Skip to content

Query caches

Propulsion has three independent, opt-in caches. Two of them cache result rows at different scopes, and the third caches a query’s compiled SQL string:

CacheScopeStoresOpt in with
L1 result cacheOne requestThe formatted result — collection, object, or countsetQueryCache()
L2 result cacheAcross requests, and (driver permitting) across processes and hostsRaw pre-hydration rowssetQueryCache() plus a configured backend
Compiled-query cacheThe worker processThe SELECT SQL textsetCompiledQueryCache($key)

Everything here is off by default. An uncached read is always correct; a cached one is a trade you make deliberately, per query.

L1 catches a query repeated within one request — a lookup called from a loop, or from several unrelated call sites that don’t know about each other. L2 catches a query repeated across requests, which is what makes caching worth having in a worker-mode deployment where the process outlives the request.

L1 needs nothing but the per-query opt-in:

$books = BookQuery::create()
->setQueryCache()
->filterByTitle('War And Peace')
->find();

The first call executes the query and stores its formatted result — the PropulsionCollection, object, or count, exactly as find()/findOne()/count() would normally return it. A second call producing identical SQL and bound parameters returns the stored result without touching the database:

// Same generated SQL + params as above -> served from cache, no query.
$books = BookQuery::create()
->setQueryCache()
->filterByTitle('War And Peace')
->find();

L2 needs two gates, both required: a configured backend, and the same per-query opt-in above.

runtime-conf.php
return [
'datasources' => [ /* ... */ ],
'cache' => [
'query' => [
'enabled' => true,
'driver' => 'apcu',
'ttl' => 300,
],
],
];

Configuring a backend is not by itself consent to serve stale data across requests, which is why enabled defaults to false and turning it on is a config-file change — a deliberate, reviewable, deployment-level act. See Query cache configuration for every option, the driver comparison, and how to register your own PSR-16 pool.

doSelect()/doCount() on a generated Peer honor the same flag, so code that queries through the Peer layer directly gets the same caching behavior as ModelCriteria:

$c = new Criteria();
$c->setQueryCache();
$c->add(\Map\BookTableMap::TITLE, 'War And Peace');
$books = \Map\BookPeer::doSelect($c);

Hand-written SQL gets the same tiering, invalidation, and overload protection through Propulsion::rawQuery().

public function setQueryCache(bool $b = true, ?int $ttl = null, bool $shared = true)
  • $b — enable or disable caching for this query.
  • $ttl — override cache.query.ttl for this query, in seconds. Only meaningful for L2; L1 is bounded by the request itself.
  • $shared — whether this query’s result may reach L2 at all. Pass false to keep a query in the request-scoped tier only.

shared: false is what you want for a query whose SQL text is stable but whose correct answer isn’t — anything built on NOW(), CURRENT_DATE, RANDOM(), or a LIMIT over an unstable ORDER BY. Such a query has an identical cache key every time, so its first result would otherwise freeze for the whole TTL, across every process sharing the backend rather than merely for the rest of one request:

$expiring = BookQuery::create()
->setQueryCache(true, shared: false)
->where("Book.ExpiresAt < NOW() + INTERVAL '1 hour'")
->find();

Propulsion does not try to detect these by scanning SQL. That gives false positives on ordinary columns like now_at and false negatives on user-defined functions and views, and a confidently wrong detector is worse than none.

The cache key is an xxh128 digest of the datasource, the formatter, the exact SQL string, and the serialized bound parameters. Nothing about how the query object was assembled contributes, so two differently-built Criterias that compile to the same SQL and parameters share an entry, and a single-character difference in a filter value is a miss.

The formatter is part of the key because the same SQL formatted as arrays and formatted as objects are different results. Hashing rather than concatenating matters for the same reason bounded caches matter: a joined query with a sizeable IN (...) list produces several kilobytes of SQL, and holding that live as an array key costs memory on every lookup.

L2 keys additionally embed a version token per table the query reads — see below.

Every table has a version token in the shared backend, and a query’s cache key embeds the tokens of every table it reads. A write replaces that table’s token with a fresh random value, which makes every key derived from the old one unreachable in a single write — no index to maintain, no scan, no cross-process coordination.

