| ... | @@ -1,13 +1,15 @@ | ... | @@ -1,13 +1,15 @@ |
| 1 | /** | 1 | /** |
| 2 | * DrainableWorker - A queue-based worker with deterministic `drain()`. | 2 | * DrainableWorker - A queue-based worker that exposes a `drain()` effect. |
| 3 | * | 3 | * |
| 4 | * Tracks outstanding work in STM so `drain()` resolves only when no items | 4 | * Wraps the common `Queue.unbounded` + `Effect.forever` pattern and adds |
| 5 | * are queued or in flight. Useful in tests instead of timing-based waits. | 5 | * a signal that resolves when the queue is empty **and** the current item |
| | 6 | * has finished processing. This lets tests replace timing-sensitive |
| | 7 | * `Effect.sleep` calls with deterministic `drain()`. |
| 6 | * | 8 | * |
| 7 | * @module DrainableWorker | 9 | * @module DrainableWorker |
| 8 | */ | 10 | */ |
| 9 | import { Effect, TxQueue, TxRef } from "effect"; | | |
| 10 | import type { Scope } from "effect"; | 11 | import type { Scope } from "effect"; |
| | 12 | import { Effect, TxQueue, TxRef } from "effect"; |
| 11 | | 13 | |
| 12 | export interface DrainableWorker<A> { | 14 | export interface DrainableWorker<A> { |
| 13 | /** | 15 | /** |
| ... | @@ -37,40 +39,30 @@ export const makeDrainableWorker = <A, E, R>( | ... | @@ -37,40 +39,30 @@ export const makeDrainableWorker = <A, E, R>( |
| 37 | process: (item: A) => Effect.Effect<void, E, R>, | 39 | process: (item: A) => Effect.Effect<void, E, R>, |
| 38 | ): Effect.Effect<DrainableWorker<A>, never, Scope.Scope | R> => | 40 | ): Effect.Effect<DrainableWorker<A>, never, Scope.Scope | R> => |
| 39 | Effect.gen(function* () { | 41 | Effect.gen(function* () { |
| 40 | const ref = yield* TxRef.make(0); | 42 | const queue = yield* Effect.acquireRelease(TxQueue.unbounded<A>(), TxQueue.shutdown); |
| 41 | | 43 | const outstanding = yield* TxRef.make(0); |
| 42 | const queue = yield* Effect.acquireRelease(TxQueue.unbounded<A>(), (queue) => | | |
| 43 | TxQueue.shutdown(queue), | | |
| 44 | ); | | |
| 45 | | 44 | |
| 46 | const takeItem = Effect.tx( | 45 | yield* TxQueue.take(queue).pipe( |
| 47 | Effect.gen(function* () { | 46 | Effect.tap((a) => |
| 48 | const item = yield* TxQueue.take(queue); | 47 | Effect.ensuring( |
| 49 | yield* TxRef.update(ref, (n) => n + 1); | 48 | process(a), |
| 50 | return item; | 49 | TxRef.update(outstanding, (n) => n - 1), |
| 51 | }), | 50 | ), |
| 52 | ); | | |
| 53 | | | |
| 54 | yield* takeItem.pipe( | | |
| 55 | Effect.flatMap((item) => | | |
| 56 | process(item).pipe(Effect.ensuring(TxRef.update(ref, (n) => n - 1))), | | |
| 57 | ), | 51 | ), |
| 58 | Effect.forever, | 52 | Effect.forever, |
| 59 | Effect.forkScoped, | 53 | Effect.forkScoped, |
| 60 | ); | 54 | ); |
| 61 | | 55 | |
| 62 | const drain: DrainableWorker<A>["drain"] = Effect.tx( | 56 | const drain: DrainableWorker<A>["drain"] = TxRef.get(outstanding).pipe( |
| 63 | Effect.gen(function* () { | 57 | Effect.tap((n) => (n > 0 ? Effect.txRetry : Effect.void)), |
| 64 | const inFlight = yield* TxRef.get(ref); | 58 | Effect.tx, |
| 65 | const isEmpty = yield* TxQueue.isEmpty(queue); | | |
| 66 | if (inFlight > 0 || !isEmpty) { | | |
| 67 | return yield* Effect.txRetry; | | |
| 68 | } | | |
| 69 | }), | | |
| 70 | ); | 59 | ); |
| 71 | | 60 | |
| 72 | return { | 61 | const enqueue = (element: A): Effect.Effect<boolean, never, never> => |
| 73 | enqueue: (item) => TxQueue.offer(queue, item), | 62 | TxQueue.offer(queue, element).pipe( |
| 74 | drain, | 63 | Effect.tap(() => TxRef.update(outstanding, (n) => n + 1)), |
| 75 | } satisfies DrainableWorker<A>; | 64 | Effect.tx, |
| | 65 | ); |
| | 66 | |
| | 67 | return { enqueue, drain } satisfies DrainableWorker<A>; |
| 76 | }); | 68 | }); |