perf(redis): skip refills behind observed watermarks - #143

Merged
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills
Aug 30, 2026
Merged

perf(redis): skip refills behind observed watermarks#143
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills

Conversation

@lan17

@lan17lan17 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes#141

Summary

Tracked Redis misses now preserve a trustworthy numeric watermark when the existing authoritative MGET observed one. The public miss variant is explicitly discriminated, and conditional refills use two application-clock samples:

tracked MGET -> RedisWatermarkMiss(W) -> source fallback -> preflight P
|-> P <= W: skip before dump
`-> P > W: dump/compress -> final N
|-> N <= W: skip SET
`-> N > W: SET frame(N)

The preflight still removes serializer, compression, frame-allocation, network, Redis, replication, and AOF work for refills already known to remain unreadable. Admitted fills use the final dispatch-adjacent timestamp, so payload preparation does not consume the stored value's logical TTL. The final recheck also suppresses a write if the wall clock moves back behind the observed watermark.

This adds no Redis command or round trip and changes no key, frame, or watermark encoding.

Design

Adapter boundary

  • Add RedisReadResult = DecodedRedisFrame | RedisWatermarkMiss | null.
  • Define RedisWatermarkMiss as { kind: "watermark_miss", observedWatermarkMs } and centralize its runtime guard.
  • Add decodeTrackedRedisReadResult for adapters that intentionally opt into discriminated watermark misses.
  • Preserve decodeTrackedRedisFrame and its established DecodedRedisFrame | null behavior for legacy custom adapters.
  • Add optional RedisWriteRequest.createdAtMs. Adapters returning discriminated misses must encode a supplied value exactly; ordinary and direct writes that omit it retain adapter-side clock sampling.
  • Node-redis and GLIDE use the same decoder and exact-timestamp behavior.

Core flow

  • Carry a validated discriminated miss through caller and detached shadow paths.
  • Sample a preflight timestamp before serializer dump; preflightCreatedAtMs <= observedWatermarkMs skips all payload and write work.
  • After payload preparation and the shadow deadline gate, sample the final timestamp and recheck the same watermark.
  • Suppress SET when the final timestamp is fenced; otherwise pass that exact final sample to the adapter.
  • Preserve existing behavior for untracked, invalid typed-miss, missing/malformed-watermark, future-frame, and legacy null misses.
  • Preserve tracked local-publication suppression and the existing later-invalidation race boundary.
  • Report either local dispatch skip as fill_fenced; discriminated confirmation misses remain superseded.

The early preflight is intentionally conservative: DialCache does not perform expensive work merely to see whether a later timestamp might clear the fence.

Compatibility and safety

  • Existing custom adapters returning DecodedRedisFrame | null remain correct and keep their prior write request/timestamp behavior; they simply do not receive this optimization.
  • Structurally augmented custom frames remain hits, including frames whose extra metadata collides with the miss discriminator.
  • Invalid discriminated misses and discriminated misses returned for untracked requests normalize to the established generic-miss path.
  • DialCacheRedisClient.read() may now return a truthy RedisWatermarkMiss, and ShadowValidationOutcome adds fill_fenced; direct and exhaustive consumers must handle both changes.
  • Missing, malformed, out-of-range, or wrong-type watermark metadata retains the established generic behavior.
  • A watermark that advances after the authoritative read can still fence an admitted write. Avoiding that race remains intentionally out of scope.

Validation

  • corepack pnpm check on Node 22.22.0 — typecheck, 633 unit tests with coverage thresholds, build/declarations, and packed ESM/CJS consumer checks pass.
  • corepack pnpm test:integration — 143 tests pass across node-redis and GLIDE on Redis 6.2 and Valkey 8; the two GLIDE Cluster cases skip locally because Docker Desktop cannot route announced container IPs, while CI remains fail-closed for those cases.
  • Focused regressions cover slow async serialization across a short logical TTL, backwards clock movement between samples, invalid and untracked discriminated misses, discriminator-collision frames, and admitted/fenced shadow fills.
  • corepack pnpm benchmark:redis-write against Redis 6.2 — admitted writes remain exactly one SET each with zero Lua and zero TIME calls.
  • git diff --check passes.
  • Independent core, API, and holistic review lanes returned clean after fixes.

BREAKING CHANGE: DialCacheRedisClient.read() may now return RedisWatermarkMiss for tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

@lan17
lan17force-pushed the codex/issue-141-fenced-refills branch from 86caf56 to a0150bdCompareAugust 30, 2026 03:57

@lan17lan17 left a comment

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I like the optimization and the implementation direction. This faithfully implements #141: tracked MGET misses can now preserve an authoritative watermark, caller/shadow fills can avoid serializer/compression/Redis work when the replacement is already known to be fenced, admitted fills do not add a Redis round trip, and legacy custom adapters can continue returning DecodedRedisFrame | null without opting into the optimization. The test coverage is also broad, including the equal-boundary case, admitted writes, large-payload serialization/compression avoidance, shadow fills, confirmation behavior, both bundled adapters, real Redis/Valkey, and packed consumers.

I do think there are two changes we should make before merging, plus one API-shape recommendation that is worth considering while this surface is new.

1. Blocking: admitted refills now start their logical TTL before serialization/compression

The important semantic change is in RedisCache.putWithLayer:

if(key.trackForInvalidation&&watermarkMiss!==undefined){createdAtMs=Date.now();
...
if(createdAtMs<=watermarkMiss.observedWatermarkMs){returnfalse;}}constserialized=awaitserializer.dump(value);// compression
...
awaitclient.write({ ...,createdAtMs});

Before this PR, the bundled adapters sampled Date.now() immediately before frame construction / SET dispatch. With this PR, a typed-miss refill that is admitted samples createdAtMs before potentially asynchronous serializer.dump, synchronous compression, size work, and any delay before the adapter dispatches the write.

That matters because createdAtMs is not only an invalidation token. Core also uses it as the logical age origin for TTL and stale-on-error decisions:

age = reader Date.now() - frame.createdAtMs

So serialization/compression time now consumes the stored value's logical TTL even though Redis starts its physical PX TTL only when the SET executes.

Concrete example:

remote TTL = 1s
candidate timestamp = t=0
serializer/compression = 1.5s
SET executes = t=1.5s

Redis receives a brand-new key with PX 1000, but the frame is already logically 1.5s old. The next DialCache read rejects it immediately and can go back to source, while the physically present key remains in Redis for another second. A slow async custom serializer makes this particularly easy to hit, but a large synchronous payload can consume a meaningful fraction of a short TTL as well.

I think we can preserve the optimization without changing the old dispatch-adjacent timestamp semantics:

constobservedWatermarkMs=watermarkMiss?.observedWatermarkMs;// Cheap preflight: lets us avoid serializer/compression entirely when already fenced.if(observedWatermarkMs!==undefined){constpreflightCreatedAtMs=Date.now();assertValidRedisTimestampMs(preflightCreatedAtMs);if(preflightCreatedAtMs<=observedWatermarkMs){returnfalse;}}constserialized=awaitserializeAndCompress(value);if(shouldWrite!==undefined&&!shouldWrite()){returnfalse;}letcreatedAtMs: number|undefined;if(observedWatermarkMs!==undefined){// Final timestamp is the one that actually goes into the frame.createdAtMs=Date.now();assertValidRedisTimestampMs(createdAtMs);// Recheck because the wall clock may have moved backwards while we serialized.if(createdAtMs<=observedWatermarkMs){returnfalse;}}awaitclient.write({
valueKey,
cacheTtlMs,value: serialized,
...(createdAtMs===undefined ? {} : { createdAtMs }),});

This gives us both desirable properties:

  • if the clock is already behind/equal to the observed watermark, we still skip before serializer.dump, compression, allocation, and SET;
  • if the fill is admitted, the timestamp written into the frame remains close to actual dispatch, preserving the pre-PR logical-TTL behavior.

The second check is also useful for a wall-clock rollback during an async serializer: the final frame timestamp should be the same timestamp used for the final admission decision.

I would add a focused test with an async dump() that advances fake time by more than a short TTL and assert that the frame written after serialization is still fresh on the subsequent read. I would also test a backwards clock move between the preflight and final sample and verify that we suppress the write if the final timestamp is again behind the observed watermark.

There is a smaller semantic point here too: even with the two-sample approach, the early preflight can skip a fill that would have become admissible by the time a slow serializer finished. I think that is a reasonable conservative optimization because the point is to avoid doing expensive work while we currently know the candidate is fenced, but it is worth treating this as intentional behavior rather than claiming the optimization is entirely semantics-neutral.

2. Blocking: this is a public breaking change but the current perf commit will release as a patch

The PR correctly calls out public type-surface changes, but the release metadata does not currently reflect them.

Two externally observable changes are breaking for exhaustive/direct consumers:

  1. DialCacheRedisClient.read() can now return a truthy RedisWatermarkMiss where a bundled adapter previously returned null for the same semantic miss.
  2. ShadowValidationOutcome adds "fill_fenced", which breaks exhaustive switches and Record<ShadowValidationOutcome, ...> values.

A direct consumer compiled against the old contract could have code like:

constframe=awaitadapter.read(request);if(frame!==null){consume(frame.payload);}

With the new bundled adapter behavior, a fenced tracked miss enters that branch with { observedWatermarkMs }. Recompilation against the new declarations catches this, but existing JS / loosely typed consumers can see a runtime behavior change.

The current commit is:

perf(redis): skip refills behind observed watermarks

and this repo's semantic-release config maps plain perf to a patch while pre-1.0 breaking changes are mapped to a minor. So as currently authored this would be released with the wrong semver classification.

I suggest making the squash/title explicitly breaking, e.g.:

perf(redis)!: skip refills behind observed watermarks

with a footer along the lines of:

BREAKING CHANGE: DialCacheRedisClient.read may return RedisWatermarkMiss for
tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

RedisWriteRequest.createdAtMs being optional is source-compatible by itself; the widened read behavior and outcome union are the pieces I would specifically call out.

3. Non-blocking but strongly recommended: use an explicit discriminant for RedisWatermarkMiss

The new public variant currently relies on structural absence/presence checks:

interfaceRedisWatermarkMiss{observedWatermarkMs: number;payload?: never;createdAtMs?: never;}

and core repeats a guard like:

"observedWatermarkMs"inresult&&!("payload"inresult)&&!("createdAtMs"inresult)

in multiple files.

Because this is a brand-new public union variant, I think we have a good opportunity to make the semantic type explicit:

exportinterfaceRedisWatermarkMiss{readonlykind: "watermark_miss";readonlyobservedWatermarkMs: number;}

Then narrowing becomes both simpler and more robust:

functionisRedisWatermarkMiss(result: RedisReadResult,): result is RedisWatermarkMiss{returnresult?.kind==="watermark_miss";}

This removes the duplicated duck-typing logic and gives custom-adapter authors a much clearer contract. The current code does intentionally preserve a structurally augmented frame containing observedWatermarkMs, which is good, but an explicit discriminator expresses that distinction directly instead of inferring it from the absence of two unrelated fields.

If we keep the current shape, I would at least centralize/export one guard rather than maintaining three subtly independent copies.

Additional test cases worth locking down

The existing coverage is very good. A few small cases would make the public adapter boundary harder to misuse:

  • invalid typed misses (NaN, negative, fractional, unsafe integer) should be normalized to a generic miss and follow the ordinary refill path rather than activating suppression;
  • a custom adapter returning a typed miss for an untracked request should not activate the optimization;
  • the dispatch-adjacent timestamp / short-TTL test described above;
  • backwards wall-clock movement between preflight and final write timestamp;
  • if we add a discriminator, a custom frame carrying arbitrary extra metadata should still remain a hit while only { kind: "watermark_miss", ... } is treated as the miss variant.

Things I think are correct as-is

  • Keeping decodeTrackedRedisFrame as the legacy DecodedRedisFrame | null helper and adding a separate opt-in decodeTrackedRedisReadResult is a good compatibility split.
  • Passing the exact admitted timestamp through RedisWriteRequest.createdAtMs is necessary; otherwise the fence decision and stored frame could diverge.
  • The optimization should remain snapshot-based. A watermark advancing after the authoritative MGET can still fence an admitted write, and avoiding that would require server-side coordination / another protocol change. I agree that is out of scope here.
  • fill_fenced is the right shadow outcome. Reporting filled when no SET was dispatched would make the telemetry misleading.
  • Confirmation turning a typed C1 watermark miss into superseded is correct: it tells us the original C0 observation no longer survives the current tracked-read semantics.
  • Missing/malformed watermark metadata falling back to the legacy generic-miss behavior is appropriately conservative.
  • No extra Redis read / TIME / Lua / transaction is introduced, which preserves the main performance constraint of #141.

So I consider the timestamp/TTL semantic regression and the release classification blocking before merge. Once those are fixed, I think the core optimization is in good shape. The discriminated-union cleanup would be ideal to do now while the type is new, but I would not block the PR solely on that if we deliberately prefer the current structural surface.

@lan17lan17 changed the title perf(redis): skip refills behind observed watermarksperf(redis)!: skip refills behind observed watermarksAug 30, 2026
@lan17lan17 changed the title perf(redis)!: skip refills behind observed watermarksperf(redis): skip refills behind observed watermarksAug 30, 2026
@lan17
lan17 merged commit 1f8238f into mainAug 30, 2026
7 checks passed
@lan17
lan17 deleted the codex/issue-141-fenced-refills branch August 30, 2026 19:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skip tracked Redis refills that remain behind the observed watermark

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