Writes through the ORM bump the right tokens for you: save(), delete(), ModelCriteria::update()/delete()/deleteAll(), and direct BasePeer::doInsert()/doUpdate()/doDelete()/doDeleteAll()/doUpsert() calls. Invalidation is unconditional — a write to book drops every cached query that reads book, regardless of whether the write and the cached read otherwise appear related:

$books = BookQuery::create()->setQueryCache()->find(); // cached
$book = new Book();
$book->setTitle('Anna Karenina');
$book->save(); // invalidates every cached query that reads the `book` table
$books = BookQuery::create()->setQueryCache()->find(); // cache miss, re-queries

Tokens are random values rather than incrementing counters, deliberately. PSR-16 has no atomic increment, so a read-add-write counter would race: two writers both reading v=7 both write v=8, and a result cached in between stays live and stale for its whole TTL. A blind random write has no read step, so the race cannot happen. It also means losing a token always fails toward a miss rather than toward staleness — an evicted token is reseeded to a never-used value, whereas a counter reseeded to 1 would resurrect orphaned entries.

A write inside an open transaction buffers its token bump and publishes it on commit; a rollback discards it. And no query issued inside a transaction is ever published to L2, because such a SELECT can see your own uncommitted rows, and publishing those to a cache other processes read would leak them. In-transaction reads still use L1, so read-your-own-writes holds inside the transaction.

Raw SQL, another application sharing the database, a migration, a DBA at a console — none of these invalidate anything, because Propulsion never sees them. Tell it:

$con->exec('UPDATE book SET ... /* hand-written */');
Propulsion::invalidateQueryCacheForTables(['book']);

Otherwise those entries stay served until their TTL lapses. TTL is the only backstop against writes Propulsion cannot see, which is why it defaults to a finite 300 seconds rather than “never expire”.

One driver-specific case belongs here too: with the apcu driver, a CLI process has its own shared-memory segment and therefore cannot invalidate the web tier’s entries. If anything writes to your database from CLI, don’t use that driver — see the driver comparison.

Propulsion derives the dependency list from the query itself: both sides of every join, the primary table, the select columns, and any withColumn() expression — the last of those because such an expression reaches the SELECT list without contributing a FROM entry, so it can name a table appearing nowhere else. Every name is resolved through the alias map first, so a query built with setModelAlias('b', true) depends on book, which is what write paths bump, not on b.

The list does not descend into subqueries, CTEs, or set-operation branches. A query whose only reference to a table is inside one of those will not be invalidated by writes to it. Express such a query through rawQuery() with an explicit dependsOn(), or leave it uncached.

A query that ends up with no dependencies at all is refused rather than stored: an entry nothing can evict would be served until its TTL lapsed, including by invalidateQueryCacheForTables(). Such a query degrades to an ordinary uncached read.

Some results can’t be cached correctly, and Propulsion declines rather than getting it wrong:

  • FORMAT_STATEMENT and FORMAT_ON_DEMAND results, at either tier. Both are tied to a live statement, so a second hit would hand back an exhausted cursor. PropulsionFormatter::supportsRowCaching() reports this, and a cache-enabled query using either formatter simply runs uncached.
  • Results carrying BLOB stream resources skip L2 and use L1 only — a stream cannot be serialized. Every row of the result set is checked, not just the first: serialize() doesn’t fail on a resource, it quietly writes i:0, so a partial check could publish an entry with its blob columns replaced by the integer 0.
  • Queries with no table dependencies, as above.
  • Queries issued inside a transaction, at L2 only.
  • Authorization decisions. Don’t cache a query whose result gates access.
  • Object identity. An L1 hit returns the same object instances, so mutating a cached find() result mutates what the cache holds. An L2 hit returns a freshly hydrated graph and behaves exactly like a real database read, instance pool included.
  • Read-your-own-writes is guaranteed within a process. Across processes it’s bounded by commit-to-publish latency plus the per-request token memo: a bump published by another process midway through your request isn’t observed until the next one.
  • Non-deterministic SQL — use shared: false, as above.

