Skip to content

Add opt-in Redis Pub/Sub coherence for tracked process-local caching #106

Description

@lan17

Priority

P1 — High — let mutation-sensitive tracked use cases retain process-local caching without accepting the current full local-TTL staleness window after a coordinated invalidation.

v0.14.0 grooming verification (2026-08-01)

The implementation PR #107 is now a draft, conflicting, pre-v0.14.0 branch. Its best-effort Pub/Sub model cannot guarantee peer coherence after undetected event loss, and it predates grouped shadow configuration plus batch invalidation PR #114. A maintainer decision is required: either close #106 and PR #107 as not planned, or rebase/redesign/re-review them against current main. Until that decision, keep the existing P1 classification because the issue proposes changing mutation-sensitive local-cache behavior; do not treat the stale green checks as current validation.

Current baseline

At v0.11.0 / e06d833ba245706a499d66056fdad15dc1210b68, trackForInvalidation: true protects Redis values with a durable Redis watermark. A process-local hit stops before Redis, however, so it never observes that watermark. invalidateRemote(keyType, id, futureBufferMs) therefore advances Redis state while existing process-local and request-local values remain readable.

This issue deliberately reconsiders the remote-only process-local decision recorded in #28. It leaves that closed issue intact as the historical decision record and adds an opt-in coordination mode; configurations that do not enable coordination keep the current behavior.

Overall design

Human model

Redis remains the authoritative invalidation state. Redis Pub/Sub is only the fast notification path that tells each participating process, “the tracked identity namespace + keyType + id changed; discard what you hold locally and temporarily stop publishing new local values.”

Applications continue to use the existing operation after the source mutation commits:

await dialcache.invalidateRemote(
  "user_id",
  userId,
  USER_INVALIDATION_BUFFER_MS,
);

There is no generation added to DialCacheKey.urn, no new invalidation component in stored cache keys, and no Redis lookup on ordinary local hits. When optional process-local coordination is configured, the same call:

  1. immediately applies a provisional local eviction/fence to every DialCache instance sharing the in-process coordinator;
  2. atomically advances the Redis watermark and publishes a versioned event from one Lua script;
  3. returns the effective Redis watermark and Redis time to the originating process;
  4. applies that authoritative result locally before invalidateRemote() resolves; and
  5. lets healthy subscribers on other processes apply the event asynchronously.

The direct local application avoids waiting for the caller's own Pub/Sub echo. Peer delivery is deliberately not part of the completion barrier: successful invalidateRemote() means the origin applied its local state and Redis accepted the watermark/publication operation, not that every pod has processed the event.

Redis watermark and event

The event must carry the effective Redis watermark, not merely the caller's requested futureBufferMs. The invalidation script preserves the furthest watermark:

effective watermark = max(existing watermark, Redis TIME + requested buffer)

For example, if an invalidation establishes a ten-second future watermark and another invalidation one second later requests a zero buffer, Redis remains fenced for roughly nine seconds. A process receiving only the second requested buffer would otherwise resume local publication too early.

Use a compact, versioned event with the logical identity and timing values produced by the same Lua invocation:

interface DialCacheInvalidationEventV1 {
  readonly version: 1;
  readonly namespace: string;
  readonly keyType: string;
  readonly id: string;
  readonly effectiveWatermarkMs: string;
  readonly redisNowMs: string;
}

Timestamps are decimal strings on the wire and are validated before conversion. A receiver computes the remaining Redis fence and translates that duration onto its own monotonic clock:

const remainingMs = Math.max(
  Number(event.effectiveWatermarkMs) - Number(event.redisNowMs),
  0,
);

const blockedUntilMonoMs = performance.now() + remainingMs;

Redis epoch time is never compared directly with Date.now() in a pod. Starting the complete remaining duration at receipt conservatively extends the fence by Pub/Sub transit and event-loop delay, which is safer than ending early.

Every valid event performs eviction even when its effective watermark equals a previously observed value. Equal watermarks can represent distinct committed source mutations.

Process-local eviction and temporary fence

