Skip to content

Cache rules in front of the flag lookup - #45

Open
lbajsarowicz wants to merge 1 commit into
sansecio:mainfrom
lbajsarowicz:perf/rules-cache-layer
Open

Cache rules in front of the flag lookup#45
lbajsarowicz wants to merge 1 commit into
sansecio:mainfrom
lbajsarowicz:perf/rules-cache-layer

Conversation

@lbajsarowicz

Copy link
Copy Markdown

Problem

Plugin\Shield injects Model\Waf eagerly, and Waf::__construct() calls Rules::loadRules(). So every request that reaches the plugin does:

  1. FlagResource::load() — a point SELECT on flag by flag_code, through AbstractDb::load() (connection lookup, select build, fetch, Flag::setData()).
  2. Flag::getFlagData() — JSON decode of flag_data.

Step 2 is unavoidable; the rules have to be decoded. Step 1 is a database round trip repeated on every request for a value that only changes when the 5-minute cron writes it.

Design

The obvious move (store the rules in the cache) is the design this module already had and deliberately dropped in #13 (c5085f2, "store shield rules in flag instead of cache"), which deleted Model/Cache/Type/CacheType.php and etc/cache.xml. The failure mode that change removed: bin/magento cache:flush, a disabled cache type, or a cold cache after deploy left the WAF with no rules at all until the next cron run, a silent fail-open of up to five minutes. This PR does not reintroduce that.

The rule here is that the flag remains the only source of truth. The cache is an accelerator with no authority:

  • Read: try the cache. On a miss, an empty entry, an unreadable entry, or any error from the cache backend, load the flag exactly as before and repopulate the cache. A cache that returns nothing is indistinguishable, in behaviour, from today's code.
  • Write: saveFlag() writes the flag and then the cache, so rules synced by cron or by sansec:shield:sync-rules are live on the next request without waiting for a cache miss. deleteFlag() (403 from the API, legacy flag format) removes the cache entry too, so revoked rules cannot survive in it.
  • TTL of 300 seconds, matching the cron interval. The write-through in saveFlag() means the TTL is not what normally refreshes the entry, so on a shared backend it costs close to nothing: one extra flag read per five minutes. What it buys is a bound on staleness where the backend is not shared (see the multi-node row below) — an entry can then be at most one cron cycle behind the flag, instead of stale until someone cleans the cache.

Failure modes, compared against main:

Scenario main This PR
cache:flush / cache:clean rules unaffected cache entry gone, next request reads the flag and repopulates — rules unaffected
Cache type disabled by admin n/a not applicable: no cache type is registered, so there is no switch to disable. Were the default frontend ever a no-op, every request would simply read the flag, which is the behaviour of main
Cache backend down Magento cannot boot (config and layout caches) same; both the cache read and the repopulating write on the miss path are wrapped, so a throwing backend degrades to the flag rather than turning Waf::__construct() into a 500
Multi-node deployment with a non-shared cache backend (file cache per node) n/a cron writes the flag and the cache on one node; the other nodes do not see that write and keep serving their own entry until its 300-second TTL expires, then read the flag and pick up the new rules. Staleness is bounded by one cron cycle. Magento already requires a shared cache backend across nodes for the same reason (config and layout caches behave identically), so this is not a new requirement, but it is worth naming
Flag missing or corrupt deleteFlag(), no rules until next sync identical — the cache is never consulted as an authority, and the entry is dropped with the flag

There is no state in which the cache can hold rules the flag does not have, and no state in which an empty cache means an empty rule set.

