Cluster Cache: Operations and Consumer#

This page separates ordinary local cache operations from distributed invalidation, then describes the consumer and scheduling lifecycle.

Local reads and writes#

Read paths do not contact the transport. This keeps the hot path independent of the event-store availability.

// Node-local read through APCu then SQLite; no transport call.
$product = $runtime->cache()->remember(
    'product.42',
    function ($item) use ($products) {
        $item->expiresAfter(300);

        return $products->find(42);
    },
    tags: ['products', 'product.42'],
);

Direct writes are local too:

$runtime->cache()->set('product.42', $product, 300);
$runtime->cache()->setTagged('catalog.home', $payload, ['products', 'home'], 60);
$runtime->cache()->setMultiple(['product.42' => $product, 'product.43' => $other], 300);

This is intentional. A cache fill should not broadcast to every node because each node has its own capacity, traffic pattern, and TTL. Only changes to the underlying data need cross-node invalidation.

Distributed invalidation operations#

After a source-data change commits, select the narrowest invalidation that correctly covers affected entries.

Key invalidation#

Use a key when one cached representation changed:

$products->update(42, $input); // authoritative write has committed
$runtime->invalidateKey('product.42');

This deletes product.42 locally and publishes a key event for other nodes.

Tag invalidation#

Use tags when a change affects a group, list, or related derived entries:

$products->update(42, $input);
$runtime->invalidateTag('products');

Any node that previously stored values with ['products'] treats those entries as stale after consuming the event. You can still delete a direct key locally if appropriate, then invalidate the shared tag:

$runtime->cache()->delete('product.42'); // local direct representation
$runtime->invalidateTag('products');      // distributed aggregate invalidation

Namespace invalidation#

Use clearNamespace() for a controlled broad reset, such as a schema or serialization deployment that invalidates every entry in the namespace:

$runtime->clearNamespace();

It is intentionally expensive because every participating node clears that namespace after consuming the event. Prefer a key or tag for routine writes.

End-to-end application workflows#

Read a product#

  1. Request calls $runtime->cache()->remember('product.42', ...).

  2. The node checks APCu, then local SQLite.

  3. On a miss, the resolver reads the authoritative store and caches the value locally.

  4. No cluster event is created; cache fills are local.

Update a product#

  1. Commit the source-of-truth update.

  2. Call $runtime->invalidateKey('product.42') and/or $runtime->invalidateTag('products').

  3. The initiating node invalidates its own local state and publishes events.

  4. Other nodes’ consumers replay those events and invalidate their local state.

  5. Subsequent reads rebuild local entries from the authoritative source.

Delete a product#

  1. Commit the authoritative deletion.

  2. Invalidate the direct representation and any affected list/search tags.

  3. Allow consumers to propagate the same invalidation to other nodes.

There is no ClusterRuntime::invalidateTags() method. Publish one event per tag instead:

$runtime->invalidateKey('product.42');
$runtime->invalidateTag('products');
$runtime->invalidateTag('search-results');

Do not call $runtime->cache()->invalidateTags() for distributed work; that would only invalidate the initiating node.

Consumer lifecycle#

Each application node needs a consumer. consume() performs these steps:

  1. Check whether the node’s stored cursor is older than retained events.

  2. If it is, clear the local namespace and reset the cursor safely.

  3. Fetch up to the requested number of events after the current cursor.

  4. Ignore events emitted by this same node (they were already applied locally).

  5. Apply matching-namespace events from other nodes.

  6. Advance the local SQLite cursor after every event, including ignored events.

consume() returns the number of events read, not the number applied. It returns zero when no events are available or when passed a limit below one.

Scheduled polling#

For moderate traffic, run a short CLI task on every node every few seconds or minutes, according to the maximum acceptable stale window:

// bin/cache-consume.php: bootstrap $runtime for this node first.
$processed = $runtime->consume();

For a worker that should catch up without running forever, use drain(). It reads until it receives a partial batch or reaches maxBatches:

$processed = $runtime->drain(limit: 1_000, maxBatches: 50);

The schedule determines remote invalidation latency. For example, a task every five seconds means a healthy remote node may serve stale data for nearly five seconds plus normal request timing. TTL remains the final safety bound.

Cluster consumers need scheduling#

CacheLayer deliberately does not run a background daemon or install cron jobs: the host application owns process supervision, credentials, logging, retries, and graceful shutdown. However, every node must run a consumer for remote invalidations to become effective. Choose one of these deployment models:

cron or framework scheduler

Good for modest event volume and a known stale-data budget. Schedule the same small CLI command on every application node. The poll interval is part of the remote-staleness budget.

Supervised long-running worker

Good for low-latency or high-volume systems. Run a worker under systemd, Supervisor, Kubernetes, or your framework queue/worker runtime. It calls bounded consume() loops and polls/sleeps between iterations.

Transport-native delivery

A custom transport can integrate with a durable broker’s worker model, but it still must preserve the replay/cursor contract. CacheLayer’s consumer is explicitly pull-based and works with scheduled polling for all transports.

For a cron deployment, create a command that only bootstraps the runtime for the current node and consumes one bounded batch:

// bin/cache-cluster-consume.php
$processed = $runtime->consume(1_000);

Then schedule it on every node. This example polls every minute; lower the interval only when the resulting remote-staleness window is acceptable for the application and the transport can handle the polling rate:

* * * * * www-data /usr/bin/php /srv/application/bin/cache-cluster-consume.php

Traditional cron cannot reliably poll more often than once a minute. For sub-minute propagation, use a framework scheduler that supports it or a supervised worker. Ensure the scheduler prevents overlapping runs on the same node, or make each invocation deliberately short and bounded.

Cluster health status#

Use status() in a health endpoint or scheduled diagnostic. It returns a ClusterStatus value with the local cursor and update time, oldest/newest transport event IDs, pending-event count when the transport can calculate it, and the most recent consume count, error, and recovery time.

$status = $runtime->status();

if ($status->lastConsumeError !== null) {
    reportCacheConsumerFailure($status->lastConsumeError);
}

if ($status->pendingEventCount !== null && $status->pendingEventCount > 10_000) {
    alertOnConsumerLag($status);
}

The PDO transport implements detailed inspection. A custom transport that does not implement the optional inspection contract reports null for newest ID and pending count rather than inventing a lag estimate.

Continuous worker with bounded drain#

For higher event volume, use a supervised worker. Keep individual iterations bounded so one constantly busy stream cannot starve health checks or graceful shutdown handling in your worker framework:

$limit = 1_000;
do {
    $processed = $runtime->consume($limit);
} while ($processed === $limit);

Then let the worker sleep/poll according to the transport and your supervision model. CacheLayer does not start a daemon or provide a scheduler; deployment owns that lifecycle.

Startup and deployment behavior#

It is safe to call consume() during worker startup before serving traffic. That lets a node catch up before it begins filling local cache entries. Retain events long enough for planned deployments, autoscaling delays, node outages, and the time needed to drain a backlog.

If a node has a new empty local SQLite file, it has no cursor and begins from the retained stream. This may replay unnecessary invalidations but is safe. If a node intentionally changes its nodeId, it gets a distinct cursor record; ensure the identity remains unique and monitor for unexpected backlog replay.