Skip to content

Cache.get: a caller interrupted while its lookup starts leaves the lookup running and the key stuck #8751

Description

@notprolands

What version of Effect is running?

effect@4.0.1

What steps can reproduce the bug?

he first lookup signals a Deferred and then hangs. The parent awaits that
signal and immediately interrupts the fiber calling Cache.get. The failure
TTL is zero, so an interrupted lookup should leave the cache. The second run
adds one Effect.yieldNow before the interrupt as a control.

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

const run = (label: string, beforeInterrupt: Effect.Effect<void>) =>
  Effect.gen(function*() {
    const started = yield* Deferred.make<void>()
    let calls = 0
    const cache = yield* Cache.makeWith(
      (_: string) =>
        Effect.suspend(() => {
          calls += 1
          if (calls > 1) return Effect.succeed("ok")
          // first lookup: signal that it started, then hang
          return Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never))
        }),
      { capacity: 4, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero) }
    )
    const first = yield* Cache.get(cache, "k").pipe(Effect.forkChild)
    yield* Deferred.await(started)
    yield* beforeInterrupt
    yield* Fiber.interrupt(first)
    const size = yield* Cache.size(cache)
    const next = yield* Cache.get(cache, "k").pipe(Effect.timeoutOption("1 second"))
    console.log(`${label}: size after interrupt = ${size}, next get = ${Option.getOrElse(next, () => "timed out")}`)
  })

await Effect.runPromise(Effect.gen(function*() {
  yield* run("interrupt right after the lookup starts", Effect.void)
  yield* run("interrupt after one Effect.yieldNow", Effect.yieldNow)
}))

Run with bun repro.ts (or npx tsx repro.ts) after installing effect@4.0.1.

What is the expected behavior?

Interrupting the only caller abandons the lookup: it is interrupted, the entry is removed (size 0), and the next get starts a fresh lookup and returns ok, as in the control run.

What do you see instead?

interrupt right after the lookup starts: size after interrupt = 1, next get = timed out
interrupt after one Effect.yieldNow: size after interrupt = 0, next get = ok

The first lookup is never interrupted, its entry stays in the map, and every later get for the key joins it and waits forever.

Additional information

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions