Skip to content

fix(bun-cache): one invalidation, one CDN purge - #707

Merged
kingsloob1 merged 1 commit into
developfrom
fix/cache-single-purge
Nov 8, 2026
Merged

kingsloob1 merged 1 commit into
developfrom
fix/cache-single-purge

Conversation

@kingsloob1

Copy link
Copy Markdown
Owner

fix(bun-cache): an invalidation runs twice only when a write stored after it began

Item 10 of track G: one invalidation, one CDN purge. Item 1 of this track (the surrogate test) found that cacheResponse() sometimes invalidated a path twice, and the second time fired invalidate again. The README's CDN-purge recipe then purged twice.

What happened

  • pages.invalidate() and an unsafe method's invalidation run once.
  • If a capture's write for the path was still tracked, they run again once it settles. This is the second pass, by design: a write issued after the invalidation (a streamed body is written at its end) is not covered by the core's fence and would put back what was just dropped.
  • But a write was tracked until its capture's promise chain settled. That is a few microtasks after the store itself.
  • So an invalidation landing in that gap ran a redundant second pass on a write that had already landed, and was already dropped by the first pass. It emitted a second invalidate.
  • The same happened for a coalesced write that the invalidation refused: the core forgets local flights (fenced, reason: "local"), nothing is stored, and the second pass still ran.

Choice: the product, not the recipe

Deduping in the recipe cannot be exact.

  • The second event comes after the first pass completes, which can wait on a timer (the clock pass). So the two events can be macrotask turns apart, and a per-turn batch does not merge them.
  • Skipping a purge identical to one still in flight would also drop a needed second purge. When a write genuinely raced, the CDN may have stored that same raced answer, so the second purge is wanted.

cacheResponse() change.

  • Each tracked write now records when its key was last stored. It reads the cache's set event, which is synchronous with the store.
  • An invalidation notes the count when it begins.
  • The second pass runs only if a tracked write stored after that point.
  • A write to the same key by someone else marks it too, which can only add a pass, never remove a needed one.
  • A write whose key is not known always counts as late, so the second pass behaves as before for it.
  • The listener is added with the first tracked write, not when the middleware is built. bun-nest's BunCacheInterceptor builds one against a probe that throws on any read, and its suite caught the first version of this change doing exactly that.

Effects:

  • an invalidation right after a write landed: one pass, one event;
  • a coalesced write the invalidation fenced: one pass;
  • a streamed write that lands after the invalidation began: two passes, two events, as before (and the second purge is wanted).

Decisions (approved by the user, 2026-11-08)

Fewer invalidate events.

  • A redundant second pass, and its event, no longer happen.
  • invalidate fires twice for one pages.invalidate() or unsafe method only when a write stored after it began.
  • Two README paragraphs say so: the methods section and the purge recipe. The recipe code is unchanged.

Recommendation: accept. It is what the recipe always claimed ("purges each invalidation once"), and the remaining second event is the one a CDN needs.

Tests (__tests__/http/methods.test.ts, "one invalidation, one pass…", 4 new)

  1. An invalidation right after the write landed (awaiting its set event) makes one invalidateTags call and one event. Run with coalesce off and on.
  2. A coalesced streamed write in flight is fenced by the invalidation: nothing stored, one pass. The test waits for the fenced event, then one turn.
  3. A streamed write whose body ends after the invalidation stores, then gets the second pass: two calls, nothing left. It waits for the second event.

All of them wait on events (set, fenced, invalidate) and one setImmediate turn, never on time. The calls are counted by wrapping cache.invalidateTags, which is synchronous at the call.

Negative controls:

  • develop's cacheResponse.ts: tests 1 (both) and 2 fail, 2 calls instead of 1. Test 3 passes.
  • This change with the second pass disabled: test 3 fails.

Also:

  • surrogate.test.ts: item 1's setImmediate drain is gone; it is no longer needed. The test waits for its third event as before. 32 copies × 5 rounds: 0 of 160 failed (1-min load 30-33).
  • The new tests themselves: 32 copies × 5 rounds, 0 of 160 failed (load 16–19).
  • The recipe example (examples/bun-cache/05-http/cdn-tags.ts) is unchanged. A step asserting one purge was tried and dropped, because it passed on develop too: the redundant event arrives a turn after any point an example can check without wrapping the cache. No test parses the README's recipe or the examples README row.

Gates

  • bun-cache:
    • bun run check: clean, 275 files.
    • bun run lint: 0 errors.
    • bun run format:check: clean.
    • bun run test (every BUN_CACHE_TEST_* URL but Upstash): 2158 pass, 150 skip, 0 fail.
    • seeds 1 and 2, first run (load about 30): one failure each, in files no item touches:
      • seed 1: drivers/encrypted.test.ts "a raised clear expiry gains nothing" ("the entry expired before the test could raise it");
      • seed 2: a drivers/mysql.test.ts hook timeout.
    • Those two files with each seed: 80 pass, 0 fail.
    • The whole runs again: 2158 pass, 150 skip, 0 fail each (seed 1 started at load 68, seed 2 at 17).
  • bun-nest (it builds cacheResponse in BunCacheInterceptor): bun run test 1511 pass, 7 skip, 0 fail; seeds 1 and 2: 1511 pass, 0 fail each.
  • consumer-check bun-cache: exit 0, 112 OK cells, no other status; dts/ removed afterwards.
  • examples/bun-cache run-all.ts: 44 examples: 42 passed, 2 skipped, 0 failed. The two skips (04-drivers/sql.ts, 06-tiers/separate-bus.ts) read EXAMPLE_*_URL; run directly with those set to the test servers they passed (65 and 8 checks). Directly: s3.ts 36 checks, cdn-tags.ts 22 checks.
  • bun scripts/typecheck.ts: 25 projects clean.
  • Root bun run format:check: clean.

Approved by the user, 2026-11-08.

cacheResponse() ran a second invalidation pass (and a second invalidate
event) after every tracked write that was settling when an invalidation
began, so the README's CDN-purge recipe purged twice. Each tracked write
now records, through the cache's synchronous set event, whether it was
stored after the invalidation began, and only then runs the second pass;
a refused or already-landed write no longer does. The listener is added
with the first tracked write (bun-nest's interceptor builds one against a
throwing probe). Develop fails 3 of the 4 new tests. Approved by the user,
2026-11-08.
@kingsloob1
kingsloob1 merged commit f5df6e9 into develop Nov 8, 2026
@kingsloob1
kingsloob1 deleted the fix/cache-single-purge branch November 8, 2026 15:52
Sign up for free to 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.

1 participant