perf(redis): skip refills behind observed watermarks - #143

Merged
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills
Aug 30, 2026
Merged

perf(redis): skip refills behind observed watermarks#143
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills

Conversation

@lan17

@lan17lan17 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes#141

Summary

Tracked Redis misses now preserve a trustworthy numeric watermark when the existing authoritative MGET observed one. The public miss variant is explicitly discriminated, and conditional refills use two application-clock samples:

tracked MGET -> RedisWatermarkMiss(W) -> source fallback -> preflight P
|-> P <= W: skip before dump
`-> P > W: dump/compress -> final N
|-> N <= W: skip SET
`-> N > W: SET frame(N)

The preflight still removes serializer, compression, frame-allocation, network, Redis, replication, and AOF work for refills already known to remain unreadable. Admitted fills use the final dispatch-adjacent timestamp, so payload preparation does not consume the stored value's logical TTL. The final recheck also suppresses a write if the wall clock moves back behind the observed watermark.

This adds no Redis command or round trip and changes no key, frame, or watermark encoding.

Design

Adapter boundary

  • Add RedisReadResult = DecodedRedisFrame | RedisWatermarkMiss | null.
  • Define RedisWatermarkMiss as { kind: "watermark_miss", observedWatermarkMs } and centralize its runtime guard.
  • Add decodeTrackedRedisReadResult for adapters that intentionally opt into discriminated watermark misses.
  • Preserve decodeTrackedRedisFrame and its established DecodedRedisFrame | null behavior for legacy custom adapters.
  • Add optional RedisWriteRequest.createdAtMs. Adapters returning discriminated misses must encode a supplied value exactly; ordinary and direct writes that omit it retain adapter-side clock sampling.
  • Node-redis and GLIDE use the same decoder and exact-timestamp behavior.

Core flow

  • Carry a validated discriminated miss through caller and detached shadow paths.
  • Sample a preflight timestamp before serializer dump; preflightCreatedAtMs <= observedWatermarkMs skips all payload and write work.
  • After payload preparation and the shadow deadline gate, sample the final timestamp and recheck the same watermark.
  • Suppress SET when the final timestamp is fenced; otherwise pass that exact final sample to the adapter.
  • Preserve existing behavior for untracked, invalid typed-miss, missing/malformed-watermark, future-frame, and legacy null misses.
  • Preserve tracked local-publication suppression and the existing later-invalidation race boundary.
  • Report either local dispatch skip as fill_fenced; discriminated confirmation misses remain superseded.

The early preflight is intentionally conservative: DialCache does not perform expensive work merely to see whether a later timestamp might clear the fence.

Compatibility and safety

  • Existing custom adapters returning DecodedRedisFrame | null remain correct and keep their prior write request/timestamp behavior; they simply do not receive this optimization.
  • Structurally augmented custom frames remain hits, including frames whose extra metadata collides with the miss discriminator.
  • Invalid discriminated misses and discriminated misses returned for untracked requests normalize to the established generic-miss path.
  • DialCacheRedisClient.read() may now return a truthy RedisWatermarkMiss, and ShadowValidationOutcome adds fill_fenced; direct and exhaustive consumers must handle both changes.
  • Missing, malformed, out-of-range, or wrong-type watermark metadata retains the established generic behavior.
  • A watermark that advances after the authoritative read can still fence an admitted write. Avoiding that race remains intentionally out of scope.

Validation

  • corepack pnpm check on Node 22.22.0 — typecheck, 633 unit tests with coverage thresholds, build/declarations, and packed ESM/CJS consumer checks pass.
  • corepack pnpm test:integration — 143 tests pass across node-redis and GLIDE on Redis 6.2 and Valkey 8; the two GLIDE Cluster cases skip locally because Docker Desktop cannot route announced container IPs, while CI remains fail-closed for those cases.
  • Focused regressions cover slow async serialization across a short logical TTL, backwards clock movement between samples, invalid and untracked discriminated misses, discriminator-collision frames, and admitted/fenced shadow fills.
  • corepack pnpm benchmark:redis-write against Redis 6.2 — admitted writes remain exactly one SET each with zero Lua and zero TIME calls.
  • git diff --check passes.
  • Independent core, API, and holistic review lanes returned clean after fixes.

BREAKING CHANGE: DialCacheRedisClient.read() may now return RedisWatermarkMiss for tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

@lan17
lan17force-pushed the codex/issue-141-fenced-refills branch from 86caf56 to a0150bdCompareAugust 30, 2026 03:57

@lan17lan17 left a comment

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I like the optimization and the implementation direction. This faithfully implements #141: tracked MGET misses can now preserve an authoritative watermark, caller/shadow fills can avoid serializer/compression/Redis work when the replacement is already known to be fenced, admitted fills do not add a Redis round trip, and legacy custom adapters can continue returning DecodedRedisFrame | null without opting into the optimization. The test coverage is also broad, including the equal-boundary case, admitted writes, large-payload serialization/compression avoidance, shadow fills, confirmation behavior, both bundled adapters, real Redis/Valkey, and packed consumers.

I do think there are two changes we should make before merging, plus one API-shape recommendation that is worth considering while this surface is new.

1. Blocking: admitted refills now start their logical TTL before serialization/compression

The important semantic change is in RedisCache.putWithLayer:

if(key.trackForInvalidation&&watermarkMiss!==undefined){createdAtMs=Date.now();
...
if(createdAtMs<=watermarkMiss.observedWatermarkMs){returnfalse;}}constserialized=awaitserializer.dump(value);// compression
...
awaitclient.write({ ...,createdAtMs});

Before this PR, the bundled adapters sampled Date.now() immediately before frame construction / SET dispatch. With this PR, a typed-miss refill that is admitted samples createdAtMs before potentially asynchronous serializer.dump, synchronous compression, size work, and any delay before the adapter dispatches the write.

That matters because createdAtMs is not only an invalidation token. Core also uses it as the logical age origin for TTL and stale-on-error decisions:

age = reader Date.now() - frame.createdAtMs

So serialization/compression time now consumes the stored value's logical TTL even though Redis starts its physical PX TTL only when the SET executes.

Concrete example:

remote TTL = 1s
candidate timestamp = t=0
serializer/compression = 1.5s
SET executes = t=1.5s

Redis receives a brand-new key with PX 1000, but the frame is already logically 1.5s old. The next DialCache read rejects it immediately and can go back to source, while the physically present key remains in Redis for another second. A slow async custom serializer makes this particularly easy to hit, but a large synchronous payload can consume a meaningful fraction of a short TTL as well.

I think we can preserve the optimization without changing the old dispatch-adjacent timestamp semantics:

constobservedWatermarkMs=watermarkMiss?.observedWatermarkMs;// Cheap preflight: lets us avoid serializer/compression entirely when already fenced.if(observedWatermarkMs!==undefined){constpreflightCreatedAtMs=Date.now();assertValidRedisTimestampMs(preflightCreatedAtMs);if(preflightCreatedAtMs<=observedWatermarkMs){returnfalse;}}constserialized=awaitserializeAndCompress(value);if(shouldWrite!==undefined&&!shouldWrite()){returnfalse;}letcreatedAtMs: number|undefined;if(observedWatermarkMs!==undefined){// Final timestamp is the one that actually goes into the frame.createdAtMs=Date.now();assertValidRedisTimestampMs(createdAtMs);// Recheck because the wall clock may have moved backwards while we serialized.if(createdAtMs<=observedWatermarkMs){returnfalse;}}awaitclient.write({
valueKey,
cacheTtlMs,value: serialized,
...(createdAtMs===undefined ? {} : { createdAtMs }),});

This gives us both desirable properties:

  • if the clock is already behind/equal to the observed watermark, we still skip before serializer.dump, compression, allocation, and SET;
  • if the fill is admitted, the timestamp written into the frame remains close to actual dispatch, preserving the pre-PR logical-TTL behavior.

The second check is also useful for a wall-clock rollback during an async serializer: the final frame timestamp should be the same timestamp used for the final admission decision.

I would add a focused test with an async dump() that advances fake time by more than a short TTL and assert that the frame written after serialization is still fresh on the subsequent read. I would also test a backwards clock move between the preflight and final sample and verify that we suppress the write if the final timestamp is again behind the observed watermark.

There is a smaller semantic point here too: even with the two-sample approach, the early preflight can skip a fill that would have become admissible by the time a slow serializer finished. I think that is a reasonable conservative optimization because the point is to avoid doing expensive work while we currently know the candidate is fenced, but it is worth treating this as intentional behavior rather than claiming the optimization is entirely semantics-neutral.

2. Blocking: this is a public breaking change but the current perf commit will release as a patch

The PR correctly calls out public type-surface changes, but the release metadata does not currently reflect them.

Two externally observable changes are breaking for exhaustive/direct consumers:

  1. DialCacheRedisClient.read() can now return a truthy RedisWatermarkMiss where a bundled adapter previously returned null for the same semantic miss.
  2. ShadowValidationOutcome adds "fill_fenced", which breaks exhaustive switches and Record<ShadowValidationOutcome, ...> values.

A direct consumer compiled against the old contract could have code like:

constframe=awaitadapter.read(request);if(frame!==null){consume(frame.payload);}

With the new bundled adapter behavior, a fenced tracked miss enters that branch with { observedWatermarkMs }. Recompilation against the new declarations catches this, but existing JS / loosely typed consumers can see a runtime behavior change.

The current commit is:

perf(redis): skip refills behind observed watermarks

and this repo's semantic-release config maps plain perf to a patch while pre-1.0 breaking changes are mapped to a minor. So as currently authored this would be released with the wrong semver classification.

I suggest making the squash/title explicitly breaking, e.g.:

perf(redis)!: skip refills behind observed watermarks

with a footer along the lines of:

BREAKING CHANGE: DialCacheRedisClient.read may return RedisWatermarkMiss for
tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

RedisWriteRequest.createdAtMs being optional is source-compatible by itself; the widened read behavior and outcome union are the pieces I would specifically call out.

3. Non-blocking but strongly recommended: use an explicit discriminant for RedisWatermarkMiss

The new public variant currently relies on structural absence/presence checks:

interfaceRedisWatermarkMiss{observedWatermarkMs: number;payload?: never;createdAtMs?: never;}

and core repeats a guard like:

"observedWatermarkMs"inresult&&!("payload"inresult)&&!("createdAtMs"inresult)

in multiple files.

Because this is a brand-new public union variant, I think we have a good opportunity to make the semantic type explicit:

exportinterfaceRedisWatermarkMiss{readonlykind: "watermark_miss";readonlyobservedWatermarkMs: number;}

Then narrowing becomes both simpler and more robust:

functionisRedisWatermarkMiss(result: RedisReadResult,): result is RedisWatermarkMiss{returnresult?.kind==="watermark_miss";}

This removes the duplicated duck-typing logic and gives custom-adapter authors a much clearer contract. The current code does intentionally preserve a structurally augmented frame containing observedWatermarkMs, which is good, but an explicit discriminator expresses that distinction directly instead of inferring it from the absence of two unrelated fields.

If we keep the current shape, I would at least centralize/export one guard rather than maintaining three subtly independent copies.

Additional test cases worth locking down

The existing coverage is very good. A few small cases would make the public adapter boundary harder to misuse:

  • invalid typed misses (NaN, negative, fractional, unsafe integer) should be normalized to a generic miss and follow the ordinary refill path rather than activating suppression;
  • a custom adapter returning a typed miss for an untracked request should not activate the optimization;
  • the dispatch-adjacent timestamp / short-TTL test described above;
  • backwards wall-clock movement between preflight and final write timestamp;
  • if we add a discriminator, a custom frame carrying arbitrary extra metadata should still remain a hit while only { kind: "watermark_miss", ... } is treated as the miss variant.

Things I think are correct as-is

  • Keeping decodeTrackedRedisFrame as the legacy DecodedRedisFrame | null helper and adding a separate opt-in decodeTrackedRedisReadResult is a good compatibility split.
  • Passing the exact admitted timestamp through RedisWriteRequest.createdAtMs is necessary; otherwise the fence decision and stored frame could diverge.
  • The optimization should remain snapshot-based. A watermark advancing after the authoritative MGET can still fence an admitted write, and avoiding that would require server-side coordination / another protocol change. I agree that is out of scope here.
  • fill_fenced is the right shadow outcome. Reporting filled when no SET was dispatched would make the telemetry misleading.
  • Confirmation turning a typed C1 watermark miss into superseded is correct: it tells us the original C0 observation no longer survives the current tracked-read semantics.
  • Missing/malformed watermark metadata falling back to the legacy generic-miss behavior is appropriately conservative.
  • No extra Redis read / TIME / Lua / transaction is introduced, which preserves the main performance constraint of #141.

So I consider the timestamp/TTL semantic regression and the release classification blocking before merge. Once those are fixed, I think the core optimization is in good shape. The discriminated-union cleanup would be ideal to do now while the type is new, but I would not block the PR solely on that if we deliberately prefer the current structural surface.

@lan17lan17 changed the title perf(redis): skip refills behind observed watermarksperf(redis)!: skip refills behind observed watermarksAug 30, 2026
@lan17lan17 changed the title perf(redis)!: skip refills behind observed watermarksperf(redis): skip refills behind observed watermarksAug 30, 2026
@lan17
lan17 merged commit 1f8238f into mainAug 30, 2026
7 checks passed
@lan17
lan17 deleted the codex/issue-141-fenced-refills branch August 30, 2026 19:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skip tracked Redis refills that remain behind the observed watermark

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(redis): skip refills behind observed watermarks - #143

