API Index — Namespaces, Packages, Reports, Indices
TieredBackend
in package
implements
StoreBackend
Three-tier-ready hybrid backend — TableBackend as L1 (in-process,
ns latency, bounded staleness) + RedisBackend as L2 (cross-node,
source of truth, ~µs to ~ms).
Read path: L1 first; if entry is fresh (within $l1Ttl seconds), return
it. Otherwise fetch from L2, populate L1, return.
Write path: write-through to L2 (source of truth), then refresh the L1
entry. Concurrent writers across nodes converge at L2; local L1 stays
bounded-stale by $l1Ttl. For instant cross-node L1 eviction layer
RedisPubSub invalidation on top (L1InvalidationConsumer, separate
file — Phase 3).
Staleness bookkeeping: every L1 row gets a synthetic __cached_at INT
column appended to the schema at make() time. The L1 schema is
$columns + ['__cached_at' => [TYPE_INT, 8]]. The user-facing
get() strips this column before returning.
Use when you want: hot keys with ns reads, cross-node visibility for cold keys, can tolerate bounded staleness. Pick the underlying backends to match your throughput vs durability needs.
Table of Contents
Interfaces
- StoreBackend
- Behavioural contract every Store backend implements.
Constants
- CACHED_AT : mixed = '__cached_at'
Properties
- $invalidationChannels : array<string, true>
- Channels (
__l1_invalidate:{table}) for which a subscriber has been registered on the$invalidationRunner. - $invalidationRunner : RedisPubSub|null
- $invalidationSecret : string|null
- C2: Shared HMAC secret for invalidation message authentication.
- $l1 : TableBackend
- $l1Ttl : int
- $l2 : RedisBackend
- $originId : string
- Origin tag stamped on every invalidation publish — the receiver skips messages that originated from itself so writers don't re-evict their own freshly-written L1.
Methods
- __construct() : mixed
- clear() : void
- Clear all rows from both L1 and L2 for the given table.
- count() : int
- Return the authoritative row count from L2. L1 is a partial cache and must not be counted (it holds only a subset of the L2 key space).
- decr() : int|float
- Decrement
$colin L2 (authoritative), evict the stale L1 entry, and publish an invalidation to peers. Mirror ofincr(). - del() : bool
- Delete
$keyfrom both L1 and L2, then publish an invalidation to peers. - enableInvalidation() : void
- Turn on cross-node L1 invalidation. After this, every write through
TieredBackend (set/del/incr/decr/clear) PUBLISHes an origin-tagged
invalidation message on
{prefix}:__l1_invalidate:{table}; peer TieredBackend instances on other nodes receive it and evict the matching L1 entry. Self-publishes are skipped via the origin tag. - exists() : bool
- Authoritative existence check — always defers to L2.
- existsCached() : bool
- Stale-OK exists check (H8).
- get() : mixed
- Read from L1 if the entry is fresh (within
$l1Ttlseconds); otherwise fetch from L2, repopulate L1, and return. The synthetic__cached_atcolumn is stripped before returning the row. - incr() : int|float
- Increment
$colin L2 (authoritative), evict the stale L1 entry so the nextget()re-fetches the updated value, and publish an invalidation to peers. - isInvalidationAuthenticated() : bool
- True if HMAC verification is active.
- isInvalidationEnabled() : bool
- True once
enableInvalidation()has spun up the cross-node L1 invalidation subscriber. While false, peer writes do NOT evict local L1 entries — L1 staleness is bounded only by$l1Ttl(a key updated on node A serves stale from node B's L1 for up to that window). Used by theStore::tieredBootChecks()advisory to surface the silent gap. - iterate() : Generator<string, array<string, scalar>>
- Yield all rows from L2 (authoritative and complete).
-
iteratePaged()
: array{cursor: string, rows: array
>} - Return a cursor-paginated page of rows from L2.
- l1() : TableBackend
- Return the L1
TableBackendinstance (in-process shared-memory tier). - l1Ttl() : int
- Return the configured L1 TTL in seconds (maximum staleness window for L1 reads).
- l2() : RedisBackend
- Return the L2
RedisBackendinstance (cross-node source-of-truth tier). - make() : void
- Create a named table in both L1 and L2 backends.
- mget() : array<string, array<string, scalar>|null>
- Batch get: serve L1-fresh entries from the cache tier and fetch the rest from L2 in one round-trip, populating L1 on the way back.
- mset() : bool
- Batch set: write all rows to L2, then repopulate L1 with
__cached_attimestamps. Returnstrueonly when the L2 bulk write succeeds. - names() : array<int, string>
- Return the list of table names known to L2 (the authoritative registry).
- originId() : string
- Return the origin ID string stamped on invalidation publishes (hostname + PID + random suffix).
- set() : bool
- Write-through: persist
$rowto L2 first (source of truth), then refresh the L1 entry and publish an invalidation so peers evict their stale copies. - stopInvalidation() : void
- Stop the cross-node L1 invalidation subscriber coroutine.
- channel() : string
- Build the Redis pub/sub channel name for L1 invalidation of
$table. - computeHmac() : string
- Deterministic 64-bit truncated HMAC-SHA256 over
table|key|origin. - invalidationHandler() : callable
- Handler that evicts the local L1 entry for the message's key (unless self-publish).
- publishInvalidation() : void
- Publish an invalidation marker after a successful L2 write.
- scalarRow() : array<string, scalar>
- Narrow an opaque row to array<string, scalar> for the L1 schema.
Constants
CACHED_AT
private
mixed
CACHED_AT
= '__cached_at'
Properties
$invalidationChannels
Channels (__l1_invalidate:{table}) for which a subscriber has been registered
on the $invalidationRunner.
private
array<string, true>
$invalidationChannels
= []
$invalidationRunner
private
RedisPubSub|null
$invalidationRunner
= null
$invalidationSecret
C2: Shared HMAC secret for invalidation message authentication.
private
string|null
$invalidationSecret
NULL ⇒ insecure trust mode (any Redis writer can forge an evict). Set this on every node in the cluster via either constructor arg or ZEALPHP_TIERED_INVALIDATION_SECRET env var.
$l1
private
TableBackend
$l1
$l1Ttl
private
int
$l1Ttl
= 5
$l2
private
RedisBackend
$l2
$originId
Origin tag stamped on every invalidation publish — the receiver skips messages that originated from itself so writers don't re-evict their own freshly-written L1.
private
string
$originId
Methods
__construct()
public
__construct(TableBackend $l1, RedisBackend $l2[, int $l1Ttl = 5 ][, string|null $originId = null ][, string|null $invalidationSecret = null ]) : mixed
Parameters
- $l1 : TableBackend
- $l2 : RedisBackend
- $l1Ttl : int = 5
- $originId : string|null = null
- $invalidationSecret : string|null = null
clear()
Clear all rows from both L1 and L2 for the given table.
public
clear(string $name) : void
Parameters
- $name : string
count()
Return the authoritative row count from L2. L1 is a partial cache and must not be counted (it holds only a subset of the L2 key space).
public
count(string $name) : int
Parameters
- $name : string
Return values
intdecr()
Decrement $col in L2 (authoritative), evict the stale L1 entry, and
publish an invalidation to peers. Mirror of incr().
public
decr(string $name, string $key, string $col[, int|float $by = 1 ]) : int|float
Parameters
- $name : string
- $key : string
- $col : string
- $by : int|float = 1
Return values
int|floatdel()
Delete $key from both L1 and L2, then publish an invalidation to peers.
public
del(string $name, string $key) : bool
Returns the L2 delete result (true when the key existed and was removed).
Parameters
- $name : string
- $key : string
Return values
boolenableInvalidation()
Turn on cross-node L1 invalidation. After this, every write through
TieredBackend (set/del/incr/decr/clear) PUBLISHes an origin-tagged
invalidation message on {prefix}:__l1_invalidate:{table}; peer
TieredBackend instances on other nodes receive it and evict the
matching L1 entry. Self-publishes are skipped via the origin tag.
public
enableInvalidation() : void
MUST be called inside a coroutine context (spawns the subscriber cor). Idempotent — repeated calls are no-ops.
BOOT-ORDER REQUIREMENT — call enableInvalidation() AFTER every
make(), or call it once up front and make() tables afterwards:
a table make()d BEFORE this runner is up auto-subscribes itself when
enable() runs (its channel is replayed from $invalidationChannels),
and a table make()d AFTER enable() auto-subscribes at make() time.
The ONLY broken ordering is enabling, then making — which IS handled
here — but a runner that is restarted (stopInvalidation() then
enableInvalidation()) only re-subscribes channels still tracked in
$invalidationChannels. In short: register all tables AND call
enableInvalidation() during boot, before request concurrency; do not
interleave make()/stop()/enable() at runtime.
exists()
Authoritative existence check — always defers to L2.
public
exists(string $name, string $key) : bool
L1 may hold a stale entry for a key that has since been deleted on another node; only L2 is the source of truth.
Parameters
- $name : string
- $key : string
Return values
boolexistsCached()
Stale-OK exists check (H8).
public
existsCached(string $name, string $key) : bool
Returns true if L1 has a fresh entry (within $l1Ttl of now);
otherwise defers to L2 (the strict authoritative check). Use when
"probably exists" is good enough — saves a Redis round-trip on
hot read paths where the caller already tolerates $l1Ttl-bounded
staleness for get().
The strict exists() always hits L2 (consistency); this variant
trades that consistency for speed at the caller's explicit request.
Never returns a false positive (L1 entries are always set after a
confirmed L2 set/get; stale-but-still-present is the only window).
Parameters
- $name : string
- $key : string
Return values
boolget()
Read from L1 if the entry is fresh (within $l1Ttl seconds); otherwise
fetch from L2, repopulate L1, and return. The synthetic __cached_at
column is stripped before returning the row.
public
get(string $name, string $key[, string|null $field = null ]) : mixed
Returns null (field lookup) or false (full row) on miss, matching
TableBackend::get() semantics.
Parameters
- $name : string
- $key : string
- $field : string|null = null
incr()
Increment $col in L2 (authoritative), evict the stale L1 entry so the
next get() re-fetches the updated value, and publish an invalidation to peers.
public
incr(string $name, string $key, string $col[, int|float $by = 1 ]) : int|float
Parameters
- $name : string
- $key : string
- $col : string
- $by : int|float = 1
Return values
int|floatisInvalidationAuthenticated()
True if HMAC verification is active.
public
isInvalidationAuthenticated() : bool
Return values
boolisInvalidationEnabled()
True once enableInvalidation() has spun up the cross-node L1
invalidation subscriber. While false, peer writes do NOT evict local L1
entries — L1 staleness is bounded only by $l1Ttl (a key updated on
node A serves stale from node B's L1 for up to that window). Used by the
Store::tieredBootChecks() advisory to surface the silent gap.
public
isInvalidationEnabled() : bool
Return values
booliterate()
Yield all rows from L2 (authoritative and complete).
public
iterate(string $name) : Generator<string, array<string, scalar>>
L1 is not iterated — it holds only a hot subset of the L2 key space.
Parameters
- $name : string
Return values
Generator<string, array<string, scalar>>iteratePaged()
Return a cursor-paginated page of rows from L2.
public
iteratePaged(string $name[, string $cursor = '0' ][, int $count = 100 ]) : array{cursor: string, rows: array>}
Same reasoning as iterate(): L2 is the authoritative source.
Parameters
- $name : string
- $cursor : string = '0'
- $count : int = 100
Return values
array{cursor: string, rows: arrayl1()
Return the L1 TableBackend instance (in-process shared-memory tier).
public
l1() : TableBackend
Return values
TableBackendl1Ttl()
Return the configured L1 TTL in seconds (maximum staleness window for L1 reads).
public
l1Ttl() : int
Return values
intl2()
Return the L2 RedisBackend instance (cross-node source-of-truth tier).
public
l2() : RedisBackend
Return values
RedisBackendmake()
Create a named table in both L1 and L2 backends.
public
make(string $name, int $maxRows, array<string|int, mixed> $columns[, array<string|int, mixed> $opts = [] ]) : void
The L1 schema receives a synthetic __cached_at INT column appended
to $columns for staleness bookkeeping; user-facing get() strips it.
Registers the invalidation channel for the new table; if enableInvalidation()
has already been called, the subscriber is registered immediately.
Parameters
- $name : string
- $maxRows : int
- $columns : array<string|int, mixed>
- $opts : array<string|int, mixed> = []
-
backend-specific: mode/ttl/etc.
mget()
Batch get: serve L1-fresh entries from the cache tier and fetch the rest from L2 in one round-trip, populating L1 on the way back.
public
mget(string $name, array<string|int, mixed> $keys) : array<string, array<string, scalar>|null>
Returns an array keyed by the original $keys; values are the row arrays
(with __cached_at stripped) or null for keys not found in either tier.
Parameters
- $name : string
- $keys : array<string|int, mixed>
Return values
array<string, array<string, scalar>|null>mset()
Batch set: write all rows to L2, then repopulate L1 with __cached_at
timestamps. Returns true only when the L2 bulk write succeeds.
public
mset(string $name, array<string|int, mixed> $rows) : bool
Parameters
- $name : string
- $rows : array<string|int, mixed>
Return values
boolnames()
Return the list of table names known to L2 (the authoritative registry).
public
names() : array<int, string>
Return values
array<int, string>originId()
Return the origin ID string stamped on invalidation publishes (hostname + PID + random suffix).
public
originId() : string
Return values
stringset()
Write-through: persist $row to L2 first (source of truth), then refresh
the L1 entry and publish an invalidation so peers evict their stale copies.
public
set(string $name, string $key, array<string|int, mixed> $row) : bool
Returns false only when the L2 write itself fails.
Parameters
- $name : string
- $key : string
- $row : array<string|int, mixed>
-
Column-name → value map. All declared columns must be present.
Return values
boolstopInvalidation()
Stop the cross-node L1 invalidation subscriber coroutine.
public
stopInvalidation() : void
After this call, peer writes will no longer evict local L1 entries;
L1 staleness reverts to the $l1Ttl window. Safe to call when no
invalidation runner is active (no-op).
channel()
Build the Redis pub/sub channel name for L1 invalidation of $table.
private
channel(string $table) : string
Parameters
- $table : string
Return values
stringcomputeHmac()
Deterministic 64-bit truncated HMAC-SHA256 over table|key|origin.
private
computeHmac(string $table, string $key, string $origin) : string
64 bits is plenty for cache-invalidation forgery resistance — an attacker would need ~2^32 trials to land a collision, infeasible within any reasonable cache window. We don't need 256-bit MAC resistance because the payload is not a credential.
Parameters
- $table : string
- $key : string
- $origin : string
Return values
stringinvalidationHandler()
Handler that evicts the local L1 entry for the message's key (unless self-publish).
private
invalidationHandler() : callable
Return values
callablepublishInvalidation()
Publish an invalidation marker after a successful L2 write.
private
publishInvalidation(string $table, string $key) : void
Parameters
- $table : string
- $key : string
scalarRow()
Narrow an opaque row to array<string, scalar> for the L1 schema.
private
static scalarRow(array<int|string, mixed> $row) : array<string, scalar>
Non-scalar values are stringified; nulls become ''.
Parameters
- $row : array<int|string, mixed>