Each participating DialCache instance maintains temporary local fence state keyed by the existing canonical invalidation identity. This is local metadata, not a cache-key generation:

Map<InvalidationIdentity, {
  blockedUntilMonoMs: number;
}>

On each event, in this order, the instance:

  1. installs or extends the monotonic identity fence;
  2. scans the process-local LRU and deletes every tracked entry with that exact braced invalidation prefix, across all use cases and argument variants; and
  3. records the local invalidation without allowing logger or metrics failures to affect correctness.

The tracked URN already begins with an exact Redis hash-tagged identity, so the first implementation can match the closed-brace prefix without parsing ids or adding a reverse index. Adjacent ids, other namespaces, and untracked entries must remain untouched.

The scan is intentionally the first implementation. It is O(current local occupancy), not always O(10,000): callers can configure localMaxSize above or below the default. Invalidation should be much less frequent than reads, and this keeps the local-hit path and entry shape unchanged. A reverse identity index is justified only if the required benchmark shows the bounded scan is material.

Every process-local publication site checks the coordination state immediately before the final LRU set. A matching active fence suppresses the write. This closes the race where a Redis hit or fallback observed old data before the event and attempts to populate local storage after eviction. Once the fence expires, publication is allowed again.

This mirrors the current Redis future-buffer contract: a fallback that completes after an undersized future window may publish. The application must continue sizing futureBufferMs to cover source visibility lag and the remaining stale-work tail documented by #26. A zero buffer evicts current local entries but provides no continuing stale-publication protection once monotonic time advances.

Why not only compare lazy local watermarks on hits?

A pure hit-time comparison has a lifetime problem. Suppose a local entry has 60 seconds remaining while the future buffer is five seconds. If the local watermark record is removed after five seconds, an untouched pre-invalidation entry can become readable again for the remaining 55 seconds.

Preventing that resurrection would require retaining every identity watermark until all older matching entries expire, adding publication timestamps plus lifetime accounting, or maintaining another index. It would also add a map lookup to every tracked local hit. Eager deletion permanently removes old entries, so the fence only needs to cover future publication and the normal hit path stays unchanged.

Existing in-flight response semantics remain unchanged

This issue fences cache state, not application-result ordering.

A caller arriving after invalidation may still join an exact-key process flight that began before invalidation and receive its result. Existing leaders and followers are not canceled, detached, split, or versioned. Publication from that flight is decided by the local fence at the time of the final local write, exactly as Redis decides a tracked write against the watermark at script execution time.

That preserves the accepted overlap contract in #27. Preventing post-invalidation callers from joining earlier work would be a separate, stronger behavior change.

Subscription health and delivery guarantee

Redis Pub/Sub is at-most-once and does not replay a disconnect gap: https://redis.io/docs/latest/develop/pubsub/#delivery-semantics. This feature therefore provides low-latency, best-effort convergence rather than linearizable all-pod invalidation.

On initial startup or a detected subscription outage, coordinated tracked process-local caching enters an unavailable state:

  • clear all tracked process-local entries while preserving untracked entries;
  • suppress tracked local publication while unavailable;
  • increment a process-local health epoch so work that crossed the detected gap cannot publish after reconnect; and
  • emit one state-transition warning rather than logging per cache invocation.

After exact-channel subscription acknowledgement, clear tracked state once more and allow work started in the new healthy epoch to populate again. This closes detected disconnect and in-flight recovery races. It cannot repair an event lost during an undetected gap, and a remote-disabled/ramped-out invocation after reconnect cannot independently prove that it observed a missed Redis watermark. That residual limitation must remain explicit in the public contract.

Applications requiring strict read-after-invalidation behavior across every pod must disable process-local caching for that use case or use a durable replay/barrier design. Redis Streams, acknowledgements, and per-hit watermark validation are not part of this issue.

Redis Cluster and operational cost

The initial protocol uses ordinary PUBLISH/SUBSCRIBE because the project tests Redis 6.2. In Redis Cluster, ordinary Pub/Sub is propagated cluster-wide. Redis 7 sharded Pub/Sub reduces cluster fan-out but would raise the minimum version and complicate atomic publication from arbitrary watermark slots, so it remains a later optimization.