Merged
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills
Aug 30, 2026
Merged

perf(redis): skip refills behind observed watermarks#143
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills

Conversation

@lan17

@lan17lan17 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes#141

Summary

Tracked Redis misses now preserve a trustworthy numeric watermark when the existing authoritative MGET observed one. The public miss variant is explicitly discriminated, and conditional refills use two application-clock samples:

tracked MGET -> RedisWatermarkMiss(W) -> source fallback -> preflight P
|-> P <= W: skip before dump
`-> P > W: dump/compress -> final N
|-> N <= W: skip SET
`-> N > W: SET frame(N)

The preflight still removes serializer, compression, frame-allocation, network, Redis, replication, and AOF work for refills already known to remain unreadable. Admitted fills use the final dispatch-adjacent timestamp, so payload preparation does not consume the stored value's logical TTL. The final recheck also suppresses a write if the wall clock moves back behind the observed watermark.

This adds no Redis command or round trip and changes no key, frame, or watermark encoding.

Design

Adapter boundary

  • Add RedisReadResult = DecodedRedisFrame | RedisWatermarkMiss | null.
  • Define RedisWatermarkMiss as { kind: "watermark_miss", observedWatermarkMs } and centralize its runtime guard.
  • Add decodeTrackedRedisReadResult for adapters that intentionally opt into discriminated watermark misses.
  • Preserve decodeTrackedRedisFrame and its established DecodedRedisFrame | null behavior for legacy custom adapters.
  • Add optional RedisWriteRequest.createdAtMs. Adapters returning discriminated misses must encode a supplied value exactly; ordinary and direct writes that omit it retain adapter-side clock sampling.
  • Node-redis and GLIDE use the same decoder and exact-timestamp behavior.

Core flow

  • Carry a validated discriminated miss through caller and detached shadow paths.
  • Sample a preflight timestamp before serializer dump; preflightCreatedAtMs <= observedWatermarkMs skips all payload and write work.
  • After payload preparation and the shadow deadline gate, sample the final timestamp and recheck the same watermark.
  • Suppress SET when the final timestamp is fenced; otherwise pass that exact final sample to the adapter.
  • Preserve existing behavior for untracked, invalid typed-miss, missing/malformed-watermark, future-frame, and legacy null misses.
  • Preserve tracked local-publication suppression and the existing later-invalidation race boundary.
  • Report either local dispatch skip as fill_fenced; discriminated confirmation misses remain superseded.

The early preflight is intentionally conservative: DialCache does not perform expensive work merely to see whether a later timestamp might clear the fence.

Compatibility and safety

  • Existing custom adapters returning DecodedRedisFrame | null remain correct and keep their prior write request/timestamp behavior; they simply do not receive this optimization.
  • Structurally augmented custom frames remain hits, including frames whose extra metadata collides with the miss discriminator.
  • Invalid discriminated misses and discriminated misses returned for untracked requests normalize to the established generic-miss path.
  • DialCacheRedisClient.read() may now return a truthy RedisWatermarkMiss, and ShadowValidationOutcome adds fill_fenced; direct and exhaustive consumers must handle both changes.
  • Missing, malformed, out-of-range, or wrong-type watermark metadata retains the established generic behavior.
  • A watermark that advances after the authoritative read can still fence an admitted write. Avoiding that race remains intentionally out of scope.

Validation

  • corepack pnpm check on Node 22.22.0 — typecheck, 633 unit tests with coverage thresholds, build/declarations, and packed ESM/CJS consumer checks pass.
  • corepack pnpm test:integration — 143 tests pass across node-redis and GLIDE on Redis 6.2 and Valkey 8; the two GLIDE Cluster cases skip locally because Docker Desktop cannot route announced container IPs, while CI remains fail-closed for those cases.
  • Focused regressions cover slow async serialization across a short logical TTL, backwards clock movement between samples, invalid and untracked discriminated misses, discriminator-collision frames, and admitted/fenced shadow fills.
  • corepack pnpm benchmark:redis-write against Redis 6.2 — admitted writes remain exactly one SET each with zero Lua and zero TIME calls.
  • git diff --check passes.
  • Independent core, API, and holistic review lanes returned clean after fixes.

BREAKING CHANGE: DialCacheRedisClient.read() may now return RedisWatermarkMiss for tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

@lan17
lan17force-pushed the codex/issue-141-fenced-refills branch from 86caf56 to a0150bdCompareAugust 30, 2026 03:57

@lan17lan17 left a comment

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I like the optimization and the implementation direction. This faithfully implements #141: tracked MGET misses can now preserve an authoritative watermark, caller/shadow fills can avoid serializer/compression/Redis work when the replacement is already known to be fenced, admitted fills do not add a Redis round trip, and legacy custom adapters can continue returning DecodedRedisFrame | null without opting into the optimization. The test coverage is also broad, including the equal-boundary case, admitted writes, large-payload serialization/compression avoidance, shadow fills, confirmation behavior, both bundled adapters, real Redis/Valkey, and packed consumers.

I do think there are two changes we should make before merging, plus one API-shape recommendation that is worth considering while this surface is new.

1. Blocking: admitted refills now start their logical TTL before serialization/compression

The important semantic change is in RedisCache.putWithLayer:

if(key.trackForInvalidation&&watermarkMiss!==undefined){createdAtMs=Date.now();
...
if(createdAtMs<=watermarkMiss.observedWatermarkMs){returnfalse;}}constserialized=awaitserializer.dump(value);// compression
...
awaitclient.write({ ...,createdAtMs});

Before this PR, the bundled adapters sampled Date.now() immediately before frame construction / SET dispatch. With this PR, a typed-miss refill that is admitted samples createdAtMs before potentially asynchronous serializer.dump, synchronous compression, size work, and any delay before the adapter dispatches the write.

That matters because createdAtMs is not only an invalidation token. Core also uses it as the logical age origin for TTL and stale-on-error decisions:

age = reader Date.now() - frame.createdAtMs

So serialization/compression time now consumes the stored value's logical TTL even though Redis starts its physical PX TTL only when the SET executes.

Concrete example:

remote TTL = 1s
candidate timestamp = t=0
serializer/compression = 1.5s
SET executes = t=1.5s

Redis receives a brand-new key with PX 1000, but the frame is already logically 1.5s old. The next DialCache read rejects it immediately and can go back to source, while the physically present key remains in Redis for another second. A slow async custom serializer makes this particularly easy to hit, but a large synchronous payload can consume a meaningful fraction of a short TTL as well.

I think we can preserve the optimization without changing the old dispatch-adjacent timestamp semantics:

constobservedWatermarkMs=watermarkMiss?.observedWatermarkMs;// Cheap preflight: lets us avoid serializer/compression entirely when already fenced.if(observedWatermarkMs!==undefined){constpreflightCreatedAtMs=Date.now();assertValidRedisTimestampMs(preflightCreatedAtMs);if(preflightCreatedAtMs<=observedWatermarkMs){returnfalse;}}constserialized=awaitserializeAndCompress(value);if(shouldWrite!==undefined&&!shouldWrite()){returnfalse;}letcreatedAtMs: number|undefined;if(observedWatermarkMs!==undefined){// Final timestamp is the one that actually goes into the frame.createdAtMs=Date.now();assertValidRedisTimestampMs(createdAtMs);// Recheck because the wall clock may have moved backwards while we serialized.if(createdAtMs<=observedWatermarkMs){returnfalse;}}awaitclient.write({
valueKey,
cacheTtlMs,value: serialized,
...(createdAtMs===undefined ? {} : { createdAtMs }),});

This gives us both desirable properties:

  • if the clock is already behind/equal to the observed watermark, we still skip before serializer.dump, compression, allocation, and SET;
  • if the fill is admitted, the timestamp written into the frame remains close to actual dispatch, preserving the pre-PR logical-TTL behavior.

The second check is also useful for a wall-clock rollback during an async serializer: the final frame timestamp should be the same timestamp used for the final admission decision.

I would add a focused test with an async dump() that advances fake time by more than a short TTL and assert that the frame written after serialization is still fresh on the subsequent read. I would also test a backwards clock move between the preflight and final sample and verify that we suppress the write if the final timestamp is again behind the observed watermark.

There is a smaller semantic point here too: even with the two-sample approach, the early preflight can skip a fill that would have become admissible by the time a slow serializer finished. I think that is a reasonable conservative optimization because the point is to avoid doing expensive work while we currently know the candidate is fenced, but it is worth treating this as intentional behavior rather than claiming the optimization is entirely semantics-neutral.

2. Blocking: this is a public breaking change but the current perf commit will release as a patch

The PR correctly calls out public type-surface changes, but the release metadata does not currently reflect them.

Two externally observable changes are breaking for exhaustive/direct consumers:

  1. DialCacheRedisClient.read() can now return a truthy RedisWatermarkMiss where a bundled adapter previously returned null for the same semantic miss.
  2. ShadowValidationOutcome adds "fill_fenced", which breaks exhaustive switches and Record<ShadowValidationOutcome, ...> values.

A direct consumer compiled against the old contract could have code like:

constframe=awaitadapter.read(request);if(frame!==null){consume(frame.payload);}

With the new bundled adapter behavior, a fenced tracked miss enters that branch with { observedWatermarkMs }. Recompilation against the new declarations catches this, but existing JS / loosely typed consumers can see a runtime behavior change.

The current commit is:

perf(redis): skip refills behind observed watermarks

and this repo's semantic-release config maps plain perf to a patch while pre-1.0 breaking changes are mapped to a minor. So as currently authored this would be released with the wrong semver classification.

I suggest making the squash/title explicitly breaking, e.g.:

perf(redis)!: skip refills behind observed watermarks

with a footer along the lines of:

BREAKING CHANGE: DialCacheRedisClient.read may return RedisWatermarkMiss for
tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

RedisWriteRequest.createdAtMs being optional is source-compatible by itself; the widened read behavior and outcome union are the pieces I would specifically call out.

3. Non-blocking but strongly recommended: use an explicit discriminant for RedisWatermarkMiss

The new public variant currently relies on structural absence/presence checks:

interfaceRedisWatermarkMiss{observedWatermarkMs: number;payload?: never;createdAtMs?: never;}

and core repeats a guard like:

"observedWatermarkMs"inresult&&!("payload"inresult)&&!("createdAtMs"inresult)

in multiple files.

Because this is a brand-new public union variant, I think we have a good opportunity to make the semantic type explicit:

exportinterfaceRedisWatermarkMiss{readonlykind: "watermark_miss";readonlyobservedWatermarkMs: number;}

Then narrowing becomes both simpler and more robust:

functionisRedisWatermarkMiss(result: RedisReadResult,): result is RedisWatermarkMiss{returnresult?.kind==="watermark_miss";}

This removes the duplicated duck-typing logic and gives custom-adapter authors a much clearer contract. The current code does intentionally preserve a structurally augmented frame containing observedWatermarkMs, which is good, but an explicit discriminator expresses that distinction directly instead of inferring it from the absence of two unrelated fields.

If we keep the current shape, I would at least centralize/export one guard rather than maintaining three subtly independent copies.

Additional test cases worth locking down

The existing coverage is very good. A few small cases would make the public adapter boundary harder to misuse:

  • invalid typed misses (NaN, negative, fractional, unsafe integer) should be normalized to a generic miss and follow the ordinary refill path rather than activating suppression;
  • a custom adapter returning a typed miss for an untracked request should not activate the optimization;
  • the dispatch-adjacent timestamp / short-TTL test described above;
  • backwards wall-clock movement between preflight and final write timestamp;
  • if we add a discriminator, a custom frame carrying arbitrary extra metadata should still remain a hit while only { kind: "watermark_miss", ... } is treated as the miss variant.

Things I think are correct as-is

  • Keeping decodeTrackedRedisFrame as the legacy DecodedRedisFrame | null helper and adding a separate opt-in decodeTrackedRedisReadResult is a good compatibility split.
  • Passing the exact admitted timestamp through RedisWriteRequest.createdAtMs is necessary; otherwise the fence decision and stored frame could diverge.
  • The optimization should remain snapshot-based. A watermark advancing after the authoritative MGET can still fence an admitted write, and avoiding that would require server-side coordination / another protocol change. I agree that is out of scope here.
  • fill_fenced is the right shadow outcome. Reporting filled when no SET was dispatched would make the telemetry misleading.
  • Confirmation turning a typed C1 watermark miss into superseded is correct: it tells us the original C0 observation no longer survives the current tracked-read semantics.
  • Missing/malformed watermark metadata falling back to the legacy generic-miss behavior is appropriately conservative.
  • No extra Redis read / TIME / Lua / transaction is introduced, which preserves the main performance constraint of #141.

So I consider the timestamp/TTL semantic regression and the release classification blocking before merge. Once those are fixed, I think the core optimization is in good shape. The discriminated-union cleanup would be ideal to do now while the type is new, but I would not block the PR solely on that if we deliberately prefer the current structural surface.

@lan17lan17 changed the title perf(redis): skip refills behind observed watermarksperf(redis)!: skip refills behind observed watermarksAug 30, 2026
@lan17lan17 changed the title perf(redis)!: skip refills behind observed watermarksperf(redis): skip refills behind observed watermarksAug 30, 2026
@lan17
lan17 merged commit 1f8238f into mainAug 30, 2026
7 checks passed
@lan17
lan17 deleted the codex/issue-141-fenced-refills branch August 30, 2026 19:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skip tracked Redis refills that remain behind the observed watermark

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(redis): skip refills behind observed watermarks - #143