Which cache: Magento\Framework\App\CacheInterface, with the entry tagged SANSEC_SHIELD, not a dedicated cache type. A dedicated type (etc/cache.xml + a TagScope subclass, i.e. exactly what #13 removed) buys an independent flush switch in Cache Management, but that switch can only be used to turn the acceleration off; it cannot fix anything, because the flag is authoritative and correctness never depends on the cache. The cost is two files, an admin-visible toggle that does nothing an admin would want, and the reappearance of the machinery this project already decided to delete. CacheInterface writes to the default frontend, which is the same backend (Redis in most production topologies), respects the existing tag-based cleaning, and adds no configuration surface. Core uses dedicated cache types where the cache is the storage (config, layout, block HTML); this is not that case.

Serialization: the cache payload goes through the module's Model\Serializer (already injected into Rules via etc/di.xml), so the cached bytes are the same JSON, with the same JSON_INVALID_UTF8_SUBSTITUTE behaviour, as what Flag stores in flag_data.

Out of scope: Waf's constructor still loads rules eagerly, signature verification, the fetch, and the cron schedule are untouched.

Change

  • Model/Rules.php: inject Magento\Framework\App\CacheInterface; split loadRules() into a cache read, a flag read, and a cache write with a 300-second lifetime. The read and the repopulating write are both guarded, because loadRules() runs inside Waf::__construct() and an exception there fails the whole request. saveFlag()'s write-through is deliberately left unguarded: it runs from cron and the CLI, where a cache error should surface in the log.
  • Test/Model/RulesTest.php (new): cache hit never touches the flag resource; a cache miss loads the flag and populates the cache; a corrupt cache entry falls back to the flag; a cache write that throws still returns the flag rules; a missing flag is not cached; saveFlag() writes the cache; deleteFlag() removes it; the legacy (non-array) flag format still deletes both.

Measurements

Be clear about what is and is not measured here.

What was measured: the decode step, in isolation, on PHP 8.3 (php:8.3-cli-alpine), Test/fixture/testrules.json (12,464 bytes, 37 rules), 2,000 iterations after a warm-up:

Operation Per call
json_decode($json, true) 0.0371 ms
Serializer::unserialize() 0.0369 ms
json_encode(..., JSON_INVALID_UTF8_SUBSTITUTE) 0.0077 ms
Serializer::serialize() 0.0075 ms

The point of that table is that the decode is identical either way. Flag::getFlagData() json-decodes flag_data; the cache path json-decodes the cached string. The module Serializer costs the same as raw json_decode. This PR saves nothing on decoding. The entire benefit is the storage round trip.

What is estimated, not measured: Magento cannot be booted in this environment, so the storage round trip is not benchmarked here. Order-of-magnitude figures from the usual public numbers for these paths:

Path Typical
MySQL point SELECT via AbstractDb::load(), DB on the same host ~0.2–0.5 ms
MySQL point SELECT via AbstractDb::load(), DB over the network ~0.5–2 ms
Redis GET, same host or same LAN ~0.1–0.3 ms
File cache read, warm page cache tens of microseconds

Treat these as estimates. They are not from a run on this branch.

What follows honestly from them:

  • File-based cache, MySQL on the same host: the benefit is marginal. A local point SELECT is already fast, and swapping it for a file read saves a fraction of a millisecond. If that is the topology, this PR is close to a no-op on latency.
  • Remote database plus Redis (the common production topology) is where this pays. Replacing a ~0.5–2 ms network round trip to MySQL with a ~0.1–0.3 ms Redis GET, on every request that reaches the plugin, is a real saving, and it removes one query per request from the database's connection budget.
  • The saving does not scale with rule-set size. Decode cost does, and decode cost is unchanged.

Anyone with a production install can confirm the direction cheaply: the flag SELECT for sansec_shield_rules disappears from the query log on every request after the first.

Testing

  • vendor/bin/phpunit --fail-on-warning Test — 65 tests, 80 assertions, green (PHP 8.3; the new tests use only PHPUnit 8 APIs).
  • php -l across the module and xmllint --noout on etc/**/*.xml.
  • No XML was added or changed.

Every request that reaches the Shield plugin loads the sansec_shield_rules
flag, which is a point SELECT on the flag table plus Flag model hydration.
Put Magento's cache in front of it as a read accelerator: the flag stays
the source of truth, a cache miss or a failing cache backend falls back to
it, and both saveFlag() and deleteFlag() keep the cache in step. A 300
second lifetime bounds staleness to one cron cycle on cache backends that
are not shared between nodes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant