Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.
API Index — Namespaces, Packages, Reports, Indices

TieredBackend
in package
implements StoreBackend

FinalYes

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 $col in L2 (authoritative), evict the stale L1 entry, and publish an invalidation to peers. Mirror of incr().
del()  : bool
Delete $key from 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 $l1Ttl seconds); otherwise fetch from L2, repopulate L1, and return. The synthetic __cached_at column is stripped before returning the row.
incr()  : int|float
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.
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 the Store::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 TableBackend instance (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 RedisBackend instance (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_at timestamps. Returns true only 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 $row to 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

Properties

$invalidationChannels

Channels (__l1_invalidate:{table}) for which a subscriber has been registered on the $invalidationRunner.

private array<string, true> $invalidationChannels = []

$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.

$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
int

decr()

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

del()

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
bool

enableInvalidation()

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
bool

existsCached()

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
bool

get()

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

isInvalidationAuthenticated()

True if HMAC verification is active.

public isInvalidationAuthenticated() : bool
Return values
bool

isInvalidationEnabled()

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
bool

iterate()

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

l1Ttl()

Return the configured L1 TTL in seconds (maximum staleness window for L1 reads).

public l1Ttl() : int
Return values
int

make()

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
bool

names()

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
string

set()

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
bool

stopInvalidation()

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
string

computeHmac()

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
string

invalidationHandler()

Handler that evicts the local L1 entry for the message's key (unless self-publish).

private invalidationHandler() : callable
Return values
callable

publishInvalidation()

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>
Return values
array<string, scalar>
On this page