Merged
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills
Aug 30, 2026
Merged

perf(redis): skip refills behind observed watermarks#143
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills

Conversation

@lan17

@lan17lan17 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes#141

Summary

Tracked Redis misses now preserve a trustworthy numeric watermark when the existing authoritative MGET observed one. The public miss variant is explicitly discriminated, and conditional refills use two application-clock samples:

tracked MGET -> RedisWatermarkMiss(W) -> source fallback -> preflight P
|-> P <= W: skip before dump
`-> P > W: dump/compress -> final N
|-> N <= W: skip SET
`-> N > W: SET frame(N)

The preflight still removes serializer, compression, frame-allocation, network, Redis, replication, and AOF work for refills already known to remain unreadable. Admitted fills use the final dispatch-adjacent timestamp, so payload preparation does not consume the stored value's logical TTL. The final recheck also suppresses a write if the wall clock moves back behind the observed watermark.

This adds no Redis command or round trip and changes no key, frame, or watermark encoding.

Design

Adapter boundary

  • Add RedisReadResult = DecodedRedisFrame | RedisWatermarkMiss | null.
  • Define RedisWatermarkMiss as { kind: "watermark_miss", observedWatermarkMs } and centralize its runtime guard.
  • Add decodeTrackedRedisReadResult for adapters that intentionally opt into discriminated watermark misses.
  • Preserve decodeTrackedRedisFrame and its established DecodedRedisFrame | null behavior for legacy custom adapters.
  • Add optional RedisWriteRequest.createdAtMs. Adapters returning discriminated misses must encode a supplied value exactly; ordinary and direct writes that omit it retain adapter-side clock sampling.
  • Node-redis and GLIDE use the same decoder and exact-timestamp behavior.

Core flow

  • Carry a validated discriminated miss through caller and detached shadow paths.
  • Sample a preflight timestamp before serializer dump; preflightCreatedAtMs <= observedWatermarkMs skips all payload and write work.
  • After payload preparation and the shadow deadline gate, sample the final timestamp and recheck the same watermark.
  • Suppress SET when the final timestamp is fenced; otherwise pass that exact final sample to the adapter.
  • Preserve existing behavior for untracked, invalid typed-miss, missing/malformed-watermark, future-frame, and legacy null misses.
  • Preserve tracked local-publication suppression and the existing later-invalidation race boundary.
  • Report either local dispatch skip as fill_fenced; discriminated confirmation misses remain superseded.

The early preflight is intentionally conservative: DialCache does not perform expensive work merely to see whether a later timestamp might clear the fence.

Compatibility and safety

  • Existing custom adapters returning DecodedRedisFrame | null remain correct and keep their prior write request/timestamp behavior; they simply do not receive this optimization.
  • Structurally augmented custom frames remain hits, including frames whose extra metadata collides with the miss discriminator.
  • Invalid discriminated misses and discriminated misses returned for untracked requests normalize to the established generic-miss path.
  • DialCacheRedisClient.read() may now return a truthy RedisWatermarkMiss, and ShadowValidationOutcome adds fill_fenced; direct and exhaustive consumers must handle both changes.
  • Missing, malformed, out-of-range, or wrong-type watermark metadata retains the established generic behavior.
  • A watermark that advances after the authoritative read can still fence an admitted write. Avoiding that race remains intentionally out of scope.

Validation

  • corepack pnpm check on Node 22.22.0 — typecheck, 633 unit tests with coverage thresholds, build/declarations, and packed ESM/CJS consumer checks pass.
  • corepack pnpm test:integration — 143 tests pass across node-redis and GLIDE on Redis 6.2 and Valkey 8; the two GLIDE Cluster cases skip locally because Docker Desktop cannot route announced container IPs, while CI remains fail-closed for those cases.
  • Focused regressions cover slow async serialization across a short logical TTL, backwards clock movement between samples, invalid and untracked discriminated misses, discriminator-collision frames, and admitted/fenced shadow fills.
  • corepack pnpm benchmark:redis-write against Redis 6.2 — admitted writes remain exactly one SET each with zero Lua and zero TIME calls.
  • git diff --check passes.
  • Independent core, API, and holistic review lanes returned clean after fixes.

BREAKING CHANGE: DialCacheRedisClient.read() may now return RedisWatermarkMiss for tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

@lan17
lan17force-pushed the codex/issue-141-fenced-refills branch from 86caf56 to a0150bdCompareAugust 30, 2026 03:57

@lan17lan17 left a comment

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I like the optimization and the implementation direction. This faithfully implements #141: tracked MGET misses can now preserve an authoritative watermark, caller/shadow fills can avoid serializer/compression/Redis work when the replacement is already known to be fenced, admitted fills do not add a Redis round trip, and legacy custom adapters can continue returning DecodedRedisFrame | null without opting into the optimization. The test coverage is also broad, including the equal-boundary case, admitted writes, large-payload serialization/compression avoidance, shadow fills, confirmation behavior, both bundled adapters, real Redis/Valkey, and packed consumers.

I do think there are two changes we should make before merging, plus one API-shape recommendation that is worth considering while this surface is new.

1. Blocking: admitted refills now start their logical TTL before serialization/compression

The important semantic change is in RedisCache.putWithLayer:

if(key.trackForInvalidation&&watermarkMiss!==undefined){createdAtMs=Date.now();
...
if(createdAtMs<=watermarkMiss.observedWatermarkMs){returnfalse;}}constserialized=awaitserializer.dump(value);// compression
...
awaitclient.write({ ...,createdAtMs});

Before this PR, the bundled adapters sampled Date.now() immediately before frame construction / SET dispatch. With this PR, a typed-miss refill that is admitted samples createdAtMs before potentially asynchronous serializer.dump, synchronous compression, size work, and any delay before the adapter dispatches the write.

That matters because createdAtMs is not only an invalidation token. Core also uses it as the logical age origin for TTL and stale-on-error decisions:

age = reader Date.now() - frame.createdAtMs

So serialization/compression time now consumes the stored value's logical TTL even though Redis starts its physical PX TTL only when the SET executes.

Concrete example:

remote TTL = 1s
candidate timestamp = t=0
serializer/compression = 1.5s
SET executes = t=1.5s

Redis receives a brand-new key with PX 1000, but the frame is already logically 1.5s old. The next DialCache read rejects it immediately and can go back to source, while the physically present key remains in Redis for another second. A slow async custom serializer makes this particularly easy to hit, but a large synchronous payload can consume a meaningful fraction of a short TTL as well.

I think we can preserve the optimization without changing the old dispatch-adjacent timestamp semantics:

constobservedWatermarkMs=watermarkMiss?.observedWatermarkMs;// Cheap preflight: lets us avoid serializer/compression entirely when already fenced.if(observedWatermarkMs!==undefined){constpreflightCreatedAtMs=Date.now();assertValidRedisTimestampMs(preflightCreatedAtMs);if(preflightCreatedAtMs<=observedWatermarkMs){returnfalse;}}constserialized=awaitserializeAndCompress(value);if(shouldWrite!==undefined&&!shouldWrite()){returnfalse;}letcreatedAtMs: number|undefined;if(observedWatermarkMs!==undefined){// Final timestamp is the one that actually goes into the frame.createdAtMs=Date.now();assertValidRedisTimestampMs(createdAtMs);// Recheck because the wall clock may have moved backwards while we serialized.if(createdAtMs<=observedWatermarkMs){returnfalse;}}awaitclient.write({
valueKey,
cacheTtlMs,value: serialized,
...(createdAtMs===undefined ? {} : { createdAtMs }),});

This gives us both desirable properties:

  • if the clock is already behind/equal to the observed watermark, we still skip before serializer.dump, compression, allocation, and SET;
  • if the fill is admitted, the timestamp written into the frame remains close to actual dispatch, preserving the pre-PR logical-TTL behavior.

The second check is also useful for a wall-clock rollback during an async serializer: the final frame timestamp should be the same timestamp used for the final admission decision.

I would add a focused test with an async dump() that advances fake time by more than a short TTL and assert that the frame written after serialization is still fresh on the subsequent read. I would also test a backwards clock move between the preflight and final sample and verify that we suppress the write if the final timestamp is again behind the observed watermark.

There is a smaller semantic point here too: even with the two-sample approach, the early preflight can skip a fill that would have become admissible by the time a slow serializer finished. I think that is a reasonable conservative optimization because the point is to avoid doing expensive work while we currently know the candidate is fenced, but it is worth treating this as intentional behavior rather than claiming the optimization is entirely semantics-neutral.

2. Blocking: this is a public breaking change but the current perf commit will release as a patch

The PR correctly calls out public type-surface changes, but the release metadata does not currently reflect them.

Two externally observable changes are breaking for exhaustive/direct consumers:

  1. DialCacheRedisClient.read() can now return a truthy RedisWatermarkMiss where a bundled adapter previously returned null for the same semantic miss.
  2. ShadowValidationOutcome adds "fill_fenced", which breaks exhaustive switches and Record<ShadowValidationOutcome, ...> values.

A direct consumer compiled against the old contract could have code like:

constframe=awaitadapter.read(request);if(frame!==null){consume(frame.payload);}

With the new bundled adapter behavior, a fenced tracked miss enters that branch with { observedWatermarkMs }. Recompilation against the new declarations catches this, but existing JS / loosely typed consumers can see a runtime behavior change.

The current commit is:

perf(redis): skip refills behind observed watermarks

and this repo's semantic-release config maps plain perf to a patch while pre-1.0 breaking changes are mapped to a minor. So as currently authored this would be released with the wrong semver classification.

I suggest making the squash/title explicitly breaking, e.g.:

perf(redis)!: skip refills behind observed watermarks

with a footer along the lines of:

BREAKING CHANGE: DialCacheRedisClient.read may return RedisWatermarkMiss for
tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

RedisWriteRequest.createdAtMs being optional is source-compatible by itself; the widened read behavior and outcome union are the pieces I would specifically call out.

3. Non-blocking but strongly recommended: use an explicit discriminant for RedisWatermarkMiss

The new public variant currently relies on structural absence/presence checks:

interfaceRedisWatermarkMiss{observedWatermarkMs: number;payload?: never;createdAtMs?: never;}

and core repeats a guard like:

"observedWatermarkMs"inresult&&!("payload"inresult)&&!("createdAtMs"inresult)

in multiple files.

Because this is a brand-new public union variant, I think we have a good opportunity to make the semantic type explicit:

exportinterfaceRedisWatermarkMiss{readonlykind: "watermark_miss";readonlyobservedWatermarkMs: number;}

Then narrowing becomes both simpler and more robust:

functionisRedisWatermarkMiss(result: RedisReadResult,): result is RedisWatermarkMiss{returnresult?.kind==="watermark_miss";}

This removes the duplicated duck-typing logic and gives custom-adapter authors a much clearer contract. The current code does intentionally preserve a structurally augmented frame containing observedWatermarkMs, which is good, but an explicit discriminator expresses that distinction directly instead of inferring it from the absence of two unrelated fields.

If we keep the current shape, I would at least centralize/export one guard rather than maintaining three subtly independent copies.

Additional test cases worth locking down

The existing coverage is very good. A few small cases would make the public adapter boundary harder to misuse:

  • invalid typed misses (NaN, negative, fractional, unsafe integer) should be normalized to a generic miss and follow the ordinary refill path rather than activating suppression;
  • a custom adapter returning a typed miss for an untracked request should not activate the optimization;
  • the dispatch-adjacent timestamp / short-TTL test described above;
  • backwards wall-clock movement between preflight and final write timestamp;
  • if we add a discriminator, a custom frame carrying arbitrary extra metadata should still remain a hit while only { kind: "watermark_miss", ... } is treated as the miss variant.

Things I think are correct as-is

  • Keeping decodeTrackedRedisFrame as the legacy DecodedRedisFrame | null helper and adding a separate opt-in decodeTrackedRedisReadResult is a good compatibility split.
  • Passing the exact admitted timestamp through RedisWriteRequest.createdAtMs is necessary; otherwise the fence decision and stored frame could diverge.
  • The optimization should remain snapshot-based. A watermark advancing after the authoritative MGET can still fence an admitted write, and avoiding that would require server-side coordination / another protocol change. I agree that is out of scope here.
  • fill_fenced is the right shadow outcome. Reporting filled when no SET was dispatched would make the telemetry misleading.
  • Confirmation turning a typed C1 watermark miss into superseded is correct: it tells us the original C0 observation no longer survives the current tracked-read semantics.
  • Missing/malformed watermark metadata falling back to the legacy generic-miss behavior is appropriately conservative.
  • No extra Redis read / TIME / Lua / transaction is introduced, which preserves the main performance constraint of #141.

So I consider the timestamp/TTL semantic regression and the release classification blocking before merge. Once those are fixed, I think the core optimization is in good shape. The discriminated-union cleanup would be ideal to do now while the type is new, but I would not block the PR solely on that if we deliberately prefer the current structural surface.

@lan17lan17 changed the title perf(redis): skip refills behind observed watermarksperf(redis)!: skip refills behind observed watermarksAug 30, 2026
@lan17lan17 changed the title perf(redis)!: skip refills behind observed watermarksperf(redis): skip refills behind observed watermarksAug 30, 2026
@lan17
lan17 merged commit 1f8238f into mainAug 30, 2026
7 checks passed
@lan17
lan17 deleted the codex/issue-141-fenced-refills branch August 30, 2026 19:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skip tracked Redis refills that remain behind the observed watermark

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

perf(redis): skip refills behind observed watermarks - #143

