Skip to content

Query cache configuration

This page configures the global (L2) query result cache — the tier that survives a request boundary and, driver permitting, is shared across processes and hosts. For what the caches do and how to opt a query into them, start at Query caches.

The whole section is optional. An absent cache key gives you exactly the request-scoped-only behaviour, with no shared tier built and none consulted.

Add it to the same runtime configuration array Propulsion::init() reads:

runtime-conf.php
return [
'datasources' => [ /* ... */ ],
'cache' => [
'query' => [
'enabled' => true,
'driver' => 'apcu', // '' | 'null' | 'array' | 'apcu' | 'file' | 'psr16'
'ttl' => 300, // seconds; null = never expire (discouraged)
'namespace' => 'myapp', // key prefix, if several apps share one backend
'admission' => [
'min_sightings' => 2,
'window' => 300,
],
'stampede' => [
'beta' => 1.0,
'lock_ttl' => 5,
],
// the selected driver's own options, under its own name
'file' => [
'directory' => '/var/cache/propulsion',
],
],
],
];
KeyTypeDefaultDescription
cache.query.enabledboolfalseMaster switch. When false the shared tier is never consulted, whatever else is set.
cache.query.driverstring'''' or 'null' (off), 'array', 'apcu', 'file', or 'psr16' for a pool you register yourself.
cache.query.ttlint|null300Result-entry TTL in seconds. null means no expiry, which is discouraged — see Invalidation.
cache.query.namespacestringpropulsionKey prefix. 1–24 characters of A-Za-z0-9_. — PSR-16 caps keys at 64 characters, and the digest that follows needs the rest.
cache.query.admission.min_sightingsint2How many times a query must be seen before its result is stored. Minimum 1.
cache.query.admission.windowint|nullttlTTL of the sighting markers, in seconds.
cache.query.stampede.betafloat1.0Early-recomputation aggressiveness. 0.0 disables it. Must not be negative.
cache.query.stampede.lock_ttlint5Single-flight lock TTL in seconds. Minimum 1.
cache.query.array.max_entriesint1000array driver only. Minimum 1.
cache.query.file.directorystringfile driver only, required.
cache.query.file.levelsint2Shard depth, 0–3. At 2 that’s 65,536 leaf directories.
cache.query.file.max_bytesint|nullnullSize ceiling enforced by prune().
cache.query.file.dir_modeint0o770Mode for directories the driver creates.
cache.query.file.file_modeint0o660Mode for entry files.

Propulsion ships no Redis or Memcached client. Both protocols already have several mature PSR-16 implementations, and owning reconnection, cluster and sentinel topologies, TLS, and protocol versions to duplicate them would be pure maintenance cost. Register any third-party pool instead:

use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Cache\Psr16Cache;
Propulsion::setQueryCachePool(new Psr16Cache(new RedisAdapter($redis)));

A pool registered this way always wins over cache.query.driver. Set driver: 'psr16' to make that explicit in the config file — and note that psr16 with nothing registered leaves the shared tier inert rather than failing queries over a misconfiguration.

The shipped drivers under Propulsion\Cache\Driver are ordinary PSR-16 implementations too, so you can construct one yourself and pass it here instead of configuring it by name.

Related accessors: Propulsion::hasQueryCachePool(), Propulsion::queryCachePool() (never null — an unconfigured deployment gets a NullCache null object), and Propulsion::getQueryCacheConfig() for the parsed section.

The three real drivers land within ~20% of each other on the hit path, so choose on sharing semantics, not speed.

DriverShared acrossSurvives restartNotes
arraynothingnoIn-process only, and under a threaded worker, per-thread.
apcuevery process and thread on one hostnoInvisible to CLI.
fileeverything on one host, including CLIyesSlow writes; needs pruning.
psr16whatever your pool doesThe answer for multi-node.
null / ''The “off” null object.

Fastest, and in a single-threaded long-lived worker it’s a genuine cross-request cache. Two limits, both silent:

  • Not cross-process. Two php-fpm workers never see each other’s writes — or, more dangerously, each other’s invalidations.
  • Not cross-thread. PHP memory belongs to the thread, so with FrankenPHP’s worker <n> above 1, each thread accumulates its own entries. Propulsion’s own worker harness measures this: of 16 concurrently-written keys, only 7 were visible to the reading thread.

Always bounded by max_entries (default 1000, oldest-first). An unbounded array in a process that never exits is an out-of-memory condition waiting for enough distinct keys.

The realistic zero-infrastructure choice for a single host: shared by every php-fpm worker and every worker thread, with lookups in already-mapped shared memory. It takes no options. Two things to know:

  • A php-fpm restart discards the whole segment, so a deploy cold-starts the cache.
  • CLI processes do not share it. apc.enable_cli=1 gives each CLI invocation its own segment. A cron job therefore cannot see, warm, or — the dangerous one — invalidate the web tier’s entries. If anything writes to your database from CLI, don’t use this driver.

Capacity is apc.shm_size; APCu evicts under pressure on its own. The driver throws at construction if the extension is missing or apcu_enabled() is false, rather than degrading to a cache that never hits.

It is not “about as fast as APCu”. A warm-page-cache read is still open/read/close — three or four syscalls, each paying a mode switch and a VFS walk, under PHP’s stream layer — against APCu’s hash probe in mapped memory. Expect roughly 3–10× slower reads, and considerably worse writes (~769µs to store an entry and ~98µs to bump a table version, against ~80µs and ~1.5µs for the in-memory drivers), because every write is an atomic temp-file-plus-rename.

Choose it for what it uniquely does, not for speed:

  1. No infrastructure. Works on shared hosting, in a bare container.
  2. Survives restarts, unlike APCu.
  3. Shared across SAPIs — the one thing APCu genuinely cannot do. A cron job writing to the database can invalidate the web tier’s cached queries here. For applications with CLI writers this driver is more correct, not just slower.
'file' => [
'directory' => '/var/cache/propulsion',
'levels' => 2,
'max_bytes' => 512 * 1024 * 1024,
],

Expired entries are unlinked lazily, when something asks for them. Entries that expire and are never requested again occupy disk until you prune, so schedule prune():

// bin/propulsion-cache-prune, from cron
$cache = Propulsion::queryCachePool();
if ($cache instanceof \Propulsion\Cache\Driver\FileCache) {
$cache->prune(); // drops expired entries and orphaned temp files, then enforces max_bytes
}

Propulsion deliberately does not prune probabilistically inside requests: that turns one unlucky user’s request into a full-tree stat walk.

Two safety properties worth knowing about:

  • clear() refuses to run unless it finds the .propulsion-cache marker file the driver wrote at construction. A misconfigured directory pointing at a document root would otherwise turn a routine flush into data loss.
  • The driver deserializes with allowed_classes: false. Its whole selling point is being shared across processes and SAPIs, which means its directory is writable by everything using it — possibly including another application — so what comes back out is untrusted input. Running unserialize() with objects allowed would hand anyone who could drop a file in there a __wakeup()/__destruct() gadget-chain foothold in every reader. Nothing Propulsion stores through the driver is an object (row arrays, version tokens, admission counters), and a class payload reaching the shared cache is rejected as “not a row set” and degrades to a clean miss. This narrows exactly one use: treating the driver as a general-purpose PSR-16 pool for your own objects.

Propulsion cannot bound a pool it doesn’t own. Configure maxmemory and an LRU policy on Redis yourself (maxmemory-policy allkeys-lru); without it, a diverse query workload grows the keyspace until Redis refuses writes.

One capability is lost with a third-party pool: PSR-16 has no atomic create-if-absent, so strict single-flight is impossible. Propulsion detects this (Propulsion\Cache\AtomicCache) and falls back to the probabilistic defence below.

Two different failure modes, two different mechanisms. They’re often conflated; they are not the same problem.

A query whose parameters never repeat — WHERE id = <random>, whether from a diverse workload or a deliberate flood — produces a distinct key every time. Every request misses, and if every miss also wrote, the cache would grow without bound while never serving a hit. Stampede protection does nothing here: there’s no contention on any single key.

So an entry is admitted only once its key has been seen twice within a short window, tracked by a tiny marker key. A never-repeating key never reaches a second sighting and never stores anything; a genuinely repeated query is cached from its second execution.

'admission' => [
'min_sightings' => 2, // 1 restores cache-on-first-miss for trusted workloads
'window' => 300, // marker TTL; defaults to ttl
],

The marker lookup is batched into the round trip that already fetches version tokens, so it costs no extra latency. Admission is decided before the query runs, so a rejected first execution formats straight off the live statement and keeps its streaming memory profile rather than materialising a row array it’s about to discard. An entry that already exists is exempt from the re-check, because that’s the early-refresh path below and its sighting marker has usually expired by then.

When a popular entry expires, every concurrent reader can pile onto the database at once. Two defences:

  • Probabilistic early recomputation, always on. As an entry nears expiry, each reader independently may elect to refresh it slightly early, weighted by how expensive the query was. One reader refreshes while everyone else still gets a hit. Lock-free, no extra round trip, nothing to clean up if a process dies.
  • Single-flight, where the backend supports atomic create-if-absent — all three shipped drivers do. The winner recomputes; losers serve the existing entry rather than blocking, because a cache outage must not become a latency outage.
'stampede' => [
'beta' => 1.0, // 0.0 disables early recomputation
'lock_ttl' => 5,
],

On a third-party PSR-16 pool only the probabilistic defence applies, so N truly simultaneous cold misses on one key can still produce N queries.

Turning it off in an incident is one config line — enabled => false — and needs no code change or redeploy of application logic.

Sizing. apcu: apc.shm_size. file: max_bytes plus a pruning cron. array: max_entries. Redis: maxmemory and allkeys-lru, which only you can set.

Monitoring. A cache that never hits is pure overhead; a cache that hits constantly on stale data is worse. Instrument at the pool: wrap your PSR-16 implementation in a decorator that counts hits and misses, and register that with Propulsion::setQueryCachePool().

Worker mode. The pool is process-scoped and deliberately survives Session::reset(); the request-scoped tier does not. Session::reset() never calls clear() on the backend — doing so would flush every other process’s cache at the end of every request. See Running under a persistent worker.

Performance, for expectation-setting. An L2 hit costs ~45µs against ~89µs uncached and ~6µs for an L1 hit, measured against in-memory SQLite. The two tiers are complementary: L2 skips the query but still hydrates, while L1 skips both. That 2× headline understates production, though — the benchmark’s baseline is a database with no round trip, so it largely measures hydration against a free query. Against a networked PostgreSQL the same hit avoids 0.5–2ms.