Use one deterministic channel per cache namespace rather than dynamic per-identity subscriptions. Pub/Sub is independent of the Redis database number, so the namespace must be part of both channel identity and payload validation.

Healthy-path cost is:

  • one combined Lua invalidation/publication command per coordinated invalidation;
  • one small event delivered to each subscribed process;
  • one scan of current process-local occupancy per event;
  • one bounded local-publication guard on cache fills; and
  • no Redis operation, event parsing, timestamp field, or fence lookup on ordinary local hits.

The PUBLISH receiver count is not a processing acknowledgement and must not affect success, especially in Cluster: https://redis.io/docs/latest/commands/publish/.

Interface and ownership boundary

Coordination is optional and must not turn the existing resource-free DialCacheRedisClient command boundary into a connection owner.

Add a separate optional invalidation-coordinator capability alongside redis.client. The caller creates, connects, configures, drains, and closes the command and subscriber clients. A first-party async factory may subscribe and return only after acknowledgement, but it must not call duplicate(), connect(), quit(), disconnect(), or close an injected client.

The coordinator owns only its listeners, event parsing/state, and any adapter-owned Lua script handles. One coordinator should be shareable by multiple DialCache instances in a process so one dedicated subscriber connection can fan out locally.

Keep invalidateRemote() as the one public invalidation operation. Without a coordinator, its behavior and the existing DialCacheRedisClient.invalidate(): Awaitable<void> contract remain unchanged. The coordinated path uses a separate optional invalidate-and-publish capability and a new Lua script, preserving the legacy exported invalidation script and exact integer 1 reply for existing custom adapters and raw protocol consumers.

Scope boundary

This issue covers only process-local entries for trackForInvalidation: true use cases.

Request-local memoization remains an outer-request snapshot. Active request-local maps are not centrally enumerable and have no TTL; the existing same-request behavior remains unchanged. Applications needing stronger behavior must keep requestLocal disabled for those use cases. Request-local invalidation would require a separate design.

Detailed implementation plan

1. Define the backward-compatible coordination contracts

Touch:

  • src/config.ts
  • src/redis-client.ts
  • src/internal/redis-cache.ts
  • src/index.ts

Add root-exported backend-neutral types for:

  • a versioned invalidation event;
  • coordinator listener registration/unregistration;
  • health transitions (ready, unavailable, disposed);
  • a coordinated invalidation request/result; and
  • an optional caller-owned coordinator under RedisConfig.

The coordinator contract must support synchronous in-process fan-out, one coordinator shared across instances, and an async factory that establishes the external subscription before construction of DialCache instances.

Keep the default branch exact:

  • no coordinator means no subscription and no PUBLISH;
  • no additional local-hit work or entry metadata;
  • invalidateRemote() uses the current semantic client method;
  • existing structural custom clients still compile; and
  • no library-owned background Redis connection appears.

Model coordinated configuration so TypeScript requires the optional invalidate-and-publish capability when the coordinator is selected, with a matching runtime check for plain JavaScript callers.

2. Add a parallel versioned Redis invalidation-event protocol

Touch:

  • src/internal/redis-scripts.ts
  • src/internal/redis-script-reply.ts
  • a new internal event codec module
  • src/redis-protocol.ts
  • Redis protocol and adapter unit tests

Add INVALIDATE_AND_PUBLISH_CACHE_SCRIPT; do not change INVALIDATE_CACHE_SCRIPT.

The new script must:

  1. reuse the existing future-buffer validation, Redis TIME, monotonic-max watermark, and derived-TTL logic;
  2. store the watermark before publishing;
  3. publish exactly one versioned payload on the namespace channel;
  4. return the exact effective watermark and Redis time needed for origin-side application; and
  5. ignore the subscriber count as an acknowledgement signal.