Merged
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills
Aug 30, 2026
Merged

perf(redis): skip refills behind observed watermarks#143
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills

Conversation

@lan17

@lan17lan17 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes#141

Summary

Tracked Redis misses now preserve a trustworthy numeric watermark when the existing authoritative MGET observed one. The public miss variant is explicitly discriminated, and conditional refills use two application-clock samples:

tracked MGET -> RedisWatermarkMiss(W) -> source fallback -> preflight P
|-> P <= W: skip before dump
`-> P > W: dump/compress -> final N
|-> N <= W: skip SET
`-> N > W: SET frame(N)

The preflight still removes serializer, compression, frame-allocation, network, Redis, replication, and AOF work for refills already known to remain unreadable. Admitted fills use the final dispatch-adjacent timestamp, so payload preparation does not consume the stored value's logical TTL. The final recheck also suppresses a write if the wall clock moves back behind the observed watermark.

This adds no Redis command or round trip and changes no key, frame, or watermark encoding.

Design

Adapter boundary

  • Add RedisReadResult = DecodedRedisFrame | RedisWatermarkMiss | null.
  • Define RedisWatermarkMiss as { kind: "watermark_miss", observedWatermarkMs } and centralize its runtime guard.
  • Add decodeTrackedRedisReadResult for adapters that intentionally opt into discriminated watermark misses.
  • Preserve decodeTrackedRedisFrame and its established DecodedRedisFrame | null behavior for legacy custom adapters.
  • Add optional RedisWriteRequest.createdAtMs. Adapters returning discriminated misses must encode a supplied value exactly; ordinary and direct writes that omit it retain adapter-side clock sampling.
  • Node-redis and GLIDE use the same decoder and exact-timestamp behavior.

Core flow

  • Carry a validated discriminated miss through caller and detached shadow paths.
  • Sample a preflight timestamp before serializer dump; preflightCreatedAtMs <= observedWatermarkMs skips all payload and write work.
  • After payload preparation and the shadow deadline gate, sample the final timestamp and recheck the same watermark.
  • Suppress SET when the final timestamp is fenced; otherwise pass that exact final sample to the adapter.
  • Preserve existing behavior for untracked, invalid typed-miss, missing/malformed-watermark, future-frame, and legacy null misses.
  • Preserve tracked local-publication suppression and the existing later-invalidation race boundary.
  • Report either local dispatch skip as fill_fenced; discriminated confirmation misses remain superseded.

The early preflight is intentionally conservative: DialCache does not perform expensive work merely to see whether a later timestamp might clear the fence.

Compatibility and safety

  • Existing custom adapters returning DecodedRedisFrame | null remain correct and keep their prior write request/timestamp behavior; they simply do not receive this optimization.
  • Structurally augmented custom frames remain hits, including frames whose extra metadata collides with the miss discriminator.
  • Invalid discriminated misses and discriminated misses returned for untracked requests normalize to the established generic-miss path.
  • DialCacheRedisClient.read() may now return a truthy RedisWatermarkMiss, and ShadowValidationOutcome adds fill_fenced; direct and exhaustive consumers must handle both changes.
  • Missing, malformed, out-of-range, or wrong-type watermark metadata retains the established generic behavior.
  • A watermark that advances after the authoritative read can still fence an admitted write. Avoiding that race remains intentionally out of scope.

Validation

  • corepack pnpm check on Node 22.22.0 — typecheck, 633 unit tests with coverage thresholds, build/declarations, and packed ESM/CJS consumer checks pass.
  • corepack pnpm test:integration — 143 tests pass across node-redis and GLIDE on Redis 6.2 and Valkey 8; the two GLIDE Cluster cases skip locally because Docker Desktop cannot route announced container IPs, while CI remains fail-closed for those cases.
  • Focused regressions cover slow async serialization across a short logical TTL, backwards clock movement between samples, invalid and untracked discriminated misses, discriminator-collision frames, and admitted/fenced shadow fills.
  • corepack pnpm benchmark:redis-write against Redis 6.2 — admitted writes remain exactly one SET each with zero Lua and zero TIME calls.
  • git diff --check passes.
  • Independent core, API, and holistic review lanes returned clean after fixes.

BREAKING CHANGE: DialCacheRedisClient.read() may now return RedisWatermarkMiss for tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

@lan17
lan17force-pushed the codex/issue-141-fenced-refills branch from 86caf56 to a0150bdCompareAugust 30, 2026 03:57

@lan17lan17 left a comment

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I like the optimization and the implementation direction. This faithfully implements #141: tracked MGET misses can now preserve an authoritative watermark, caller/shadow fills can avoid serializer/compression/Redis work when the replacement is already known to be fenced, admitted fills do not add a Redis round trip, and legacy custom adapters can continue returning DecodedRedisFrame | null without opting into the optimization. The test coverage is also broad, including the equal-boundary case, admitted writes, large-payload serialization/compression avoidance, shadow fills, confirmation behavior, both bundled adapters, real Redis/Valkey, and packed consumers.

I do think there are two changes we should make before merging, plus one API-shape recommendation that is worth considering while this surface is new.

1. Blocking: admitted refills now start their logical TTL before serialization/compression

The important semantic change is in RedisCache.putWithLayer:

if(key.trackForInvalidation&&watermarkMiss!==undefined){createdAtMs=Date.now();
...
if(createdAtMs<=watermarkMiss.observedWatermarkMs){returnfalse;}}constserialized=awaitserializer.dump(value);// compression
...
awaitclient.write({ ...,createdAtMs});

Before this PR, the bundled adapters sampled Date.now() immediately before frame construction / SET dispatch. With this PR, a typed-miss refill that is admitted samples createdAtMs before potentially asynchronous serializer.dump, synchronous compression, size work, and any delay before the adapter dispatches the write.

That matters because createdAtMs is not only an invalidation token. Core also uses it as the logical age origin for TTL and stale-on-error decisions:

age = reader Date.now() - frame.createdAtMs

So serialization/compression time now consumes the stored value's logical TTL even though Redis starts its physical PX TTL only when the SET executes.

Concrete example:

remote TTL = 1s
candidate timestamp = t=0
serializer/compression = 1.5s
SET executes = t=1.5s

Redis receives a brand-new key with PX 1000, but the frame is already logically 1.5s old. The next DialCache read rejects it immediately and can go back to source, while the physically present key remains in Redis for another second. A slow async custom serializer makes this particularly easy to hit, but a large synchronous payload can consume a meaningful fraction of a short TTL as well.

I think we can preserve the optimization without changing the old dispatch-adjacent timestamp semantics:

constobservedWatermarkMs=watermarkMiss?.observedWatermarkMs;// Cheap preflight: lets us avoid serializer/compression entirely when already fenced.if(observedWatermarkMs!==undefined){constpreflightCreatedAtMs=Date.now();assertValidRedisTimestampMs(preflightCreatedAtMs);if(preflightCreatedAtMs<=observedWatermarkMs){returnfalse;}}constserialized=awaitserializeAndCompress(value);if(shouldWrite!==undefined&&!shouldWrite()){returnfalse;}letcreatedAtMs: number|undefined;if(observedWatermarkMs!==undefined){// Final timestamp is the one that actually goes into the frame.createdAtMs=Date.now();assertValidRedisTimestampMs(createdAtMs);// Recheck because the wall clock may have moved backwards while we serialized.if(createdAtMs<=observedWatermarkMs){returnfalse;}}awaitclient.write({
valueKey,
cacheTtlMs,value: serialized,
...(createdAtMs===undefined ? {} : { createdAtMs }),});

This gives us both desirable properties:

  • if the clock is already behind/equal to the observed watermark, we still skip before serializer.dump, compression, allocation, and SET;
  • if the fill is admitted, the timestamp written into the frame remains close to actual dispatch, preserving the pre-PR logical-TTL behavior.

The second check is also useful for a wall-clock rollback during an async serializer: the final frame timestamp should be the same timestamp used for the final admission decision.

I would add a focused test with an async dump() that advances fake time by more than a short TTL and assert that the frame written after serialization is still fresh on the subsequent read. I would also test a backwards clock move between the preflight and final sample and verify that we suppress the write if the final timestamp is again behind the observed watermark.

There is a smaller semantic point here too: even with the two-sample approach, the early preflight can skip a fill that would have become admissible by the time a slow serializer finished. I think that is a reasonable conservative optimization because the point is to avoid doing expensive work while we currently know the candidate is fenced, but it is worth treating this as intentional behavior rather than claiming the optimization is entirely semantics-neutral.

2. Blocking: this is a public breaking change but the current perf commit will release as a patch

The PR correctly calls out public type-surface changes, but the release metadata does not currently reflect them.

Two externally observable changes are breaking for exhaustive/direct consumers:

  1. DialCacheRedisClient.read() can now return a truthy RedisWatermarkMiss where a bundled adapter previously returned null for the same semantic miss.
  2. ShadowValidationOutcome adds "fill_fenced", which breaks exhaustive switches and Record<ShadowValidationOutcome, ...> values.

A direct consumer compiled against the old contract could have code like:

constframe=awaitadapter.read(request);if(frame!==null){consume(frame.payload);}

With the new bundled adapter behavior, a fenced tracked miss enters that branch with { observedWatermarkMs }. Recompilation against the new declarations catches this, but existing JS / loosely typed consumers can see a runtime behavior change.

The current commit is:

perf(redis): skip refills behind observed watermarks

and this repo's semantic-release config maps plain perf to a patch while pre-1.0 breaking changes are mapped to a minor. So as currently authored this would be released with the wrong semver classification.

I suggest making the squash/title explicitly breaking, e.g.:

perf(redis)!: skip refills behind observed watermarks

with a footer along the lines of:

BREAKING CHANGE: DialCacheRedisClient.read may return RedisWatermarkMiss for
tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

RedisWriteRequest.createdAtMs being optional is source-compatible by itself; the widened read behavior and outcome union are the pieces I would specifically call out.

3. Non-blocking but strongly recommended: use an explicit discriminant for RedisWatermarkMiss

The new public variant currently relies on structural absence/presence checks:

interfaceRedisWatermarkMiss{observedWatermarkMs: number;payload?: never;createdAtMs?: never;}

and core repeats a guard like:

"observedWatermarkMs"inresult&&!("payload"inresult)&&!("createdAtMs"inresult)

in multiple files.

Because this is a brand-new public union variant, I think we have a good opportunity to make the semantic type explicit:

exportinterfaceRedisWatermarkMiss{readonlykind: "watermark_miss";readonlyobservedWatermarkMs: number;}

Then narrowing becomes both simpler and more robust:

functionisRedisWatermarkMiss(result: RedisReadResult,): result is RedisWatermarkMiss{returnresult?.kind==="watermark_miss";}

This removes the duplicated duck-typing logic and gives custom-adapter authors a much clearer contract. The current code does intentionally preserve a structurally augmented frame containing observedWatermarkMs, which is good, but an explicit discriminator expresses that distinction directly instead of inferring it from the absence of two unrelated fields.

If we keep the current shape, I would at least centralize/export one guard rather than maintaining three subtly independent copies.

Additional test cases worth locking down

The existing coverage is very good. A few small cases would make the public adapter boundary harder to misuse:

  • invalid typed misses (NaN, negative, fractional, unsafe integer) should be normalized to a generic miss and follow the ordinary refill path rather than activating suppression;
  • a custom adapter returning a typed miss for an untracked request should not activate the optimization;
  • the dispatch-adjacent timestamp / short-TTL test described above;
  • backwards wall-clock movement between preflight and final write timestamp;
  • if we add a discriminator, a custom frame carrying arbitrary extra metadata should still remain a hit while only { kind: "watermark_miss", ... } is treated as the miss variant.

Things I think are correct as-is

  • Keeping decodeTrackedRedisFrame as the legacy DecodedRedisFrame | null helper and adding a separate opt-in decodeTrackedRedisReadResult is a good compatibility split.
  • Passing the exact admitted timestamp through RedisWriteRequest.createdAtMs is necessary; otherwise the fence decision and stored frame could diverge.
  • The optimization should remain snapshot-based. A watermark advancing after the authoritative MGET can still fence an admitted write, and avoiding that would require server-side coordination / another protocol change. I agree that is out of scope here.
  • fill_fenced is the right shadow outcome. Reporting filled when no SET was dispatched would make the telemetry misleading.
  • Confirmation turning a typed C1 watermark miss into superseded is correct: it tells us the original C0 observation no longer survives the current tracked-read semantics.
  • Missing/malformed watermark metadata falling back to the legacy generic-miss behavior is appropriately conservative.
  • No extra Redis read / TIME / Lua / transaction is introduced, which preserves the main performance constraint of #141.

So I consider the timestamp/TTL semantic regression and the release classification blocking before merge. Once those are fixed, I think the core optimization is in good shape. The discriminated-union cleanup would be ideal to do now while the type is new, but I would not block the PR solely on that if we deliberately prefer the current structural surface.

@lan17lan17 changed the title perf(redis): skip refills behind observed watermarksperf(redis)!: skip refills behind observed watermarksAug 30, 2026
@lan17lan17 changed the title perf(redis)!: skip refills behind observed watermarksperf(redis): skip refills behind observed watermarksAug 30, 2026
@lan17
lan17 merged commit 1f8238f into mainAug 30, 2026
7 checks passed
@lan17
lan17 deleted the codex/issue-141-fenced-refills branch August 30, 2026 19:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skip tracked Redis refills that remain behind the observed watermark

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(redis): skip refills behind observed watermarks - #143

Merged
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills
Aug 30, 2026
Merged

