Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/request-zero-delay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Fix request resolvers with synchronously completing delays, including `RequestResolver.setDelay(0)` under `TestClock`, running an empty batch and failing instead of resolving the request.
20 changes: 15 additions & 5 deletions packages/effect/src/internal/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,11 @@ const addEntry = <A extends Request.Any>(
entrySet: new Set(),
entries: new Set(),
delayEffect: effect.flatMap(
effect.suspend(() => newBatch.resolver.delay),
effect.suspend(() => {
// Delay callbacks can enqueue requests before runForkWith returns.
newBatch.fiber = effect.getCurrentFiber()!
return newBatch.resolver.delay
Comment on lines +161 to +164

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Please add focused regression coverage for the reentrant-delay / pooled-batch behavior introduced here. The checkout adds only the single setDelay(0) test (the targeted file runs 35 tests, not the 38 described in the PR), which does not enqueue a request from the delay, interrupt a delay by filling its batch, or reuse a pooled batch. Consequently the early fiber publication here and the removal of the execution-fiber writeback are untested lifecycle changes. Add the reentrant batchN(2) and pooled-reuse ordering cases described in the PR, asserting completion and that interruption targets the correct delay fiber.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The test count and coverage description were stale; I have corrected the PR body to one added regression and 35 passing Request tests. It now explicitly says that reentrant enqueueing and pooled-reuse ordering are not directly covered by the retained test.

I disagree that restoring both removed cases should block this fix. Tim explicitly requested the bare minimum of focused tests. The retained public-API regression reproduces the reported TestClock/microtask failure without the fix and checks both successful completion and exactly one non-empty batch ([[1]]). Existing batch-limit and cancellation coverage remains unchanged. Independent review of this exact head found no concrete gap requiring another case.

The lifecycle safeguards are intentional: publish the running delay fiber before user callbacks can reenter, retain the post-fork assignment for schedulers that yield before publication, guard against touching an already-executed batch, and avoid writing an execution fiber into a recycled batch. The implementation has not changed during the test reduction, including delay-before-collection ordering.

The coverage limitation you identify is real, but the request does not identify an incorrect outcome in the current implementation or a distinct essential regression that warrants reversing the requested reduction. I recommend retaining the single test. If there is a specific failing public-API scenario at this head, please provide it so we can assess one focused behavioral test rather than automatically restoring both lifecycle/ordering cases. No code or tests changed in this assessment.

}),
(_) => runBatch(newBatch)
) as Effect<void>,
run: effect.onExit(
Expand Down Expand Up @@ -190,15 +194,21 @@ const addEntry = <A extends Request.Any>(
batch = newBatch
}
batchMap.set(key, batch)
batch.fiber = effect.runForkWith(fiber.context)(batch.delayEffect, { scheduler: fiber.cache.scheduler })
batch.entrySet.add(entry)
batch.entries.add(entry)
const delayFiber = effect.runForkWith(fiber.context)(batch.delayEffect, { scheduler: fiber.cache.scheduler })
// A synchronous delay may have already run and recycled this batch.
if (completed || batchMap.get(key) !== batch) return entry
batch.fiber = delayFiber
} else {
batch.entrySet.add(entry)
batch.entries.add(entry)
}

batch.entrySet.add(entry)
batch.entries.add(entry)
if (batch.resolver.collectWhile(batch.entries)) return entry

batch.fiber!.interruptUnsafe(fiber.id)
batch.fiber = effect.runForkWith(fiber.context)(runBatch(batch), { scheduler: fiber.cache.scheduler })
effect.runForkWith(fiber.context)(runBatch(batch), { scheduler: fiber.cache.scheduler })
return entry
}

Expand Down
83 changes: 83 additions & 0 deletions packages/effect/test/Request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,4 +794,87 @@ describe("Request", { concurrent: false }, () => {
assert.strictEqual(delayServiceValue, "provided")
})
)

it.effect("collectWhile preserves synchronous delay side effects across pooled batches", () =>
Effect.gen(function*() {
const events: Array<string> = []
const resolver = Resolver.makeWith<GetRequestService>({
batchKey: () => undefined,
delay: Effect.andThen(Effect.sync(() => events.push("delay")), Effect.never).pipe(
Effect.onInterrupt(() => Effect.sync(() => events.push("interrupt")))
),
collectWhile(entries) {
events.push(`collect:${entries.size}`)
return false
},
runAll: (entries) =>
Effect.sync(() => {
events.push(`run:${entries.length}`)
for (const entry of entries) entry.completeUnsafe(Exit.succeed("ok"))
})
})

// Repeat after synchronous completion to exercise reuse of a recycled batch.
for (let i = 0; i < 2; i++) {
assert.strictEqual(yield* Effect.request(GetRequestService(), resolver), "ok")
}
assert.deepStrictEqual(events, [
"delay",
"collect:1",
"interrupt",
"run:1",
"delay",
"collect:1",
"interrupt",
"run:1"
])
}))

it.effect("synchronous delay callbacks can enqueue requests that fill a batch", () =>
Effect.gen(function*() {
const context = yield* Effect.context<never>()
const batches: Array<number> = []
const results: Array<Request.Result<GetRequestService>> = []
const resolver = Resolver.make<GetRequestService>((entries) =>
Effect.sync(() => {
batches.push(entries.length)
for (const entry of entries) entry.completeUnsafe(Exit.succeed("ok"))
})
).pipe(
Resolver.batchN(2),
Resolver.setDelayEffect(Effect.andThen(
Effect.sync(() => {
Effect.requestUnsafe(GetRequestService(), { resolver, context, onExit: (exit) => results.push(exit) })
}),
Effect.never
))
)

for (let i = 0; i < 2; i++) {
assert.strictEqual(yield* Effect.request(GetRequestService(), resolver), "ok")
}
assert.deepStrictEqual(batches, [2, 2])
assert.deepStrictEqual(results, [Exit.succeed("ok"), Exit.succeed("ok")])
}))

for (const [clock, test] of [["live clock", it.live], ["TestClock", it.effect]] as const) {
test(`setDelay(0) resolves a non-empty grouped batch with ${clock}`, () =>
Effect.gen(function*() {
const batches: Array<Array<number>> = []
const resolver = Resolver.makeGrouped<GetNameById, string>({
key: () => "all",
resolver: (entries) =>
Effect.sync(() => {
batches.push(entries.map(({ request }) => request.id))
for (const entry of entries) entry.completeUnsafe(Exit.succeed(String(entry.request.id)))
})
}).pipe(Resolver.setDelay(0), Resolver.batchN(2))

// Resume after a microtask, as in callers awaiting external work before requesting.
yield* Effect.promise(() => Promise.resolve())
const exit = yield* Effect.exit(Effect.request(new GetNameById({ id: 1 }), resolver))

assert.deepStrictEqual({ exit, batches }, { exit: Exit.succeed("1"), batches: [[1]] })
}))
}
})
Loading