CoolCMS API

QueryCache
in package

FinalYes

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()):

  1. Guards in one place: isServiceEnabled(), $useCache (per-call switch), the NonCacheableFilter gate and the per-request Redis unavailability flag.
  2. Snapshot of the tag versions with MGET before the database query.
  3. Hit (stored v equals the current snapshot and f matches) returns the payload. Miss takes the single-flight lock (produceAndStore()), runs the producer (database plus extract) and stores with SETEX and a ±10 % jitter (TTL per service: Config::queryTtlFor()).
  4. Fail-open: the first PredisException turns 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

$redisUnavailable

Per-request memo of Redis unavailability (the first `PredisException` turns the cache off).

private bool $redisUnavailable = false

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: array, services: array>}

remember()

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 (NonCacheableFilter gate 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 $producer is 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
bool

audienceKeyPart()

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
bool

lockKey()

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
string

markUnavailable()

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>|null

produceAndStore()

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 (lockMs is 0) or no key or snapshot: direct database read plus store.
  • Lock holder: database read, SETEX of the payload, DEL of 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) or miss_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
bool

tryStore()

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
On this page

Search results