perf(redis): skip refills behind observed watermarks#143
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills

Conversation

@lan17

@lan17lan17 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes#141

Summary

Tracked Redis misses now preserve a trustworthy numeric watermark when the existing authoritative MGET observed one. The public miss variant is explicitly discriminated, and conditional refills use two application-clock samples:

tracked MGET -> RedisWatermarkMiss(W) -> source fallback -> preflight P
|-> P <= W: skip before dump
`-> P > W: dump/compress -> final N
|-> N <= W: skip SET
`-> N > W: SET frame(N)

The preflight still removes serializer, compression, frame-allocation, network, Redis, replication, and AOF work for refills already known to remain unreadable. Admitted fills use the final dispatch-adjacent timestamp, so payload preparation does not consume the stored value's logical TTL. The final recheck also suppresses a write if the wall clock moves back behind the observed watermark.

This adds no Redis command or round trip and changes no key, frame, or watermark encoding.

Design

Adapter boundary

  • Add RedisReadResult = DecodedRedisFrame | RedisWatermarkMiss | null.
  • Define RedisWatermarkMiss as { kind: "watermark_miss", observedWatermarkMs } and centralize its runtime guard.
  • Add decodeTrackedRedisReadResult for adapters that intentionally opt into discriminated watermark misses.
  • Preserve decodeTrackedRedisFrame and its established DecodedRedisFrame | null behavior for legacy custom adapters.
  • Add optional RedisWriteRequest.createdAtMs. Adapters returning discriminated misses must encode a supplied value exactly; ordinary and direct writes that omit it retain adapter-side clock sampling.
  • Node-redis and GLIDE use the same decoder and exact-timestamp behavior.

Core flow

  • Carry a validated discriminated miss through caller and detached shadow paths.
  • Sample a preflight timestamp before serializer dump; preflightCreatedAtMs <= observedWatermarkMs skips all payload and write work.
  • After payload preparation and the shadow deadline gate, sample the final timestamp and recheck the same watermark.
  • Suppress SET when the final timestamp is fenced; otherwise pass that exact final sample to the adapter.
  • Preserve existing behavior for untracked, invalid typed-miss, missing/malformed-watermark, future-frame, and legacy null misses.
  • Preserve tracked local-publication suppression and the existing later-invalidation race boundary.
  • Report either local dispatch skip as fill_fenced; discriminated confirmation misses remain superseded.

The early preflight is intentionally conservative: DialCache does not perform expensive work merely to see whether a later timestamp might clear the fence.

Compatibility and safety

  • Existing custom adapters returning DecodedRedisFrame | null remain correct and keep their prior write request/timestamp behavior; they simply do not receive this optimization.
  • Structurally augmented custom frames remain hits, including frames whose extra metadata collides with the miss discriminator.
  • Invalid discriminated misses and discriminated misses returned for untracked requests normalize to the established generic-miss path.
  • DialCacheRedisClient.read() may now return a truthy RedisWatermarkMiss, and ShadowValidationOutcome adds fill_fenced; direct and exhaustive consumers must handle both changes.
  • Missing, malformed, out-of-range, or wrong-type watermark metadata retains the established generic behavior.
  • A watermark that advances after the authoritative read can still fence an admitted write. Avoiding that race remains intentionally out of scope.

Validation

  • corepack pnpm check on Node 22.22.0 — typecheck, 633 unit tests with coverage thresholds, build/declarations, and packed ESM/CJS consumer checks pass.
  • corepack pnpm test:integration — 143 tests pass across node-redis and GLIDE on Redis 6.2 and Valkey 8; the two GLIDE Cluster cases skip locally because Docker Desktop cannot route announced container IPs, while CI remains fail-closed for those cases.
  • Focused regressions cover slow async serialization across a short logical TTL, backwards clock movement between samples, invalid and untracked discriminated misses, discriminator-collision frames, and admitted/fenced shadow fills.
  • corepack pnpm benchmark:redis-write against Redis 6.2 — admitted writes remain exactly one SET each with zero Lua and zero TIME calls.
  • git diff --check passes.
  • Independent core, API, and holistic review lanes returned clean after fixes.

BREAKING CHANGE: DialCacheRedisClient.read() may now return RedisWatermarkMiss for tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

@lan17
lan17force-pushed the codex/issue-141-fenced-refills branch from 86caf56 to a0150bdCompareAugust 30, 2026 03:57

@lan17lan17 left a comment

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I like the optimization and the implementation direction. This faithfully implements #141: tracked MGET misses can now preserve an authoritative watermark, caller/shadow fills can avoid serializer/compression/Redis work when the replacement is already known to be fenced, admitted fills do not add a Redis round trip, and legacy custom adapters can continue returning DecodedRedisFrame | null without opting into the optimization. The test coverage is also broad, including the equal-boundary case, admitted writes, large-payload serialization/compression avoidance, shadow fills, confirmation behavior, both bundled adapters, real Redis/Valkey, and packed consumers.

I do think there are two changes we should make before merging, plus one API-shape recommendation that is worth considering while this surface is new.

1. Blocking: admitted refills now start their logical TTL before serialization/compression

The important semantic change is in RedisCache.putWithLayer:

if(key.trackForInvalidation&&watermarkMiss!==undefined){createdAtMs=Date.now();
...
if(createdAtMs<=watermarkMiss.observedWatermarkMs){returnfalse;}}constserialized=awaitserializer.dump(value);// compression
...
awaitclient.write({ ...,createdAtMs});

Before this PR, the bundled adapters sampled Date.now() immediately before frame construction / SET dispatch. With this PR, a typed-miss refill that is admitted samples createdAtMs before potentially asynchronous serializer.dump, synchronous compression, size work, and any delay before the adapter dispatches the write.

That matters because createdAtMs is not only an invalidation token. Core also uses it as the logical age origin for TTL and stale-on-error decisions:

age = reader Date.now() - frame.createdAtMs

So serialization/compression time now consumes the stored value's logical TTL even though Redis starts its physical PX TTL only when the SET executes.

Concrete example:

remote TTL = 1s
candidate timestamp = t=0
serializer/compression = 1.5s
SET executes = t=1.5s

Redis receives a brand-new key with PX 1000, but the frame is already logically 1.5s old. The next DialCache read rejects it immediately and can go back to source, while the physically present key remains in Redis for another second. A slow async custom serializer makes this particularly easy to hit, but a large synchronous payload can consume a meaningful fraction of a short TTL as well.

I think we can preserve the optimization without changing the old dispatch-adjacent timestamp semantics:

constobservedWatermarkMs=watermarkMiss?.observedWatermarkMs;// Cheap preflight: lets us avoid serializer/compression entirely when already fenced.if(observedWatermarkMs!==undefined){constpreflightCreatedAtMs=Date.now();assertValidRedisTimestampMs(preflightCreatedAtMs);if(preflightCreatedAtMs<=observedWatermarkMs){returnfalse;}}constserialized=awaitserializeAndCompress(value);if(shouldWrite!==undefined&&!shouldWrite()){returnfalse;}letcreatedAtMs: number|undefined;if(observedWatermarkMs!==undefined){// Final timestamp is the one that actually goes into the frame.createdAtMs=Date.now();assertValidRedisTimestampMs(createdAtMs);// Recheck because the wall clock may have moved backwards while we serialized.if(createdAtMs<=observedWatermarkMs){returnfalse;}}awaitclient.write({
valueKey,
cacheTtlMs,value: serialized,
...(createdAtMs===undefined ? {} : { createdAtMs }),});

This gives us both desirable properties:

  • if the clock is already behind/equal to the observed watermark, we still skip before serializer.dump, compression, allocation, and SET;
  • if the fill is admitted, the timestamp written into the frame remains close to actual dispatch, preserving the pre-PR logical-TTL behavior.

The second check is also useful for a wall-clock rollback during an async serializer: the final frame timestamp should be the same timestamp used for the final admission decision.

I would add a focused test with an async dump() that advances fake time by more than a short TTL and assert that the frame written after serialization is still fresh on the subsequent read. I would also test a backwards clock move between the preflight and final sample and verify that we suppress the write if the final timestamp is again behind the observed watermark.

There is a smaller semantic point here too: even with the two-sample approach, the early preflight can skip a fill that would have become admissible by the time a slow serializer finished. I think that is a reasonable conservative optimization because the point is to avoid doing expensive work while we currently know the candidate is fenced, but it is worth treating this as intentional behavior rather than claiming the optimization is entirely semantics-neutral.

2. Blocking: this is a public breaking change but the current perf commit will release as a patch

The PR correctly calls out public type-surface changes, but the release metadata does not currently reflect them.

Two externally observable changes are breaking for exhaustive/direct consumers:

  1. DialCacheRedisClient.read() can now return a truthy RedisWatermarkMiss where a bundled adapter previously returned null for the same semantic miss.
  2. ShadowValidationOutcome adds "fill_fenced", which breaks exhaustive switches and Record<ShadowValidationOutcome, ...> values.

A direct consumer compiled against the old contract could have code like:

constframe=awaitadapter.read(request);if(frame!==null){consume(frame.payload);}

With the new bundled adapter behavior, a fenced tracked miss enters that branch with { observedWatermarkMs }. Recompilation against the new declarations catches this, but existing JS / loosely typed consumers can see a runtime behavior change.

The current commit is:

perf(redis): skip refills behind observed watermarks

and this repo's semantic-release config maps plain perf to a patch while pre-1.0 breaking changes are mapped to a minor. So as currently authored this would be released with the wrong semver classification.

I suggest making the squash/title explicitly breaking, e.g.:

perf(redis)!: skip refills behind observed watermarks

with a footer along the lines of:

BREAKING CHANGE: DialCacheRedisClient.read may return RedisWatermarkMiss for
tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

RedisWriteRequest.createdAtMs being optional is source-compatible by itself; the widened read behavior and outcome union are the pieces I would specifically call out.

3. Non-blocking but strongly recommended: use an explicit discriminant for RedisWatermarkMiss

The new public variant currently relies on structural absence/presence checks:

interfaceRedisWatermarkMiss{observedWatermarkMs: number;payload?: never;createdAtMs?: never;}

and core repeats a guard like:

"observedWatermarkMs"inresult&&!("payload"inresult)&&!("createdAtMs"inresult)

in multiple files.

Because this is a brand-new public union variant, I think we have a good opportunity to make the semantic type explicit:

exportinterfaceRedisWatermarkMiss{readonlykind: "watermark_miss";readonlyobservedWatermarkMs: number;}

Then narrowing becomes both simpler and more robust:

functionisRedisWatermarkMiss(result: RedisReadResult,): result is RedisWatermarkMiss{returnresult?.kind==="watermark_miss";}

This removes the duplicated duck-typing logic and gives custom-adapter authors a much clearer contract. The current code does intentionally preserve a structurally augmented frame containing observedWatermarkMs, which is good, but an explicit discriminator expresses that distinction directly instead of inferring it from the absence of two unrelated fields.

If we keep the current shape, I would at least centralize/export one guard rather than maintaining three subtly independent copies.

Additional test cases worth locking down

The existing coverage is very good. A few small cases would make the public adapter boundary harder to misuse:

  • invalid typed misses (NaN, negative, fractional, unsafe integer) should be normalized to a generic miss and follow the ordinary refill path rather than activating suppression;
  • a custom adapter returning a typed miss for an untracked request should not activate the optimization;
  • the dispatch-adjacent timestamp / short-TTL test described above;
  • backwards wall-clock movement between preflight and final write timestamp;
  • if we add a discriminator, a custom frame carrying arbitrary extra metadata should still remain a hit while only { kind: "watermark_miss", ... } is treated as the miss variant.

Things I think are correct as-is

  • Keeping decodeTrackedRedisFrame as the legacy DecodedRedisFrame | null helper and adding a separate opt-in decodeTrackedRedisReadResult is a good compatibility split.
  • Passing the exact admitted timestamp through RedisWriteRequest.createdAtMs is necessary; otherwise the fence decision and stored frame could diverge.
  • The optimization should remain snapshot-based. A watermark advancing after the authoritative MGET can still fence an admitted write, and avoiding that would require server-side coordination / another protocol change. I agree that is out of scope here.
  • fill_fenced is the right shadow outcome. Reporting filled when no SET was dispatched would make the telemetry misleading.
  • Confirmation turning a typed C1 watermark miss into superseded is correct: it tells us the original C0 observation no longer survives the current tracked-read semantics.
  • Missing/malformed watermark metadata falling back to the legacy generic-miss behavior is appropriately conservative.
  • No extra Redis read / TIME / Lua / transaction is introduced, which preserves the main performance constraint of #141.

So I consider the timestamp/TTL semantic regression and the release classification blocking before merge. Once those are fixed, I think the core optimization is in good shape. The discriminated-union cleanup would be ideal to do now while the type is new, but I would not block the PR solely on that if we deliberately prefer the current structural surface.

@lan17lan17 changed the title perf(redis): skip refills behind observed watermarksperf(redis)!: skip refills behind observed watermarksAug 30, 2026
@lan17lan17 changed the title perf(redis)!: skip refills behind observed watermarksperf(redis): skip refills behind observed watermarksAug 30, 2026
@lan17
lan17 merged commit 1f8238f into mainAug 30, 2026
7 checks passed
@lan17
lan17 deleted the codex/issue-141-fenced-refills branch August 30, 2026 19:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skip tracked Redis refills that remain behind the observed watermark

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(redis): skip refills behind observed watermarks - #143

Merged
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills
Aug 30, 2026
Merged