L1 caps at QueryResultCache::MAX_ENTRIES (500 entries) with oldest-first eviction. Evicting is always safe — the worst consequence of a miss is re-running the query — and the cap is what stops a request that runs a cached query in a loop with varying parameters from retaining every result set it ever built. Note that the bound is on entry count, not bytes: 500 wide result sets is still a lot of memory.

L2’s bound belongs to its backend: max_entries for array, apc.shm_size for apcu, max_bytes plus a pruning cron for file, and maxmemory with an LRU policy for a third-party pool, which only you can set. See Sizing.

A separate cache stores the compiled SQL string rather than the rows. It’s for the case where the same query shape is rebuilt over and over with only bound values differing: the SQL text is identical every time, so re-walking joins, columns, and criterions to re-derive it is wasted work.

$books = BookQuery::create()
->setCompiledQueryCache(__METHOD__)
->filterByTitle($title)
->find();

Unlike setQueryCache()’s plain boolean, this takes a key you supply, and the key must uniquely identify the query’s shape. __METHOD__ inside a generated Query or Peer method is the natural choice, since “the same query shape rebuilt every request” is exactly what this targets.

Benchmarked at roughly 1.5× faster SQL construction for a join plus two WHERE conditions plus ORDER BY and LIMIT/OFFSET. The saving scales with how much FROM/JOIN/WHERE text there is to skip re-deriving — a single-table query sees less, a heavily-joined one more.

Everything except ordinary bound scalar values must be identical between calls sharing a key:

  • the same joins, and the same WHERE/HAVING comparisons in the same order
  • the same number of elements in any IN (...) list
  • the same LIMIT/OFFSET values — these are written as literal integers into the SQL text on every platform, not bound
  • the same literal text for any Criteria::CUSTOM raw expression

Only plain bound values may vary. This is your responsibility to get right: there’s no way to auto-derive a shape fingerprint that’s both cheaper than just building the SQL and safe against every case above, which is why the key is explicit rather than inferred.

As a safety net against the most common mistake — reusing a key for a query with a different number of bound parameters — a hit whose freshly-collected parameter count doesn’t match the count recorded when the entry was built throws a PropulsionException rather than returning mismatched SQL. That catches the common error, not every possible one: the same count with different structure slips through.

The compiled-query cache is process-scoped, and lives on ServiceContainer. Session::reset() deliberately does not clear it: an entry is a pure function of the datasource and the query shape — no bound values, no request data, nothing identifying who asked — and clearing it every request would make a worker recompile the same SQL forever, defeating the one deployment the cache exists for.

Two things follow from process scoping:

  • The datasource is part of the key. SQL text depends on the adapter for identifier quoting and LIMIT/OFFSET dialect, so two datasources running the same generated method with the documented __METHOD__ key would otherwise serve each other’s SQL. The parameter-count guard can’t catch that: the shapes match and only the dialect differs.
  • It is bounded by CompiledQueryCache::MAX_ENTRIES (1000, oldest-first), since it’s no longer emptied every request, and it’s cleared by Propulsion::setConfiguration() so a reconfiguration can’t leave the previous adapter’s SQL reachable.

Reach it through Propulsion::getServiceContainer()->getCompiledQueryCache(). Session::getCompiledQueryCache() is deprecated but still works, returning the same instance.

It has no invalidation to worry about — SQL text doesn’t go stale when a row changes.

Only the plain-SELECT path is covered. A query carrying common table expressions, set operations, or FROM-clause subqueries (addSelectQuery()) falls back to an uncached build every time — each of those recurses into further Criteria objects with their own parameters, which the fast parameters-only path this cache relies on doesn’t mirror.

setQueryCache()setCompiledQueryCache($key)
Caches rowsCaches the SQL string
Skips the database entirely on a hitStill queries the database
Keyed automatically from datasource + formatter + SQL + paramsKeyed by you, by query shape
Needs invalidation on write (handled for you)Needs no invalidation
Request-scoped, plus a cross-process tier if configuredProcess-scoped
Risk: serving a stale resultRisk: a wrong key serving mismatched SQL

They’re independent and can both be enabled on the same query.