Keep only the watermark key in KEYS; pass the channel and validated event fields through ARGV so Redis Cluster routing remains single-slot. Export the new script and event version/channel helpers through dialcache/redis-protocol without changing REDIS_FRAME_VERSION or the stored value frame.

The codec must reject unknown versions, oversized payloads, extra/missing fields, invalid UTF-8/JSON, wrong namespaces, invalid key components, and non-decimal/non-safe timing values. An unsupported or malformed message on the dedicated channel moves the coordinator to unavailable, flushes tracked local state, and logs safely rather than throwing out of the subscriber callback.

3. Implement bounded process-local invalidation state

Touch:

  • a new src/internal/local-invalidation.ts
  • src/internal/local-cache.ts
  • src/key.ts
  • src/dialcache.ts

Add LocalCache.deleteTrackedPrefix(prefix): number and LocalCache.clearTracked(): number. Iterate current LRU contents and delete in place; do not introduce a secondary index initially.

The local invalidation state owns:

  • per-identity monotonic publication deadlines;
  • coordinator health and health epoch;
  • origin/peer event application; and
  • bounded cleanup/overflow behavior.

Do not allocate one timer per invalidation. Prune expired fences lazily on event/publication checks and bound retained identity records relative to configured local capacity. If the per-identity bound would discard a still-active fence, fail conservatively into one global tracked-publication deadline through the furthest active fence instead of silently losing safety state. With localMaxSize = 0, keep no value or fence state.

Event application must install/extend the fence before scanning. Publication must recheck health epoch and the active identity/global deadline immediately before the synchronous LRU set, after any asynchronous config/fallback/serializer work.

4. Thread publication permits through every local-fill path

Touch:

  • src/dialcache.ts
  • local/invalidation/coalescing unit tests

Capture a lightweight coordination permit for work that can publish tracked process-local state. The permit contains the health epoch only; it is not added to keys and does not split process flights.

Apply the final publication guard to every path that can populate local storage:

  • a process-local miss followed by a Redis hit;
  • local-only fallback;
  • remote-disabled/ramped-out fallback;
  • normal fallback after a Redis miss and accepted tracked write; and
  • any fail-open path currently allowed to populate local storage.

Required final check:

coordinator is healthy
AND permit health epoch is current
AND no matching identity/global fence is active

If the guard rejects publication, return the computed value normally. Preserve current coalescing leader/follower behavior and getCoalescingState() accounting unchanged.

Add deterministic race tests where an event or health transition lands after local miss but before the final local set. Ensure either the set happens first and is removed by the synchronous scan, or the final guard observes the new state and suppresses it.

5. Integrate coordinated origin behavior into invalidateRemote()

Touch:

  • src/dialcache.ts
  • src/internal/redis-cache.ts
  • invalidation unit tests

When coordination is configured:

  1. validate futureBufferMs through the existing boundary;
  2. synchronously fan a provisional identity fence/eviction to every local listener sharing the coordinator;
  3. execute the combined Redis invalidate-and-publish command;
  4. fan out the returned effective timing result locally before resolving; and
  5. preserve existing logging, metrics, and rejection behavior.

If Redis invalidation/publication fails, leave the conservative local eviction/fence in place and reject the explicit maintenance operation. Retry remains safe because the Redis watermark max, local eviction, and fence extension are idempotent.

Pub/Sub echo delivery to the origin is expected and harmless. Every event still scans because equal watermark does not prove duplicate source mutation.

Configurations without coordination continue through the current remote-only method exactly.

6. Implement caller-owned node-redis and Valkey GLIDE integrations

Touch:

  • src/node-redis.ts
  • src/valkey-glide.ts
  • adapter tests
  • packed-package consumers

For node-redis:

  • register the combined script on the caller's command client;
  • accept a caller-created, connected, dedicated subscriber client;
  • subscribe to the exact namespace channel and await acknowledgement before returning the coordinator;
  • translate reconnect/error/end and re-subscription acknowledgement into semantic health transitions;
  • unsubscribe/detach only helper-owned listeners on coordinator disposal; and
  • never create or close the Redis clients.