perf(redis): skip refills behind observed watermarks#143
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills

Conversation

@lan17

@lan17lan17 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes#141

Summary

Tracked Redis misses now preserve a trustworthy numeric watermark when the existing authoritative MGET observed one. The public miss variant is explicitly discriminated, and conditional refills use two application-clock samples:

tracked MGET -> RedisWatermarkMiss(W) -> source fallback -> preflight P
|-> P <= W: skip before dump
`-> P > W: dump/compress -> final N
|-> N <= W: skip SET
`-> N > W: SET frame(N)

The preflight still removes serializer, compression, frame-allocation, network, Redis, replication, and AOF work for refills already known to remain unreadable. Admitted fills use the final dispatch-adjacent timestamp, so payload preparation does not consume the stored value's logical TTL. The final recheck also suppresses a write if the wall clock moves back behind the observed watermark.

This adds no Redis command or round trip and changes no key, frame, or watermark encoding.

Design

Adapter boundary

  • Add RedisReadResult = DecodedRedisFrame | RedisWatermarkMiss | null.
  • Define RedisWatermarkMiss as { kind: "watermark_miss", observedWatermarkMs } and centralize its runtime guard.
  • Add decodeTrackedRedisReadResult for adapters that intentionally opt into discriminated watermark misses.
  • Preserve decodeTrackedRedisFrame and its established DecodedRedisFrame | null behavior for legacy custom adapters.
  • Add optional RedisWriteRequest.createdAtMs. Adapters returning discriminated misses must encode a supplied value exactly; ordinary and direct writes that omit it retain adapter-side clock sampling.
  • Node-redis and GLIDE use the same decoder and exact-timestamp behavior.

Core flow

  • Carry a validated discriminated miss through caller and detached shadow paths.
  • Sample a preflight timestamp before serializer dump; preflightCreatedAtMs <= observedWatermarkMs skips all payload and write work.
  • After payload preparation and the shadow deadline gate, sample the final timestamp and recheck the same watermark.
  • Suppress SET when the final timestamp is fenced; otherwise pass that exact final sample to the adapter.
  • Preserve existing behavior for untracked, invalid typed-miss, missing/malformed-watermark, future-frame, and legacy null misses.
  • Preserve tracked local-publication suppression and the existing later-invalidation race boundary.
  • Report either local dispatch skip as fill_fenced; discriminated confirmation misses remain superseded.

The early preflight is intentionally conservative: DialCache does not perform expensive work merely to see whether a later timestamp might clear the fence.

Compatibility and safety

  • Existing custom adapters returning DecodedRedisFrame | null remain correct and keep their prior write request/timestamp behavior; they simply do not receive this optimization.
  • Structurally augmented custom frames remain hits, including frames whose extra metadata collides with the miss discriminator.
  • Invalid discriminated misses and discriminated misses returned for untracked requests normalize to the established generic-miss path.
  • DialCacheRedisClient.read() may now return a truthy RedisWatermarkMiss, and ShadowValidationOutcome adds fill_fenced; direct and exhaustive consumers must handle both changes.
  • Missing, malformed, out-of-range, or wrong-type watermark metadata retains the established generic behavior.
  • A watermark that advances after the authoritative read can still fence an admitted write. Avoiding that race remains intentionally out of scope.

Validation

  • corepack pnpm check on Node 22.22.0 — typecheck, 633 unit tests with coverage thresholds, build/declarations, and packed ESM/CJS consumer checks pass.
  • corepack pnpm test:integration — 143 tests pass across node-redis and GLIDE on Redis 6.2 and Valkey 8; the two GLIDE Cluster cases skip locally because Docker Desktop cannot route announced container IPs, while CI remains fail-closed for those cases.
  • Focused regressions cover slow async serialization across a short logical TTL, backwards clock movement between samples, invalid and untracked discriminated misses, discriminator-collision frames, and admitted/fenced shadow fills.
  • corepack pnpm benchmark:redis-write against Redis 6.2 — admitted writes remain exactly one SET each with zero Lua and zero TIME calls.
  • git diff --check passes.
  • Independent core, API, and holistic review lanes returned clean after fixes.

BREAKING CHANGE: DialCacheRedisClient.read() may now return RedisWatermarkMiss for tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

@lan17
lan17force-pushed the codex/issue-141-fenced-refills branch from 86caf56 to a0150bdCompareAugust 30, 2026 03:57

@lan17lan17 left a comment

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I like the optimization and the implementation direction. This faithfully implements #141: tracked MGET misses can now preserve an authoritative watermark, caller/shadow fills can avoid serializer/compression/Redis work when the replacement is already known to be fenced, admitted fills do not add a Redis round trip, and legacy custom adapters can continue returning DecodedRedisFrame | null without opting into the optimization. The test coverage is also broad, including the equal-boundary case, admitted writes, large-payload serialization/compression avoidance, shadow fills, confirmation behavior, both bundled adapters, real Redis/Valkey, and packed consumers.

I do think there are two changes we should make before merging, plus one API-shape recommendation that is worth considering while this surface is new.

1. Blocking: admitted refills now start their logical TTL before serialization/compression

The important semantic change is in RedisCache.putWithLayer:

if(key.trackForInvalidation&&watermarkMiss!==undefined){createdAtMs=Date.now();
...
if(createdAtMs<=watermarkMiss.observedWatermarkMs){returnfalse;}}constserialized=awaitserializer.dump(value);// compression
...
awaitclient.write({ ...,createdAtMs});

Before this PR, the bundled adapters sampled Date.now() immediately before frame construction / SET dispatch. With this PR, a typed-miss refill that is admitted samples createdAtMs before potentially asynchronous serializer.dump, synchronous compression, size work, and any delay before the adapter dispatches the write.

That matters because createdAtMs is not only an invalidation token. Core also uses it as the logical age origin for TTL and stale-on-error decisions:

age = reader Date.now() - frame.createdAtMs

So serialization/compression time now consumes the stored value's logical TTL even though Redis starts its physical PX TTL only when the SET executes.

Concrete example:

remote TTL = 1s
candidate timestamp = t=0
serializer/compression = 1.5s
SET executes = t=1.5s

Redis receives a brand-new key with PX 1000, but the frame is already logically 1.5s old. The next DialCache read rejects it immediately and can go back to source, while the physically present key remains in Redis for another second. A slow async custom serializer makes this particularly easy to hit, but a large synchronous payload can consume a meaningful fraction of a short TTL as well.

I think we can preserve the optimization without changing the old dispatch-adjacent timestamp semantics:

constobservedWatermarkMs=watermarkMiss?.observedWatermarkMs;// Cheap preflight: lets us avoid serializer/compression entirely when already fenced.if(observedWatermarkMs!==undefined){constpreflightCreatedAtMs=Date.now();assertValidRedisTimestampMs(preflightCreatedAtMs);if(preflightCreatedAtMs<=observedWatermarkMs){returnfalse;}}constserialized=awaitserializeAndCompress(value);if(shouldWrite!==undefined&&!shouldWrite()){returnfalse;}letcreatedAtMs: number|undefined;if(observedWatermarkMs!==undefined){// Final timestamp is the one that actually goes into the frame.createdAtMs=Date.now();assertValidRedisTimestampMs(createdAtMs);// Recheck because the wall clock may have moved backwards while we serialized.if(createdAtMs<=observedWatermarkMs){returnfalse;}}awaitclient.write({
valueKey,
cacheTtlMs,value: serialized,
...(createdAtMs===undefined ? {} : { createdAtMs }),});

This gives us both desirable properties:

  • if the clock is already behind/equal to the observed watermark, we still skip before serializer.dump, compression, allocation, and SET;
  • if the fill is admitted, the timestamp written into the frame remains close to actual dispatch, preserving the pre-PR logical-TTL behavior.

The second check is also useful for a wall-clock rollback during an async serializer: the final frame timestamp should be the same timestamp used for the final admission decision.

I would add a focused test with an async dump() that advances fake time by more than a short TTL and assert that the frame written after serialization is still fresh on the subsequent read. I would also test a backwards clock move between the preflight and final sample and verify that we suppress the write if the final timestamp is again behind the observed watermark.

There is a smaller semantic point here too: even with the two-sample approach, the early preflight can skip a fill that would have become admissible by the time a slow serializer finished. I think that is a reasonable conservative optimization because the point is to avoid doing expensive work while we currently know the candidate is fenced, but it is worth treating this as intentional behavior rather than claiming the optimization is entirely semantics-neutral.

2. Blocking: this is a public breaking change but the current perf commit will release as a patch

The PR correctly calls out public type-surface changes, but the release metadata does not currently reflect them.

Two externally observable changes are breaking for exhaustive/direct consumers:

  1. DialCacheRedisClient.read() can now return a truthy RedisWatermarkMiss where a bundled adapter previously returned null for the same semantic miss.
  2. ShadowValidationOutcome adds "fill_fenced", which breaks exhaustive switches and Record<ShadowValidationOutcome, ...> values.

A direct consumer compiled against the old contract could have code like:

constframe=awaitadapter.read(request);if(frame!==null){consume(frame.payload);}

With the new bundled adapter behavior, a fenced tracked miss enters that branch with { observedWatermarkMs }. Recompilation against the new declarations catches this, but existing JS / loosely typed consumers can see a runtime behavior change.

The current commit is:

perf(redis): skip refills behind observed watermarks

and this repo's semantic-release config maps plain perf to a patch while pre-1.0 breaking changes are mapped to a minor. So as currently authored this would be released with the wrong semver classification.

I suggest making the squash/title explicitly breaking, e.g.:

perf(redis)!: skip refills behind observed watermarks

with a footer along the lines of:

BREAKING CHANGE: DialCacheRedisClient.read may return RedisWatermarkMiss for
tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

RedisWriteRequest.createdAtMs being optional is source-compatible by itself; the widened read behavior and outcome union are the pieces I would specifically call out.

3. Non-blocking but strongly recommended: use an explicit discriminant for RedisWatermarkMiss

The new public variant currently relies on structural absence/presence checks:

interfaceRedisWatermarkMiss{observedWatermarkMs: number;payload?: never;createdAtMs?: never;}

and core repeats a guard like:

"observedWatermarkMs"inresult&&!("payload"inresult)&&!("createdAtMs"inresult)

in multiple files.

Because this is a brand-new public union variant, I think we have a good opportunity to make the semantic type explicit:

exportinterfaceRedisWatermarkMiss{readonlykind: "watermark_miss";readonlyobservedWatermarkMs: number;}

Then narrowing becomes both simpler and more robust:

functionisRedisWatermarkMiss(result: RedisReadResult,): result is RedisWatermarkMiss{returnresult?.kind==="watermark_miss";}

This removes the duplicated duck-typing logic and gives custom-adapter authors a much clearer contract. The current code does intentionally preserve a structurally augmented frame containing observedWatermarkMs, which is good, but an explicit discriminator expresses that distinction directly instead of inferring it from the absence of two unrelated fields.

If we keep the current shape, I would at least centralize/export one guard rather than maintaining three subtly independent copies.

Additional test cases worth locking down

The existing coverage is very good. A few small cases would make the public adapter boundary harder to misuse:

  • invalid typed misses (NaN, negative, fractional, unsafe integer) should be normalized to a generic miss and follow the ordinary refill path rather than activating suppression;
  • a custom adapter returning a typed miss for an untracked request should not activate the optimization;
  • the dispatch-adjacent timestamp / short-TTL test described above;
  • backwards wall-clock movement between preflight and final write timestamp;
  • if we add a discriminator, a custom frame carrying arbitrary extra metadata should still remain a hit while only { kind: "watermark_miss", ... } is treated as the miss variant.

Things I think are correct as-is

  • Keeping decodeTrackedRedisFrame as the legacy DecodedRedisFrame | null helper and adding a separate opt-in decodeTrackedRedisReadResult is a good compatibility split.
  • Passing the exact admitted timestamp through RedisWriteRequest.createdAtMs is necessary; otherwise the fence decision and stored frame could diverge.
  • The optimization should remain snapshot-based. A watermark advancing after the authoritative MGET can still fence an admitted write, and avoiding that would require server-side coordination / another protocol change. I agree that is out of scope here.
  • fill_fenced is the right shadow outcome. Reporting filled when no SET was dispatched would make the telemetry misleading.
  • Confirmation turning a typed C1 watermark miss into superseded is correct: it tells us the original C0 observation no longer survives the current tracked-read semantics.
  • Missing/malformed watermark metadata falling back to the legacy generic-miss behavior is appropriately conservative.
  • No extra Redis read / TIME / Lua / transaction is introduced, which preserves the main performance constraint of #141.

So I consider the timestamp/TTL semantic regression and the release classification blocking before merge. Once those are fixed, I think the core optimization is in good shape. The discriminated-union cleanup would be ideal to do now while the type is new, but I would not block the PR solely on that if we deliberately prefer the current structural surface.

@lan17lan17 changed the title perf(redis): skip refills behind observed watermarksperf(redis)!: skip refills behind observed watermarksAug 30, 2026
@lan17lan17 changed the title perf(redis)!: skip refills behind observed watermarksperf(redis): skip refills behind observed watermarksAug 30, 2026
@lan17
lan17 merged commit 1f8238f into mainAug 30, 2026
7 checks passed
@lan17
lan17 deleted the codex/issue-141-fenced-refills branch August 30, 2026 19:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skip tracked Redis refills that remain behind the observed watermark

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

perf(redis): skip refills behind observed watermarks - #143

Merged
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills
Aug 30, 2026
Merged

