Skip to content

fix(Cache): release awaiters interrupted while the lookup starts - #8720

Closed
juliusmarminge wants to merge 1 commit into
Effect-TS:mainfrom
juliusmarminge:t3code/cache-get-sync-interrupt
Closed

juliusmarminge wants to merge 1 commit into
Effect-TS:mainfrom
juliusmarminge:t3code/cache-get-sync-interrupt

Conversation

@juliusmarminge

Copy link
Copy Markdown
Contributor

Problem

Cache.get forks the lookup immediately (forkUnsafe(..., immediate = true)), and only then returns entry.await(), the effect that counts the caller as an awaiter and interrupts the lookup once the last awaiter leaves. If the lookup synchronously wakes a fiber that interrupts the caller, for example by completing a Deferred that fiber awaits, the interrupt is deferred until Cache.get returns. It is then delivered before the returned effect starts. The awaiter is never registered or released, so:

  • the lookup is never interrupted;
  • the entry stays in the cache;
  • every later Cache.get for that key joins the abandoned lookup and waits forever.

Repro

import { Cache, Deferred, Effect, Exit, Fiber, Option } from "effect"

const program = Effect.gen(function*() {
  const lookupStarted = yield* Deferred.make<void>()
  let lookups = 0
  const cache = yield* Cache.make({
    capacity: 10,
    lookup: (_key: string) =>
      Effect.gen(function*() {
        if (++lookups > 1) return 42
        yield* Deferred.succeed(lookupStarted, void 0) // wakes the interrupter synchronously
        return yield* Effect.never
      })
  })

  let caller: Fiber.Fiber<unknown, unknown> | undefined
  yield* Deferred.await(lookupStarted).pipe(
    Effect.andThen(Effect.suspend(() => Fiber.interrupt(caller!))),
    Effect.forkChild({ startImmediately: true })
  )
  const fiber = yield* Effect.withFiber((fiber) => {
    caller = fiber
    return Cache.get(cache, "key")
  }).pipe(Effect.forkChild({ startImmediately: true }))

  console.log("caller interrupted:", Exit.hasInterrupts(yield* Fiber.await(fiber)))
  const next = yield* Cache.get(cache, "key").pipe(Effect.timeoutOption("100 millis"))
  console.log("next get:", Option.getOrElse(next, () => "timed out"))
})

Effect.runPromise(program)
// main:     caller interrupted: true / next get: timed out (the lookup is never interrupted)
// this PR:  caller interrupted: true / next get: 42

Fix

Register the awaiter and its release on the caller fiber with onExitUnsafe before returning, rather than inside the returned effect. The release logic is unchanged: decrement, then interrupt the lookup if no awaiters remain and it hasn't finished. Interrupting the lookup triggers the existing observer that drops interrupted entries from the map, so the next get starts a fresh lookup.

The shared logic moves into awaitEntry. Cache.get calls it with the fiber it already has, and EntryImpl.await() wraps it in withFiber for the other call sites (getOption, invalidateWhen, refresh). ScopedCache is not affected: its awaitEntry already installs cleanup inside an uninterruptible region before restore.

Testing

  • New test in packages/effect/test/Cache.test.ts: "interrupts the lookup when the caller is interrupted while it starts". It times out on main and passes with the fix.
  • pnpm vitest run on Cache, ScopedCache, persistence/PersistedCache, persistence/Redis, RequestResolver, Request, eventlog/EventLogRemote, eventlog/EventLogServerUnencrypted and the sql-sqlite-node tests: 16 files, 384 tests passed.
  • pnpm vitest run --config vitest.docs.ts packages/effect/src/Cache.ts: 28 doc examples passed.
  • tsc -b for packages/effect, plus a type check of Cache.test.ts against tsconfig.tests.json.
  • oxlint and dprint check on the changed files.

Found while adopting Effect V4 in T3 Code.

Model/harness: Claude Opus 5.5 (1M context) via Claude Code in T3 Code.

🤖 Generated with Claude Code

Cache.get forks the lookup immediately and only then returns the effect
that registers the caller as an awaiter. If the lookup synchronously
woke a fiber that interrupted the caller, the interrupt was delivered
before that effect ran, so the awaiter was never released. The lookup
was never interrupted, stayed in the cache, and every later get for the
key waited on it.

Register the awaiter and its release on the caller fiber before
returning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a57c77c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 32 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 38.90 KB 38.90 KB 0.00 KB (0.00%)
basic.ts 7.04 KB 7.04 KB 0.00 KB (0.00%)
batching.ts 10.13 KB 10.13 KB 0.00 KB (0.00%)
brand.ts 6.72 KB 6.72 KB 0.00 KB (0.00%)
cache.ts 10.84 KB 10.84 KB +0.00 KB (+0.03%)
config.ts 22.15 KB 22.15 KB 0.00 KB (0.00%)
differ.ts 21.29 KB 21.29 KB 0.00 KB (0.00%)
http-client.ts 22.27 KB 22.27 KB 0.00 KB (0.00%)
http-router.ts 33.55 KB 33.55 KB 0.00 KB (0.00%)
logger.ts 11.08 KB 11.08 KB 0.00 KB (0.00%)
metric.ts 9.02 KB 9.02 KB 0.00 KB (0.00%)
optic.ts 6.95 KB 6.95 KB 0.00 KB (0.00%)
pubsub.ts 15.16 KB 15.16 KB 0.00 KB (0.00%)
queue.ts 12.08 KB 12.08 KB 0.00 KB (0.00%)
schedule.ts 11.18 KB 11.18 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 13.73 KB 13.73 KB 0.00 KB (0.00%)
schema-binary.ts 39.95 KB 39.95 KB 0.00 KB (0.00%)
schema-class.ts 21.03 KB 21.03 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 32.06 KB 32.06 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 27.24 KB 27.24 KB 0.00 KB (0.00%)
schema-string-transformation.ts 14.46 KB 14.46 KB 0.00 KB (0.00%)
schema-string.ts 11.99 KB 11.99 KB 0.00 KB (0.00%)
schema-template-literal.ts 16.05 KB 16.05 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 38.44 KB 38.44 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 25.26 KB 25.26 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 20.21 KB 20.21 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 20.41 KB 20.41 KB 0.00 KB (0.00%)
schema-toFormatter.ts 20.54 KB 20.54 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 25.00 KB 25.00 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 20.50 KB 20.50 KB 0.00 KB (0.00%)
schema.ts 20.25 KB 20.25 KB 0.00 KB (0.00%)
stm.ts 13.08 KB 13.08 KB 0.00 KB (0.00%)
stream.ts 9.98 KB 9.98 KB 0.00 KB (0.00%)

This branch is waiting to be deployed

1 waiting deployment
fork — a57c77c9 Waiting Oct 4, 2026 by juliusmarminge via approval-gate #28764
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants