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:
- immediately applies a provisional local eviction/fence to every
DialCache instance sharing the in-process coordinator;
- atomically advances the Redis watermark and publishes a versioned event from one Lua script;
- returns the effective Redis watermark and Redis time to the originating process;
- applies that authoritative result locally before
invalidateRemote() resolves; and
- 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:
- installs or extends the monotonic identity fence;
- scans the process-local LRU and deletes every tracked entry with that exact braced invalidation prefix, across all use cases and argument variants; and
- 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:
- reuse the existing future-buffer validation, Redis
TIME, monotonic-max watermark, and derived-TTL logic;
- store the watermark before publishing;
- publish exactly one versioned payload on the namespace channel;
- return the exact effective watermark and Redis time needed for origin-side application; and
- 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:
- validate
futureBufferMs through the existing boundary;
- synchronously fan a provisional identity fence/eviction to every local listener sharing the coordinator;
- execute the combined Redis invalidate-and-publish command;
- fan out the returned effective timing result locally before resolving; and
- 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:
- deploy code capable of subscribing while tracked process-local caching is disabled for affected use cases;
- establish and observe healthy subscribers on every cache-serving process;
- enable coordinated publication on every writer that calls
invalidateRemote();
- gradually re-enable tracked process-local caching; and
- 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
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.0branch. 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: trueprotects 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 + idchanged; discard what you hold locally and temporarily stop publishing new local values.”Applications continue to use the existing operation after the source mutation commits:
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:DialCacheinstance sharing the in-process coordinator;invalidateRemote()resolves; andThe 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: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:
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:
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
DialCacheinstance maintains temporary local fence state keyed by the existing canonical invalidation identity. This is local metadata, not a cache-key generation:On each event, in this order, the instance:
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 alwaysO(10,000): callers can configurelocalMaxSizeabove 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
futureBufferMsto 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:
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/SUBSCRIBEbecause 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:
The
PUBLISHreceiver 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
DialCacheRedisClientcommand 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 callduplicate(),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
DialCacheinstances 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 existingDialCacheRedisClient.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 integer1reply for existing custom adapters and raw protocol consumers.Scope boundary
This issue covers only process-local entries for
trackForInvalidation: trueuse 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
requestLocaldisabled 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.tssrc/redis-client.tssrc/internal/redis-cache.tssrc/index.tsAdd root-exported backend-neutral types for:
ready,unavailable,disposed);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
DialCacheinstances.Keep the default branch exact:
PUBLISH;invalidateRemote()uses the current semantic client method;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.tssrc/internal/redis-script-reply.tssrc/redis-protocol.tsAdd
INVALIDATE_AND_PUBLISH_CACHE_SCRIPT; do not changeINVALIDATE_CACHE_SCRIPT.The new script must:
TIME, monotonic-max watermark, and derived-TTL logic;Keep only the watermark key in
KEYS; pass the channel and validated event fields throughARGVso Redis Cluster routing remains single-slot. Export the new script and event version/channel helpers throughdialcache/redis-protocolwithout changingREDIS_FRAME_VERSIONor 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:
src/internal/local-invalidation.tssrc/internal/local-cache.tssrc/key.tssrc/dialcache.tsAdd
LocalCache.deleteTrackedPrefix(prefix): numberandLocalCache.clearTracked(): number. Iterate current LRU contents and delete in place; do not introduce a secondary index initially.The local invalidation state owns:
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.tsCapture 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:
Required final check:
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.tssrc/internal/redis-cache.tsWhen coordination is configured:
futureBufferMsthrough the existing boundary;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.tssrc/valkey-glide.tsFor node-redis:
For Valkey GLIDE:
Scripthandles;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.tsReuse
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.mdsrc/dialcache.ts,src/config.ts,src/redis-client.ts, and integration subpathsDocument:
performance.now();Safe rollout:
invalidateRemote();Validation matrix
PUBLISH, event state, local-hit lookup, or behavior changeDialCacheRedisClient.invalidate()andINVALIDATE_CACHE_SCRIPTretain the exact current contract and replyperformance.now(), notDate.now()invalidateRemote()PUBLISHreports zeroSCRIPT FLUSHDialCacheobjectsRun the complete Node 22 validation:
corepack pnpm typecheck corepack pnpm test corepack pnpm build corepack pnpm test:package corepack pnpm test:integrationAdd a repeatable invalidation benchmark aligned with #35 and report local-hit before/after numbers required by #43.
Acceptance criteria
v0.11.0remote-only behavior.invalidateRemote(keyType, id, futureBufferMs).invalidateRemote()resolves; peers remain asynchronous and unacknowledged.Explicit non-goals
Related work