QueryCache
in package
Wire-payload query cache with tag versions.
Caches the finished wire payload (the exact output of extractAll() or
extractWithReferences()) at the Api presenter boundary. An entity is never built from the
cache. Stored value: { "f": format, "v": {tag to version snapshot}, "payload": ... }.
Read flow (remember()):
- Guards in one place:
isServiceEnabled(),$useCache(per-call switch), theNonCacheableFiltergate and the per-request Redis unavailability flag. - Snapshot of the tag versions with
MGETbefore the database query. - Hit (stored
vequals the current snapshot andfmatches) returns the payload. Miss takes the single-flight lock (produceAndStore()), runs the producer (database plus extract) and stores withSETEXand a ±10 % jitter (TTL per service: Config::queryTtlFor()). - Fail-open: the first
PredisExceptionturns the cache off for the rest of the request (no repeated timeouts), so a hosting without Redis works unchanged.
IMPORTANT: the snapshot is taken before the database read, also on the lock path, and a payload
obtained by waiting goes through the normal version validation. A foreign commit plus bump
between the snapshot and SETEX then leaves the payload with an old snapshot, so the next read
is a miss, never a stale hit.
Hit, miss, set and bump counters are kept in Redis hashes (CacheStats), fire-and-forget.
Table of Contents
Constants
- FORMAT_VERSION : mixed = 1
- Payload format version; a mismatch is a miss (a safe bump when the shape changes).
- LOCK_POLL_ATTEMPTS : mixed = 3
- Single-flight: how many times a waiting request polls the cache before falling back to the database (without storing).
- LOCK_POLL_INTERVAL_US : mixed = 100000
- Single-flight: pause between polls in microseconds (about 100 ms).
Properties
- $audience : FieldAudienceContext|null
- $compression : CompressionHelper
- $config : Config
- $dependencyResolver : DependencyTagResolver
- $keyFactory : QueryCacheKeyFactory
- $redis : ClientInterface
- $redisUnavailable : bool
- Per-request memo of Redis unavailability (the first `PredisException` turns the cache off).
- $stats : CacheStats
- $tagVersions : TagVersions
Methods
- __construct() : mixed
- clearOwn() : void
- Deletes the own keyspace manually (`SCAN` over `{prefix}cache:*`, never `FLUSHALL`).
-
getStats()
: array{total: array
, services: array >} - Returns the counters report (aggregated stats hashes plus the hit rate in percent).
- remember() : array<string, mixed>
- Wraps a read query in the cache. `$producer` returns the finished wire payload (the output of `extractAll()` or `extractWithReferences()`); it runs only on a miss or when the cache is off.
- rememberTagged() : array<string, mixed>
- Caches the own read actions of Api presenters (not getAll/get/getBy): prices, counts, aggregates.
- acquireLock() : bool
- Single-flight: `SET {lockKey} 1 NX PX lockMs`. Returns true when the lock was acquired.
- audienceKeyPart() : array{__audience?: string}
- Returns the audience part of the cache key.
- filtersAllowCaching() : bool
- `NonCacheableFilter` gate (Elasticsearch search), recursive into `GroupFilter`.
- lockKey() : string
- Derives the lock key from the query key: `{prefix}cache:q:...` becomes `{prefix}cache:lock:...` (it falls under the cache prefix, so `clearOwn()` cleans it up and the `PX` TTL never leaves it hanging).
- markUnavailable() : void
- pollForPayload() : array<string, mixed>|null
- Polls the cache briefly until the lock holder fills the payload (at most `LOCK_POLL_ATTEMPTS` times `LOCK_POLL_INTERVAL_US`). The payload must pass the normal version validation against the snapshot taken before the database read, so it is never stale. Returns the payload, or null on timeout or a Redis error.
- produceAndStore() : array<string, mixed>
- Miss path: single-flight lock against a thundering herd, then stores the payload.
- readPayload() : array<string, mixed>|null
- releaseLock() : void
- Deletes the lock after storing the payload; best effort (an error is ignored, the lock TTL cleans it up anyway).
- storePayload() : bool
- tryStore() : void
- Stores the payload (`SETEX` with jitter) and records the counters (miss reason plus set).
Constants
FORMAT_VERSION
Payload format version; a mismatch is a miss (a safe bump when the shape changes).
public
mixed
FORMAT_VERSION
= 1
LOCK_POLL_ATTEMPTS
Single-flight: how many times a waiting request polls the cache before falling back to the database (without storing).
private
mixed
LOCK_POLL_ATTEMPTS
= 3
LOCK_POLL_INTERVAL_US
Single-flight: pause between polls in microseconds (about 100 ms).
private
mixed
LOCK_POLL_INTERVAL_US
= 100000
Properties
$audience read-only
private
FieldAudienceContext|null
$audience
= null
$compression read-only
private
CompressionHelper
$compression
$config read-only
private
Config
$config
$dependencyResolver read-only
private
DependencyTagResolver
$dependencyResolver
$keyFactory read-only
private
QueryCacheKeyFactory
$keyFactory
$redis read-only
private
ClientInterface
$redis
$redisUnavailable
Per-request memo of Redis unavailability (the first `PredisException` turns the cache off).
private
bool
$redisUnavailable
= false
$stats read-only
private
CacheStats
$stats
$tagVersions read-only
private
TagVersions
$tagVersions
Methods
__construct()
public
__construct(ClientInterface $redis, Config $config, QueryCacheKeyFactory $keyFactory, DependencyTagResolver $dependencyResolver, TagVersions $tagVersions, CompressionHelper $compression, CacheStats $stats[, FieldAudienceContext|null $audience = null ]) : mixed
Parameters
- $redis : ClientInterface
- $config : Config
- $keyFactory : QueryCacheKeyFactory
- $dependencyResolver : DependencyTagResolver
- $tagVersions : TagVersions
- $compression : CompressionHelper
- $stats : CacheStats
- $audience : FieldAudienceContext|null = null
clearOwn()
Deletes the own keyspace manually (`SCAN` over `{prefix}cache:*`, never `FLUSHALL`).
public
clearOwn() : void
Fail-open.
getStats()
Returns the counters report (aggregated stats hashes plus the hit rate in percent).
public
getStats() : array{total: array, services: array>}
Manual check: redis-cli HGETALL {prefix}cache:stats:_total.
Return values
array{total: arrayremember()
Wraps a read query in the cache. `$producer` returns the finished wire payload (the output of `extractAll()` or `extractWithReferences()`); it runs only on a miss or when the cache is off.
public
remember(string $serviceClass, class-string $rootClass, string $method, array<string, mixed> $keyParams, string|array<int, mixed> $includes, array<int, mixed> $filters, array<string, mixed>|null $orderBys, callable(): array<string, mixed> $producer[, bool $useCache = true ]) : array<string, mixed>
Parameters
- $serviceClass : string
- $rootClass : class-string
- $method : string
- $keyParams : array<string, mixed>
-
raw filters, order, limit and offset (go into the key)
- $includes : string|array<int, mixed>
-
dotted relation paths
- $filters : array<int, mixed>
-
rebuilt filter objects (
NonCacheableFiltergate and dependency paths) - $orderBys : array<string, mixed>|null
-
order paths used as dependencies
- $producer : callable(): array<string, mixed>
- $useCache : bool = true
-
per-call switch; false reads only from the producer (the database) and touches Redis with no command at all, not even for counters, so it is the very first guard, before
isServiceEnabled(). It is the last parameter because$produceris required (an optional parameter before a required one is deprecated); all callers pass it by name.
Return values
array<string, mixed>rememberTagged()
Caches the own read actions of Api presenters (not getAll/get/getBy): prices, counts, aggregates.
public
rememberTagged(string $serviceClass, string $method, array<string, mixed> $keyParams, array<int, class-string> $tags, callable(): array<string, mixed> $producer[, bool $useCache = true ][, callable(array<string, mixed>): Array|null $ttlCap = null ]) : array<string, mixed>
These actions have no includes or filters to derive tags from, so the caller passes the tags explicitly (FQCNs of the Api entities the result reads, including owned collections and code lists). Same guards, envelope, tag snapshot before the database and single-flight as remember().
$ttlCap handles changes without a write (a scheduled price, an expiry): it receives the
finished payload and returns the maximum lifetime in seconds (null means the normal TTL);
a value of 0 or less means do not store. Jitter may only shorten the TTL, so a payload never
outlives the cap.
IMPORTANT: $keyParams must carry every input the result depends on (currency, region,
group, date, ...); user-specific data does not belong here. Do not cache an error result:
the producer must throw instead of returning an empty value, otherwise the empty value is
served from the cache until it expires.
Parameters
- $serviceClass : string
- $method : string
- $keyParams : array<string, mixed>
- $tags : array<int, class-string>
- $producer : callable(): array<string, mixed>
- $useCache : bool = true
- $ttlCap : callable(array<string, mixed>): Array|null = null
Return values
array<string, mixed>acquireLock()
Single-flight: `SET {lockKey} 1 NX PX lockMs`. Returns true when the lock was acquired.
private
acquireLock(string $lockKey) : bool
Predis returns the status 'OK' on success and null when the NX condition prevents the write.
Parameters
- $lockKey : string
Return values
boolaudienceKeyPart()
Returns the audience part of the cache key.
private
audienceKeyPart() : array{__audience?: string}
IMPORTANT: the cached payload is the hydrator output, which differs by caller audience (the
emit filter FieldAccess runs inside the producer).
Without this key part an internal front call (sees Audience::Internal, e.g. an advert
password hash) or a privileged external caller would fill the cache and the next external
caller would get fields its emit does not allow. The key therefore carries the audience
class (at most 4 variants per query); an unknown audience (cron, CLI) adds nothing.
Return values
array{__audience?: string}filtersAllowCaching()
`NonCacheableFilter` gate (Elasticsearch search), recursive into `GroupFilter`.
private
filtersAllowCaching(array<int, mixed> $filters) : bool
Parameters
- $filters : array<int, mixed>
Return values
boollockKey()
Derives the lock key from the query key: `{prefix}cache:q:...` becomes `{prefix}cache:lock:...` (it falls under the cache prefix, so `clearOwn()` cleans it up and the `PX` TTL never leaves it hanging).
private
lockKey(string $key) : string
Parameters
- $key : string
Return values
stringmarkUnavailable()
private
markUnavailable() : void
pollForPayload()
Polls the cache briefly until the lock holder fills the payload (at most `LOCK_POLL_ATTEMPTS` times `LOCK_POLL_INTERVAL_US`). The payload must pass the normal version validation against the snapshot taken before the database read, so it is never stale. Returns the payload, or null on timeout or a Redis error.
private
pollForPayload(string $key, array<string, int> $snapshot) : array<string, mixed>|null
Parameters
- $key : string
- $snapshot : array<string, int>
Return values
array<string, mixed>|nullproduceAndStore()
Miss path: single-flight lock against a thundering herd, then stores the payload.
private
produceAndStore(string $serviceClass, string|null $key, array<string, int>|null $snapshot, string $missReason, callable(): array<string, mixed> $producer[, Closure|null $ttlCap = null ]) : array<string, mixed>
- Lock disabled (
lockMsis 0) or no key or snapshot: direct database read plus store. - Lock holder: database read,
SETEXof the payload,DELof the lock (best effort). - Lock not acquired: polls the cache briefly (with version validation) and returns the payload, otherwise reads the database without storing so the stampede is not doubled. A Redis error on the lock goes straight to the database (fail-open).
Parameters
- $serviceClass : string
- $key : string|null
- $snapshot : array<string, int>|null
- $missReason : string
- $producer : callable(): array<string, mixed>
- $ttlCap : Closure|null = null
Return values
array<string, mixed>readPayload()
private
readPayload(string $key, array<string, int> $snapshot, string &$reason) : array<string, mixed>|null
Parameters
- $key : string
- $snapshot : array<string, int>
- $reason : string
-
out parameter, the miss reason:
miss_cold(missing or unusable) ormiss_invalidated(tag bump or format bump); unchanged on a hit
Return values
array<string, mixed>|null —payload, or null on a miss
releaseLock()
Deletes the lock after storing the payload; best effort (an error is ignored, the lock TTL cleans it up anyway).
private
releaseLock(string $lockKey) : void
Parameters
- $lockKey : string
storePayload()
private
storePayload(string $serviceClass, string|null $key, array<string, int> $snapshot, array<string, mixed> $payload[, int|null $ttlCap = null ]) : bool
Parameters
- $serviceClass : string
- $key : string|null
- $snapshot : array<string, int>
- $payload : array<string, mixed>
- $ttlCap : int|null = null
Return values
booltryStore()
Stores the payload (`SETEX` with jitter) and records the counters (miss reason plus set).
private
tryStore(string $serviceClass, string|null $key, array<string, int>|null $snapshot, string $missReason, array<string, mixed> $payload[, Closure|null $ttlCap = null ]) : void
Parameters
- $serviceClass : string
- $key : string|null
- $snapshot : array<string, int>|null
- $missReason : string
- $payload : array<string, mixed>
- $ttlCap : Closure|null = null