perf(redis): skip refills behind observed watermarks#143
lan17 merged 2 commits into
mainfrom
codex/issue-141-fenced-refills

Conversation

@lan17

@lan17lan17 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes#141

Summary

Tracked Redis misses now preserve a trustworthy numeric watermark when the existing authoritative MGET observed one. The public miss variant is explicitly discriminated, and conditional refills use two application-clock samples:

tracked MGET -> RedisWatermarkMiss(W) -> source fallback -> preflight P
|-> P <= W: skip before dump
`-> P > W: dump/compress -> final N
|-> N <= W: skip SET
`-> N > W: SET frame(N)

The preflight still removes serializer, compression, frame-allocation, network, Redis, replication, and AOF work for refills already known to remain unreadable. Admitted fills use the final dispatch-adjacent timestamp, so payload preparation does not consume the stored value's logical TTL. The final recheck also suppresses a write if the wall clock moves back behind the observed watermark.

This adds no Redis command or round trip and changes no key, frame, or watermark encoding.

Design

Adapter boundary

  • Add RedisReadResult = DecodedRedisFrame | RedisWatermarkMiss | null.
  • Define RedisWatermarkMiss as { kind: "watermark_miss", observedWatermarkMs } and centralize its runtime guard.
  • Add decodeTrackedRedisReadResult for adapters that intentionally opt into discriminated watermark misses.
  • Preserve decodeTrackedRedisFrame and its established DecodedRedisFrame | null behavior for legacy custom adapters.
  • Add optional RedisWriteRequest.createdAtMs. Adapters returning discriminated misses must encode a supplied value exactly; ordinary and direct writes that omit it retain adapter-side clock sampling.
  • Node-redis and GLIDE use the same decoder and exact-timestamp behavior.

Core flow

  • Carry a validated discriminated miss through caller and detached shadow paths.
  • Sample a preflight timestamp before serializer dump; preflightCreatedAtMs <= observedWatermarkMs skips all payload and write work.
  • After payload preparation and the shadow deadline gate, sample the final timestamp and recheck the same watermark.
  • Suppress SET when the final timestamp is fenced; otherwise pass that exact final sample to the adapter.
  • Preserve existing behavior for untracked, invalid typed-miss, missing/malformed-watermark, future-frame, and legacy null misses.
  • Preserve tracked local-publication suppression and the existing later-invalidation race boundary.
  • Report either local dispatch skip as fill_fenced; discriminated confirmation misses remain superseded.

The early preflight is intentionally conservative: DialCache does not perform expensive work merely to see whether a later timestamp might clear the fence.

Compatibility and safety

  • Existing custom adapters returning DecodedRedisFrame | null remain correct and keep their prior write request/timestamp behavior; they simply do not receive this optimization.
  • Structurally augmented custom frames remain hits, including frames whose extra metadata collides with the miss discriminator.
  • Invalid discriminated misses and discriminated misses returned for untracked requests normalize to the established generic-miss path.
  • DialCacheRedisClient.read() may now return a truthy RedisWatermarkMiss, and ShadowValidationOutcome adds fill_fenced; direct and exhaustive consumers must handle both changes.
  • Missing, malformed, out-of-range, or wrong-type watermark metadata retains the established generic behavior.
  • A watermark that advances after the authoritative read can still fence an admitted write. Avoiding that race remains intentionally out of scope.

Validation

  • corepack pnpm check on Node 22.22.0 — typecheck, 633 unit tests with coverage thresholds, build/declarations, and packed ESM/CJS consumer checks pass.
  • corepack pnpm test:integration — 143 tests pass across node-redis and GLIDE on Redis 6.2 and Valkey 8; the two GLIDE Cluster cases skip locally because Docker Desktop cannot route announced container IPs, while CI remains fail-closed for those cases.
  • Focused regressions cover slow async serialization across a short logical TTL, backwards clock movement between samples, invalid and untracked discriminated misses, discriminator-collision frames, and admitted/fenced shadow fills.
  • corepack pnpm benchmark:redis-write against Redis 6.2 — admitted writes remain exactly one SET each with zero Lua and zero TIME calls.
  • git diff --check passes.
  • Independent core, API, and holistic review lanes returned clean after fixes.

BREAKING CHANGE: DialCacheRedisClient.read() may now return RedisWatermarkMiss for tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

@lan17
lan17force-pushed the codex/issue-141-fenced-refills branch from 86caf56 to a0150bdCompareAugust 30, 2026 03:57

@lan17lan17 left a comment

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I like the optimization and the implementation direction. This faithfully implements #141: tracked MGET misses can now preserve an authoritative watermark, caller/shadow fills can avoid serializer/compression/Redis work when the replacement is already known to be fenced, admitted fills do not add a Redis round trip, and legacy custom adapters can continue returning DecodedRedisFrame | null without opting into the optimization. The test coverage is also broad, including the equal-boundary case, admitted writes, large-payload serialization/compression avoidance, shadow fills, confirmation behavior, both bundled adapters, real Redis/Valkey, and packed consumers.

I do think there are two changes we should make before merging, plus one API-shape recommendation that is worth considering while this surface is new.

1. Blocking: admitted refills now start their logical TTL before serialization/compression

The important semantic change is in RedisCache.putWithLayer:

if(key.trackForInvalidation&&watermarkMiss!==undefined){createdAtMs=Date.now();
...
if(createdAtMs<=watermarkMiss.observedWatermarkMs){returnfalse;}}constserialized=awaitserializer.dump(value);// compression
...
awaitclient.write({ ...,createdAtMs});

Before this PR, the bundled adapters sampled Date.now() immediately before frame construction / SET dispatch. With this PR, a typed-miss refill that is admitted samples createdAtMs before potentially asynchronous serializer.dump, synchronous compression, size work, and any delay before the adapter dispatches the write.

That matters because createdAtMs is not only an invalidation token. Core also uses it as the logical age origin for TTL and stale-on-error decisions:

age = reader Date.now() - frame.createdAtMs

So serialization/compression time now consumes the stored value's logical TTL even though Redis starts its physical PX TTL only when the SET executes.

Concrete example:

remote TTL = 1s
candidate timestamp = t=0
serializer/compression = 1.5s
SET executes = t=1.5s

Redis receives a brand-new key with PX 1000, but the frame is already logically 1.5s old. The next DialCache read rejects it immediately and can go back to source, while the physically present key remains in Redis for another second. A slow async custom serializer makes this particularly easy to hit, but a large synchronous payload can consume a meaningful fraction of a short TTL as well.

I think we can preserve the optimization without changing the old dispatch-adjacent timestamp semantics:

constobservedWatermarkMs=watermarkMiss?.observedWatermarkMs;// Cheap preflight: lets us avoid serializer/compression entirely when already fenced.if(observedWatermarkMs!==undefined){constpreflightCreatedAtMs=Date.now();assertValidRedisTimestampMs(preflightCreatedAtMs);if(preflightCreatedAtMs<=observedWatermarkMs){returnfalse;}}constserialized=awaitserializeAndCompress(value);if(shouldWrite!==undefined&&!shouldWrite()){returnfalse;}letcreatedAtMs: number|undefined;if(observedWatermarkMs!==undefined){// Final timestamp is the one that actually goes into the frame.createdAtMs=Date.now();assertValidRedisTimestampMs(createdAtMs);// Recheck because the wall clock may have moved backwards while we serialized.if(createdAtMs<=observedWatermarkMs){returnfalse;}}awaitclient.write({
valueKey,
cacheTtlMs,value: serialized,
...(createdAtMs===undefined ? {} : { createdAtMs }),});

This gives us both desirable properties:

  • if the clock is already behind/equal to the observed watermark, we still skip before serializer.dump, compression, allocation, and SET;
  • if the fill is admitted, the timestamp written into the frame remains close to actual dispatch, preserving the pre-PR logical-TTL behavior.

The second check is also useful for a wall-clock rollback during an async serializer: the final frame timestamp should be the same timestamp used for the final admission decision.

I would add a focused test with an async dump() that advances fake time by more than a short TTL and assert that the frame written after serialization is still fresh on the subsequent read. I would also test a backwards clock move between the preflight and final sample and verify that we suppress the write if the final timestamp is again behind the observed watermark.

There is a smaller semantic point here too: even with the two-sample approach, the early preflight can skip a fill that would have become admissible by the time a slow serializer finished. I think that is a reasonable conservative optimization because the point is to avoid doing expensive work while we currently know the candidate is fenced, but it is worth treating this as intentional behavior rather than claiming the optimization is entirely semantics-neutral.

2. Blocking: this is a public breaking change but the current perf commit will release as a patch

The PR correctly calls out public type-surface changes, but the release metadata does not currently reflect them.

Two externally observable changes are breaking for exhaustive/direct consumers:

  1. DialCacheRedisClient.read() can now return a truthy RedisWatermarkMiss where a bundled adapter previously returned null for the same semantic miss.
  2. ShadowValidationOutcome adds "fill_fenced", which breaks exhaustive switches and Record<ShadowValidationOutcome, ...> values.

A direct consumer compiled against the old contract could have code like:

constframe=awaitadapter.read(request);if(frame!==null){consume(frame.payload);}

With the new bundled adapter behavior, a fenced tracked miss enters that branch with { observedWatermarkMs }. Recompilation against the new declarations catches this, but existing JS / loosely typed consumers can see a runtime behavior change.

The current commit is:

perf(redis): skip refills behind observed watermarks

and this repo's semantic-release config maps plain perf to a patch while pre-1.0 breaking changes are mapped to a minor. So as currently authored this would be released with the wrong semver classification.

I suggest making the squash/title explicitly breaking, e.g.:

perf(redis)!: skip refills behind observed watermarks

with a footer along the lines of:

BREAKING CHANGE: DialCacheRedisClient.read may return RedisWatermarkMiss for
tracked semantic misses, and ShadowValidationOutcome adds fill_fenced.

RedisWriteRequest.createdAtMs being optional is source-compatible by itself; the widened read behavior and outcome union are the pieces I would specifically call out.

3. Non-blocking but strongly recommended: use an explicit discriminant for RedisWatermarkMiss

The new public variant currently relies on structural absence/presence checks:

interfaceRedisWatermarkMiss{observedWatermarkMs: number;payload?: never;createdAtMs?: never;}

and core repeats a guard like:

"observedWatermarkMs"inresult&&!("payload"inresult)&&!("createdAtMs"inresult)

in multiple files.

Because this is a brand-new public union variant, I think we have a good opportunity to make the semantic type explicit:

exportinterfaceRedisWatermarkMiss{readonlykind: "watermark_miss";readonlyobservedWatermarkMs: number;}

Then narrowing becomes both simpler and more robust:

functionisRedisWatermarkMiss(result: RedisReadResult,): result is RedisWatermarkMiss{returnresult?.kind==="watermark_miss";}

This removes the duplicated duck-typing logic and gives custom-adapter authors a much clearer contract. The current code does intentionally preserve a structurally augmented frame containing observedWatermarkMs, which is good, but an explicit discriminator expresses that distinction directly instead of inferring it from the absence of two unrelated fields.

If we keep the current shape, I would at least centralize/export one guard rather than maintaining three subtly independent copies.

Additional test cases worth locking down

The existing coverage is very good. A few small cases would make the public adapter boundary harder to misuse:

  • invalid typed misses (NaN, negative, fractional, unsafe integer) should be normalized to a generic miss and follow the ordinary refill path rather than activating suppression;
  • a custom adapter returning a typed miss for an untracked request should not activate the optimization;
  • the dispatch-adjacent timestamp / short-TTL test described above;
  • backwards wall-clock movement between preflight and final write timestamp;
  • if we add a discriminator, a custom frame carrying arbitrary extra metadata should still remain a hit while only { kind: "watermark_miss", ... } is treated as the miss variant.

Things I think are correct as-is

  • Keeping decodeTrackedRedisFrame as the legacy DecodedRedisFrame | null helper and adding a separate opt-in decodeTrackedRedisReadResult is a good compatibility split.
  • Passing the exact admitted timestamp through RedisWriteRequest.createdAtMs is necessary; otherwise the fence decision and stored frame could diverge.
  • The optimization should remain snapshot-based. A watermark advancing after the authoritative MGET can still fence an admitted write, and avoiding that would require server-side coordination / another protocol change. I agree that is out of scope here.
  • fill_fenced is the right shadow outcome. Reporting filled when no SET was dispatched would make the telemetry misleading.
  • Confirmation turning a typed C1 watermark miss into superseded is correct: it tells us the original C0 observation no longer survives the current tracked-read semantics.
  • Missing/malformed watermark metadata falling back to the legacy generic-miss behavior is appropriately conservative.
  • No extra Redis read / TIME / Lua / transaction is introduced, which preserves the main performance constraint of #141.

So I consider the timestamp/TTL semantic regression and the release classification blocking before merge. Once those are fixed, I think the core optimization is in good shape. The discriminated-union cleanup would be ideal to do now while the type is new, but I would not block the PR solely on that if we deliberately prefer the current structural surface.

@lan17lan17 changed the title perf(redis): skip refills behind observed watermarksperf(redis)!: skip refills behind observed watermarksAug 30, 2026
@lan17lan17 changed the title perf(redis)!: skip refills behind observed watermarksperf(redis): skip refills behind observed watermarksAug 30, 2026
@lan17
lan17 merged commit 1f8238f into mainAug 30, 2026
7 checks passed
@lan17
lan17 deleted the codex/issue-141-fenced-refills branch August 30, 2026 19:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skip tracked Redis refills that remain behind the observed watermark

1 participant

@lan17