Store & Counter
OpenSwoole adapters for cross-worker shared memory. Must be created before $app->run() so all forked workers inherit the same memory segment.
Store — OpenSwoole\Table
Row-based shared memory with per-row spinlocks. Any worker can read/write any row concurrently. Iterate all rows across workers.
Counter — OpenSwoole\Atomic
Lock-free integer. Safe for concurrent increment/decrement from all workers. Useful for metrics, rate limiting, and request counting.
Store — set / get / count
// Before app->run():
Store::make('demo_table', 128, [
'name' => [Store::TYPE_STRING, 64],
'score' => [Store::TYPE_INT, 4],
]);
// In any route (any worker):
Store::set('demo_table', 'user_1', ['name' => 'alice', 'score' => 100]);
$row = Store::get('demo_table', 'user_1');
// → ['name' => 'alice', 'score' => 100]
echo Store::count('demo_table'); // total rows across all workers
Store — atomic incr/decr
// Atomically increment a counter column
Store::set('demo_table', 'page_hits', ['score' => 0]);
$new = Store::incr('demo_table', 'page_hits', 'score');
// → 1 (atomic, safe under concurrent workers)
Counter — increment across requests
// Before app->run():
$requestCounter = new Counter(0);
// In any route:
$app->route('/demo/counter/increment', function() use ($requestCounter) {
$new = $requestCounter->increment();
return ['total_requests' => $new, 'pid' => getmypid()];
// Every worker shares the same atomic integer
});
Store API reference
| Method | Returns |
|---|---|
Store::make($name, $maxRows, $columns) | OpenSwoole\Table |
Store::set($table, $key, $row) | bool |
Store::get($table, $key, $field?) | array|mixed|false (BC: false on miss) |
Store::getStrict($table, $key, $field?) | mixed (null on miss — preferred for new code) |
Store::del($table, $key) | bool |
Store::exists($table, $key) | bool |
Store::incr($table, $key, $col, $by=1) | int (new value) |
Store::decr($table, $key, $col, $by=1) | int (new value) |
Store::count($table) | int |
Store::table($name) | OpenSwoole\Table (iterate with foreach) |
Cache — general-purpose key-value with TTL
Tiered cache built on Store. Memory tier (fast, cross-worker) + file tier (persistent, survives restarts). No Redis needed for most apps.
Cache::*
delegates to Store::set/get/del/iterate against
an internal __cache table. Flip the backend and Cache follows automatically:
Store::defaultBackend(Store::BACKEND_TABLE)→ in-memory shared via OpenSwoole\Table (single-server, ns latency)Store::defaultBackend(Store::BACKEND_REDIS)→ cross-node Redis/ValkeyStore::defaultBackend(Store::BACKEND_TIERED)→ L1 Table + L2 Redis (bounded-staleness fast read + cluster-wide truth)Store::defaultBackend(Store::BACKEND_MEMCACHED)→ flat KV cache over Memcached
.cache/ directory) is a SEPARATE layer that works alongside whichever
Store backend you've picked — it persists across restarts; Store memory state doesn't.
// Before $app->run():
Cache::init();
// Anywhere (any worker):
Cache::set('user:42', $profileArray, ttl: 300); // any PHP value, auto-serialized
$profile = Cache::get('user:42'); // memory first, file fallback
Cache::has('user:42'); // TTL-aware existence check
Cache::del('user:42'); // removes from both tiers
Cache::flush(); // clear everything
| Method | Returns | Notes |
|---|---|---|
Cache::init($maxRows?, $cacheDir?, $gcIntervalMs?) | void | Call before $app->run(). Defaults: 4096 rows, .cache/, 60s GC |
Cache::set($key, $value, ttl: $seconds) | bool | Write-through to both tiers. ttl: 0 = no expiry |
Cache::get($key, $default?) | mixed | Memory first, file fallback. Returns $default on miss |
Cache::del($key) | bool | Removes from both tiers |
Cache::has($key) | bool | Checks without deserializing. Respects TTL |
Cache::flush() | void | Clears all entries from both tiers |
Cache::count() | int | Memory tier count only |
.cache/{hash}.cache with TTL header. Expired entries are cleaned lazily on read + a periodic GC sweep every 60s on worker 0.
Consistency semantics — what's atomic, what isn't
Store is shared memory, not a database. The atomicity guarantees are narrow and important to understand before reaching for it in production.
| Operation | Atomicity | Notes |
|---|---|---|
$table->set($key, $row) — single call updating multiple fields in one row |
✅ Atomic at the C level (per-row spinlock) | Readers see either the old row or the new row, never a partial update. |
$table->get($key) / $table->get($key, 'field') |
✅ Atomic read of one row | Acquires the row lock briefly; safe under concurrent writes. |
Two $table->set($key, ['a' => 1]) + $table->set($key, ['b' => 2]) calls on the same row |
❌ Not transactional across calls | Each call is atomic individually, but a reader between them sees a half-applied update. Combine into a single set() with both fields if order matters. |
$table->incr($key, 'field', $by), $table->decr($key, 'field', $by) |
✅ Atomic — no read-then-write race | Use these for counters. Don't do get + set. |
Counter (OpenSwoole\Atomic) — compareAndSet, increment, decrement, reset, get, set |
✅ Lock-free atomic on a 64-bit signed integer | For single-value cross-worker counters, prefer this over Store. |
What happens on worker crash mid-write
The honest answer: it depends on how the worker died.
| Crash type | Effect |
|---|---|
Graceful shutdown — SIGTERM, including the max_request recycle |
✅ Worker drains current request, releases all row locks normally, exits clean. Manager forks a fresh worker. No corruption. |
SIGKILL / OOM kill / segfault mid-set() |
⚠ The row's spinlock may be left held. OpenSwoole doesn't have robust mutex-on-holder-death recovery. Other workers will spin waiting on that row until the server is fully restarted. Rare in practice (single-row writes are nanoseconds) but possible under adversarial load. |
Server hard kill (kill -9 on the master) |
Shared memory segment is destroyed entirely when the last attached process exits. Fresh segment on next start. No state survives, but no corruption either. |
Pluggable backends — Table / Redis / Tiered / Memcached
As of v0.2.39, Store and Counter are backend-agnostic. Four backends ship in-tree; pick by scope. Every Store::set/get/incr/count call works unchanged across all four.
use ZealPHP\Store;
// Recommended — type-safe enum (IDE autocomplete + refactor-safe):
Store::defaultBackend(Store::BACKEND_TABLE); // default
Store::defaultBackend(Store::BACKEND_REDIS, 'redis://cache.internal:6379'); // cross-node
Store::defaultBackend(Store::BACKEND_TIERED, [ // ns reads + cross-node truth
'url' => 'redis://cache.internal:6379',
'l1_ttl' => 5, // L1 freshness window (seconds)
'invalidation_secret' => getenv('ZEALPHP_TIERED_INVALIDATION_SECRET') ?: null,
]);
// Bare strings still work for BC (also: ZEALPHP_STORE_BACKEND=redis|tiered env):
Store::defaultBackend(Store::BACKEND_REDIS);
Store::defaultBackend(Store::BACKEND_TIERED, 'redis://cache:6379');
| Backend | Latency | Cross-node | Persistence | Bounded growth | When to pick |
|---|---|---|---|---|---|
Store::BACKEND_TABLE (default) |
~ns (lock-free shared memory) | No — one OpenSwoole server | No — volatile | HARD: maxRows at make() |
Single-node hot path. Millions of ops/sec. 95% of apps. |
Store::BACKEND_REDIS |
~tens of µs local, ~ms cross-node | Yes — any number of nodes | Yes (Redis AOF/RDB) | Server-side maxmemory + maxmemory-policy |
Horizontal scaling, persistent state, existing Redis infra. |
Store::BACKEND_TIERED |
~ns on L1 hit, ~ms on L1 miss (L2) | Yes (via L2) | Yes (via L2) | L1 capped by Table; L2 via Redis policy | Hot keys with ns reads + cross-node visibility for cold keys + tolerate l1_ttl-bounded staleness. |
Store::BACKEND_MEMCACHED |
~sub-ms local | Yes — any number of nodes | No — volatile | Server-side max_memory LRU eviction |
Flat KV cache over an existing Memcached cluster; no pub/sub or Streams support. |
Two table modes at make(): 'tracked' (default) keeps a membership SET so count() is O(1); 'ttl' supports per-key expiry but count() falls back to O(N) SCAN. Pick one per table. Mixing tracked + ttl throws at boot (v0.2.41 hardening — H1). The connection pool is per-worker (default 8 clients) — concurrent coroutines never share a socket.
Client lib: auto-detects phpredis (preferred when ext-redis is loaded) or predis (pure-PHP fallback, shipped as a dev dep). User code never imports a phpredis/predis symbol — the single ZealPHP\Store\RedisClient adapter is the only place either lib is referenced.
Backend scope — what "single process" really means
OpenSwoole\Table is shared memory allocated by the master process BEFORE fork, inherited by all worker processes of that server. So the Table backend is:
- Shared across coroutines in one worker ✓
- Shared across workers in one OpenSwoole server ✓ (the whole point)
- NOT shared across two
php app.phpinvocations on the same machine ✗ (different mmap segments) - NOT shared across machines ✗
For cross-server state, use Redis or Tiered. For cross-tab/cross-tenant WS routing where state must survive node death, see /pubsub#ws-routing.
Memory math for the Table backend
maxRows is allocated UP FRONT at the OpenSwoole master fork, not lazily. Empirically (PHP 8.3 + OpenSwoole 22.x):
| Rows | Schema | Allocated | Notes |
|---|---|---|---|
| 1,024 | 1 × STRING(32) | ~290 KB | Hash overhead dominates at small sizes |
| 1,000,000 | 1 × STRING(32) | ~280 MB | Roughly 280 B/row including overcommit + per-row mutex/metadata |
PHP_INT_MAX | any | OOM-killed | No artificial cap; the kernel decides |
Rule of thumb: RAM ≈ maxRows × (4 × Σ column sizes + ~32 B/row). The 4× factor is OpenSwoole's open-addressed-hash overcommit. For Cache::init(4096) with the default 8 KB value column: ~130 MB reserved per worker server. When you'd say "tens of millions of rows", flip to Redis (server-side bound) or Tiered (L1 stays small, L2 grows).
Cache::init & the maxRows chokepoint
Cache::init($maxRows, $cacheDir, $gcIntervalMs, $ttlSeconds) behaves differently across backends — flagged at boot when misconfigured (v0.2.41 hardening):
- Table backend:
$maxRowsis a HARD CAP. Cache spills oversize / overflow to the file tier automatically. - Redis backend:
$maxRowshas NO equivalent — Redis is a global KV store. Pair with$ttlSecondsfor per-key auto-expiry, OR configure Redis-servermaxmemory+maxmemory-policy allkeys-lrufor cluster-wide bound. If you pass a non-default$maxRowswithout a TTL on the Redis backend,Cache::init()emits a one-line warning telling you so. - Tiered backend: L1 honours
$maxRows(Table); L2 (Redis) ignores it as above.
// Recommended Redis-backed Cache config — bounded by per-key TTL:
Cache::init(
maxRows: 4096, // HARD CAP on Table; informational on Redis
cacheDir: '/var/cache',
ttlSeconds: 3600, // On Redis → mode='ttl' auto-expiry
);
// Read-through pattern — canonical helper (no more get-then-compute boilerplate):
$users = Cache::getOrCompute("users:active", fn() => DB::select(...), ttl: 60);
// First call computes + caches; subsequent calls within TTL short-circuit to the cached read.
// Null is cached as a valid value (sentinel-based miss detection).
Pub/sub + Streams (cross-node messaging)
Two public primitives on top of the Redis backend for cross-worker AND cross-host messaging. Both require Store::defaultBackend(Store::BACKEND_REDIS).
// Fire-and-forget pub/sub
App::subscribe('chat:room:42', function (string $payload, string $channel) {
// runs in every worker that's registered; routes to your local fd map
});
$receivers = Store::publish('chat:room:42', json_encode($message));
// Reliable variant via Redis Streams (at-least-once via consumer groups)
App::subscribeReliable('orders', function (string $payload, string $id, string $stream): bool {
$ok = processOrder($payload);
return $ok; // true → XACK; false/throw → leave pending
});
$messageId = Store::publishReliable('orders', json_encode($order));
| Primitive | Latency | Durability | Delivery | When to pick |
|---|---|---|---|---|
Store::publish |
~0.5 ms loopback | None (fire-and-forget) | Best-effort — lost during subscriber reconnect window | Cache invalidation, WebSocket fan-out, presence beats — drops are tolerable, speed matters. |
Store::publishReliable |
~1–2 ms (XADD + ACK) | AOF/RDB-backed | At-least-once via consumer groups | Command/event sourcing, work queues, must-not-drop business events. |
ext-redis is loaded) and predis (pure-PHP fallback) SUBSCRIBE loops yield correctly under OpenSwoole\Runtime::HOOK_ALL — the production default in coroutine mode. Empirical results (spike doc):
- predis: 760 ops/sec aggregate, 0.40 ms PUBLISH receive median.
- phpredis: 775 ops/sec aggregate, 0.23 ms PUBLISH receive median, ~2× faster on hot CRUD per-op (11 ms vs 23 ms for 50-RTT batches).
ext-redis:
Store::defaultBackend(Store::BACKEND_REDIS, [
'url' => 'redis://cache:6379/0',
'prefer' => Store::PREFER_PREDIS, // or Phpredis (default when ext loaded)
]);
// Or via env: ZEALPHP_REDIS_PREFER=predis
App::superglobals(false)); if you’ve disabled it explicitly, force predis for subscribers OR re-enable HOOK_ALL.
Receiver count semantics: Store::publish delivers ONE copy to every worker (across every node) running a matching subscriber. So 32 workers per node × 2 nodes = receivers: 64 for one PUBLISH. That's correct Redis pub/sub — matches the cross-server WebSocket routing pattern where each worker owns a subset of fds.
Atomic Lua — Store::eval()
For multi-step operations that must be atomic (read-modify-write, conditional set-membership), run a Lua script server-side. Available on the Redis / Tiered backend (throws on Table).
// Keys are raw/absolute (like publish); values pass as KEYS/ARGV — never
// interpolated into the script body. Returns whatever the script returns.
$n = Store::eval(
"local v = redis.call('INCR', KEYS[1])\n" .
"if v == 1 then redis.call('SADD', KEYS[2], ARGV[1]) end\n" .
"return v",
keys: ['room:42:count', 'room:42:servers'],
args: [$serverId],
);
WSRouter::roomServers(), maintained race-free via Store::eval()) — has landed as the additive groundwork; targeted routing (B2) and the aggregator (A1) are opt-in increments. Design + rollout plan: cross-node-fanout.md.
Live demo — this very server
Each button below fires a real HTTP request against the running ZealPHP instance. Output panel shows the JSON the server returned. Most useful with ZEALPHP_STORE_BACKEND=redis; on the default Table backend the pub/sub buttons surface a clean StoreException error in JSON.
Try it
Each click rolls a fresh random message so you can see the receiver count + message ID change.
Click a button above to fire a request. The response JSON will land here.
Endpoints: /demo/store-roundtrip, /demo/pubsub/publish, /demo/pubsub/publish-reliable, /demo/pubsub/log. See /pubsub for the multi-tab walkthrough.
Redis Cluster / Sentinel
For HA topologies that span multiple Redis nodes (Cluster) or use a failover monitor (Sentinel), drive the driver with a pre-wired Predis\Client instead of a URL string. Predis natively supports both topologies via its constructor parameters — ZealPHP’s adapter accepts the prebuilt client and uses it as-is for the connection pool.
use Predis\Client as PredisClient;
use ZealPHP\Store\{RedisBackend, RedisConnectionPool, PredisDriver};
// === Cluster (3-node, key-slot routing) ===
$cluster = new PredisClient(
['tcp://node1:7000', 'tcp://node2:7000', 'tcp://node3:7000'],
['cluster' => 'redis'],
);
// Wire as the Store backend (pool of 1 because Cluster manages its own connections):
$backend = new RedisBackend(
new RedisConnectionPool(
url: 'unused-for-cluster',
size: 1,
opts: ['prefer' => Store::PREFER_PREDIS], // phpredis Cluster needs RedisCluster — future v0.2.41
),
);
// Or simpler — bypass the URL+pool layer:
$driver = new PredisDriver($cluster);
// === Sentinel (master/slave failover) ===
$sentinel = new PredisClient(
['tcp://sentinel1:26379', 'tcp://sentinel2:26379'],
['replication' => 'sentinel', 'service' => 'mymaster'],
);
$driver = new PredisDriver($sentinel);
First-class Store-facade integration (a Store::clusterBackend() / Store::sentinelBackend() helper) is on the v0.2.41 roadmap. Today's path is to construct the Predis\Client + PredisDriver directly and inject into a RedisBackend. phpredis users wanting Cluster/Sentinel should currently use predis (set ZEALPHP_REDIS_PREFER=predis); the RedisCluster phpredis class needs a separate driver shape.
Production hardening (v0.2.41)
A senior-eng review of the v0.2.39/v0.2.40 Redis backend surface flagged 3 critical + 10 medium issues — all closed in this pass. Each gets a brief description + the opt-in recipe. Default behaviour is unchanged; nothing on this page is a BC break.
C3 — TLS via rediss://
Cross-region or untrusted-network deployments need encrypted Redis connections. Both drivers now recognise rediss:// (and the tls:// alias). verify_peer + verify_peer_name are on by default.
Store::defaultBackend(Store::BACKEND_REDIS, 'rediss://cache.prod:6380/0');
// or ZEALPHP_REDIS_URL=rediss://cache.prod:6380/0
Bare redis:// (no host) is now rejected at parse time — surfaces misconfig at boot instead of after silently defaulting to 127.0.0.1.
C1 — WSRouter FD-reuse race fix
The previous cross-server WS routing pattern was vulnerable to a FD-reuse race: when client A's onClose was lost (kernel reaped the fd, OpenSwoole reassigned it to client B), an in-flight publish to A could land on B's connection — a cross-tenant data-leakage vector.
The fix is a per-connection 16-byte hex nonce stored in ws_owner.conn_id + WSRouter::$localFds[clientId]['conn_id']. WSRouter::sendToClient() carries the nonce in the publish payload; the subscriber on the owning server verifies it matches the local conn_id before pushing. Stale entries with the same fd are evicted on own() (the fd-coherence invariant).
No API change for callers — WSRouter::own($clientId, $fd) still works exactly as before; it now returns the generated conn_id for tests + debug.
C2 — HMAC-signed L1 invalidation
TieredBackend publishes invalidation messages on {prefix}:__l1_invalidate:{table}. Before this fix, anyone with Redis write access could forge invalidations and DoS L1 across the cluster. Now: optional shared secret signs every outbound message; receivers verify before evicting.
// Same secret on every node:
$tiered = new TieredBackend(
l1: new TableBackend(),
l2: new RedisBackend(/* … */),
invalidationSecret: getenv('ZEALPHP_TIERED_INVALIDATION_SECRET') ?: null,
);
// Or via env: ZEALPHP_TIERED_INVALIDATION_SECRET=<32-byte-random-hex>
No secret set → trust mode (any peer message accepted; preserves the v0.2.40 default). When set, messages without a matching truncated HMAC-SHA256 are silently dropped + elog'd at warn level. Same secret on every node, or peers will reject each other's invalidations.
H1 — tracked + TTL combo throws at make()
Pre-v0.2.41 silently ignored TTL on tracked-mode tables (the membership SET would have drifted). Now it throws at make() with a clear message — fail fast at boot, not silently after the first expiry.
H2 — Store::getStrict() for new code
The legacy Store::get() returns false on miss for BC. New code that wants ??-style fallbacks safely with stored falsy values (0, '', '0') uses the strict variant — returns null on miss.
// Legacy — keep using === false to detect misses:
$row = Store::get('users', $id);
if ($row === false) { /* miss */ }
// New code — null on miss, value otherwise:
$row = Store::getStrict('users', $id);
$hits = Store::getStrict('users', $id, 'hits') ?? 0; // 0-stored value preserved
H3 — pipelined bulk ops + UNLINK
Store::mget, Store::mset, and Store::clear on the Redis backend now run in a single MULTI/EXEC pipeline instead of N sequential round-trips. clear() uses UNLINK (non-blocking, Redis 4.0+) so multi-second clears on 10k+-row tables drop to sub-second.
No API change — existing call sites get the speedup automatically.
H4 — Circuit breaker (opt-in graceful degradation)
When Redis is degraded, every Store call previously hit the 5-second pool acquire timeout. The CircuitBreakerBackend decorator adds three-state fail-fast: closed (normal) → open (skip primary) → half-open (one probe). Reads can fall back to a Table backend; writes throw (no fallback semantics for writes — they'd diverge on recovery).
// Opt in via the connection opts:
Store::defaultBackend(Store::BACKEND_REDIS, [
'url' => 'redis://cache:6379',
'on_error' => 'fallback_table', // wraps with CircuitBreakerBackend
'breaker' => [
'failure_threshold' => 5, // failures within window to trip
'failure_window_sec' => 10, // sliding window
'open_seconds' => 30, // cooldown before half-open probe
],
]);
// Inspect state for ops dashboards:
$b = Store::defaultBackend();
echo $b->state(); // 'closed' | 'open' | 'half-open'
Default (no opt) — no decoration, throws on Redis down (the v0.2.40 behaviour). Reads use the fallback when OPEN; writes always surface the failure to the caller.
H5 — Store::stats() operational visibility
Per-worker counter snapshot — pool acquires, pool acquire timeouts, pool clients created. Pub/sub instances expose pubsub_reconnects_total, pubsub_messages_received_total, pubsub_handler_errors_total via RedisPubSub::stats(). Wire to your monitoring of choice (Prometheus, statsd, plain log line).
// Plumb into a health endpoint:
$app->route('/health/store', fn() => Store::stats());
// → ["pool_acquires_total":1247, "pool_clients_created_total":8, ...]
H6 + H7 — boot-time advisories
- H6 (ping): when
ZEALPHP_STORE_BACKEND=redis,App::run()PINGs the pool once at boot. Failure → louderror_login the master before workers fork. Misconfigured URL surfaces immediately, not after the first request 5 seconds in. - H7 (HOOK_ALL + phpredis): phpredis SUBSCRIBE blocks the worker without
OpenSwoole\Runtime::HOOK_ALL— the production default. If you've explicitly disabled HOOK_ALL AND haveApp::subscribehandlers registered AND phpredis is the resolved driver, boot emits a warning telling you to either re-enable HOOK_ALL or setZEALPHP_REDIS_PREFER=predis.
H8 — TieredBackend::existsCached()
The strict exists() always hits L2 for consistency. existsCached() returns true when L1 has a fresh entry (within $l1Ttl); otherwise defers to L2. Use on hot paths where "probably exists" + $l1Ttl-bounded staleness is acceptable — saves a Redis round-trip.
H9 + H10 — operational debug
- H9:
PhpredisDriver::close()exceptions now route throughelogat debug level instead of being swallowed silently — diagnosable disconnect bugs. - H10:
RedisPubSubtakes an optional$maxAttemptsparam (default 0 = unlimited, preserving the existing eventually-reconnect behaviour). Set to N>0 to fail loudly after N consecutive reconnect cycles — useful for CI workers that should crash if Redis disappears.
Full review notes + the original risk-by-risk mapping: docs/architecture/2026-05-23-redis-backend-review.md.
When to use Redis / Valkey
Store and Cache cover most single-server apps. Here's when you'll need an external cache.
Built-in Cache is great for
- Single-server deployments (most apps)
- Caching API responses, config, computed values
- Rate limiting and request counting
- Session-adjacent data (preferences, feature flags)
- Apps with < 100k cache entries
Move to Redis / Valkey when you need
- Multi-server shared state — Cache is per-server only
- Large datasets — memory tier caps at 4096 rows, 8KB/value
- Pub/Sub messaging — no built-in publish/subscribe between workers or servers
- Data structures — sorted sets, streams, Lua scripting
- Crash-safe persistence — Redis AOF/RDB vs best-effort files
- Eviction policies — no LRU/LFU, full table spills to file
- Transactions — no MULTI/EXEC, per-row spinlocks only