For Valkey GLIDE:

  • register/release the combined script using adapter-owned Script handles;
  • accept a caller-owned client configured for exact-channel Pub/Sub callbacks/reconciliation;
  • adapt native messages and current subscription state into the same coordinator contract; and
  • preserve caller ownership of client creation, request/reconnect policy, reconciliation interval, draining, and closure.

If a bundled client cannot expose sufficient subscription-health state for the shared contract, do not advertise a weaker first-party coordinator for it; retain the backend-neutral interface for caller implementations and track that adapter as a focused follow-up.

Use ordinary Pub/Sub for Redis 6.2, Valkey 8, and Redis Cluster coverage. Sharded Pub/Sub remains out of scope.

7. Add bounded observability

Touch:

  • src/metrics.ts
  • existing metrics adapters/tests only where reuse requires it
  • README metrics documentation

Reuse invalidation({ layer: CacheLayer.LOCAL, ... }) when a valid event is applied to an instance. Local invalidation counts represent per-process applications; remote counts remain maintenance calls.

Log only coordinator state transitions and protocol failures through the existing safe logger. Do not log ids, full payloads, or one warning per bypassed invocation. Do not add high-cardinality identity labels or a new metrics adapter method in the first implementation.

Record the number of entries evicted only in debug logs or benchmarks unless a bounded operational requirement justifies a public metric.

8. Document setup, lifecycle, guarantees, and rollout

Touch:

  • README.md
  • public TSDoc in src/dialcache.ts, src/config.ts, src/redis-client.ts, and integration subpaths

Document:

  • opt-in configuration and the unchanged remote-only default;
  • source mutation commit ordering;
  • Redis watermark authority versus Pub/Sub notification;
  • effective-watermark event translation onto performance.now();
  • eager scan plus temporary publication fence;
  • application-owned future-buffer sizing;
  • request-local snapshot and overlapping-flight semantics;
  • subscriber startup/shutdown ordering and one dedicated subscriber per process;
  • at-most-once delivery, detected-gap behavior, and absence of an all-pod barrier;
  • the residual undetected-gap and remote-disabled/ramped-out limitation;
  • ordinary Redis Cluster Pub/Sub fan-out and ACL requirements;
  • operational cost and benchmark results; and
  • a mixed-fleet rollout sequence.

Safe rollout:

  1. deploy code capable of subscribing while tracked process-local caching is disabled for affected use cases;
  2. establish and observe healthy subscribers on every cache-serving process;
  3. enable coordinated publication on every writer that calls invalidateRemote();
  4. gradually re-enable tracked process-local caching; and
  5. roll back by disabling tracked process-local caching before disabling publishers/subscribers.

Validation matrix

Area Scenario Required result
Default compatibility No coordinator configured No subscription, PUBLISH, event state, local-hit lookup, or behavior change
Legacy protocol Existing custom client/raw invalidation script DialCacheRedisClient.invalidate() and INVALIDATE_CACHE_SCRIPT retain the exact current contract and reply
Identity coverage Same namespace/type/id across use cases and args Every matching tracked process-local entry is evicted
Isolation Different id/type/namespace and untracked values Nothing unrelated is removed or fenced
Effective fence Existing longer Redis watermark followed by shorter requested buffer Local deadline follows the longer effective Redis result
Repeated mutation A later event has the same effective watermark Matching entries are evicted again and the deadline never shortens
Stale resurrection Local TTL exceeds future buffer The pre-event entry never becomes visible after fence expiry
Timing Pod wall clock changes Fence follows performance.now(), not Date.now()
Zero buffer Existing local value plus in-flight fallback Existing value is evicted; later publication follows the documented zero-buffer boundary
Publication race Event lands between miss and local set The value is either removed by the event scan or rejected by the final guard
Redis hit A pre-event Redis read resolves after event Returned value is unaffected; active fence suppresses local publication
Remote disabled/ramped Tracked local-only publication Preserve current best-effort behavior and document lack of missed-event reconciliation
Process flight Post-event caller joins an older exact-key promise Existing #27 response semantics remain unchanged
Request local Same enabled request before/after event Existing request-local snapshot remains unchanged
Initial readiness Coordinator not yet acknowledged No tracked coordinated local state is served or published
Detected disconnect Cached values and pending fills exist Tracked values flush; publication is blocked; health epoch advances
Reconnect Subscription acknowledges after a gap State is cleared again; old-epoch work cannot publish
Malformed event Bad version, identity, namespace, or timing Callback does not throw; coordination becomes unavailable and tracked local state clears
Origin completion Caller awaits coordinated invalidateRemote() Origin listeners have applied the returned effective fence before resolution
No receivers Lua PUBLISH reports zero Watermark/publication command remains successful; zero is not treated as acknowledgement failure
Redis 6.2 Two independent node-redis processes Peer process evicts and future-window publication is suppressed
Valkey 8 Cross-client publisher/subscriber Supported first-party adapters interoperate or unsupported health capability is explicitly excluded
Redis Cluster Watermarks occupy different slots Script remains single-key and ordinary Pub/Sub reaches subscribers cluster-wide
Script recovery SCRIPT FLUSH Legacy and coordinated scripts reload normally
Multiple instances One shared coordinator, several DialCache objects One message fans out once to each registered instance
Disposal Coordinator/instance listeners are disposed after draining Adapter-owned listeners/scripts release once; caller clients remain open
Fence cardinality Many unique active identities State stays bounded and overflow disables publication conservatively rather than dropping an active fence
Feature-disabled performance Ordinary process-local hit No measurable/code-path regression from coordination machinery
Event scan performance 1,000, 10,000, and configured-larger occupancies Record event latency and allocations; add an index only if evidence justifies it

Run the complete Node 22 validation:

corepack pnpm typecheck
corepack pnpm test
corepack pnpm build
corepack pnpm test:package
corepack pnpm test:integration

Add a repeatable invalidation benchmark aligned with #35 and report local-hit before/after numbers required by #43.

Acceptance criteria

  • Coordination is opt-in; an unconfigured instance retains exact v0.11.0 remote-only behavior.
  • Applications continue to call only invalidateRemote(keyType, id, futureBufferMs).
  • No cache-key, stored Redis frame, or local-entry generation is introduced.
  • A valid event evicts every matching tracked process-local entry across use cases/args and never affects unrelated or untracked entries.
  • The local publication fence is derived from the effective Redis watermark and never shortened by later/older events.
  • Existing process-flight response/coalescing semantics and request-local snapshot semantics remain unchanged.
  • The origin applies local eviction/effective fencing before invalidateRemote() resolves; peers remain asynchronous and unacknowledged.
  • Detected subscriber uncertainty clears and disables tracked process-local state; documentation is explicit that undetected/missed delivery remains possible.
  • Legacy custom clients, legacy invalidation Lua users, and configurations without a coordinator remain compatible.
  • The library never creates, connects, disconnects, drains, or closes caller Redis clients.
  • Redis 6.2 standalone, Valkey 8, and Redis Cluster behavior is covered where the bundled client exposes the required health contract.
  • No normal local-hit Redis round trip or coordination-map lookup is added.
  • Fence memory is bounded and active safety state is never silently evicted.
  • Documentation includes setup, ACLs, lifecycle, cluster overhead, mixed-fleet rollout, and rollback.
  • Full typecheck, unit coverage, build/declarations, packed ESM/CommonJS consumers, live integration matrix, dependency audits, and invalidation benchmarks pass on Node 22.

Explicit non-goals

  • Request-local invalidation.
  • Cache-key generations or versioned local entries.
  • Cancellation, detachment, or splitting of existing process flights.
  • Strict all-pod acknowledgment or linearizable read-after-invalidation ordering.
  • Redis Streams, durable replay, consumer groups, or peer acknowledgements.
  • Redis polling on process-local hits.
  • A secondary identity index without benchmark evidence.
  • Redis 7 sharded Pub/Sub or a raised Redis minimum.
  • Library ownership of Redis connection lifecycle.

Related work

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions