From ca524e1b01dc49e0a01420dc78634e3a52d652c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Czapracki?= Date: Wed, 9 Sep 2026 09:18:42 +0000 Subject: [PATCH] Support live ordered OFCA streams --- src/index.ts | 104 ++++++++++++++++++++++++++++++++++++--------- test/async.test.js | 92 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 20 deletions(-) diff --git a/src/index.ts b/src/index.ts index 385131c..7b999ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -308,15 +308,17 @@ function iteratorFrom( /** * Process any standard iterable, async iterable, or Node Readable (through its * standard async iterator) with bounded callback-first dispatch and ordered output. - * Each leased slot includes either an active task or a reorder-buffer entry. + * The reorder buffer is deliberately not part of admission capacity: completed + * values may accumulate while an earlier task is still running. */ -export async function* ofca( - source: Iterable | AsyncIterable, +async function* ofcaKernel( + source: Iterator | AsyncIterator, mapper: (value: Input, index: number) => Output | PromiseLike | typeof DROP, options: { concurrency?: number } = {}, + cancellation: { cancelled: boolean; wake?: () => void; close?: () => Promise }, ): AsyncIterable | undefined> { const concurrency = concurrencyOf(options.concurrency); - const iterator = iteratorFrom(source); + const iterator = source; const invoke = callbackify(mapper); const completed = new Map(); let active = 0; @@ -326,8 +328,11 @@ export async function* ofca( let fatalError: unknown; let notification = false; let wake: (() => void) | undefined; + let pendingNext: Promise | undefined; + const isCancelled = (): boolean => cancellation.cancelled; const notify = (): void => { + if (isCancelled()) return; if (wake) { const resolve = wake; wake = undefined; @@ -344,10 +349,12 @@ export async function* ofca( } return new Promise((resolve) => { wake = () => resolve(); + cancellation.wake = wake; }); }; const dispatch = (value: Input, index: number): void => { + if (isCancelled()) return; active += 1; invoke(value, index, (error, result) => { active -= 1; @@ -357,20 +364,28 @@ export async function* ofca( }); }; - const fill = async (): Promise => { - while (!sourceDone && typeof fatalError === "undefined" && active + completed.size < concurrency) { - const next = await iterator.next(); - if (next.done) { - sourceDone = true; - return; - } - dispatch(next.value, dispatched++); - } + const fill = (): void => { + if (isCancelled() || sourceDone || pendingNext || typeof fatalError !== "undefined" || + active >= concurrency || completed.size >= concurrency) return; + pendingNext = Promise.resolve() + .then(() => iterator.next()) + .then((next) => { + pendingNext = undefined; + if (isCancelled()) return; + if (next.done) sourceDone = true; + else dispatch(next.value, dispatched++); + notify(); + }, (error) => { + pendingNext = undefined; + if (isCancelled()) return; + fatalError = error; + notify(); + }); }; try { - await fill(); - while (active > 0 || completed.size > 0 || !sourceDone) { + fill(); + while (!isCancelled() && (active > 0 || completed.size > 0 || !sourceDone || pendingNext)) { if (typeof fatalError !== "undefined") throw fatalError; while (completed.has(nextOutput)) { @@ -380,15 +395,64 @@ export async function* ofca( if (value !== DROP) yield value as Exclude | undefined; } - await fill(); + fill(); if (typeof fatalError !== "undefined") throw fatalError; - if (active === 0 && sourceDone) break; + if (active === 0 && completed.size === 0 && sourceDone && !pendingNext) break; if (!completed.has(nextOutput)) await waitForCompletion(); } } finally { - const close = iterator.return; - if (typeof close === "function") { - await (close as () => unknown).call(iterator); + cancellation.cancelled = true; + cancellation.wake?.(); + cancellation.wake = undefined; + completed.clear(); + notification = false; + if (cancellation.close) { + const closing = cancellation.close(); + if (pendingNext) void closing.catch(() => undefined); + else await closing; } } } + +// export async function* ofca( +export function ofca( + source: Iterable | AsyncIterable, + mapper: (value: Input, index: number) => Output | PromiseLike | typeof DROP, + options: { concurrency?: number } = {}, +): AsyncIterableIterator | undefined> { + const iterator = iteratorFrom(source); + let closePromise: Promise | undefined; + const closeSource = (): Promise => { + if (!closePromise) { + const close = iterator.return; + if (typeof close !== "function") { + closePromise = Promise.resolve(); + } else { + try { + closePromise = Promise.resolve((close as () => unknown).call(iterator)); + } catch (error) { + closePromise = Promise.reject(error); + } + } + } + return closePromise; + }; + const cancellation: {cancelled: boolean; wake?: () => void; close?: () => Promise} = { + cancelled: false, + close: closeSource, + }; + const kernel = ofcaKernel(iterator, mapper, options, cancellation)[Symbol.asyncIterator](); + return { + next: value => kernel.next(value), + return: value => { + cancellation.cancelled = true; + cancellation.wake?.(); + cancellation.wake = undefined; + void closeSource().catch(() => undefined); + void Promise.resolve(kernel.return?.(value)).catch(() => undefined); + return Promise.resolve({done: true, value: value as Exclude | undefined}); + }, + throw: error => kernel.throw ? kernel.throw(error) : Promise.reject(error), + [Symbol.asyncIterator]() { return this; }, + }; +} diff --git a/test/async.test.js b/test/async.test.js index 3f856f8..f8a4673 100644 --- a/test/async.test.js +++ b/test/async.test.js @@ -78,3 +78,95 @@ test("ofca closes a source iterator when the consumer exits early", async () => await iterator.return(); assert.equal(closed, true); }); + +test("ofca emits a live result without waiting for the next source item", async () => { + let release; + const source = (async function* () { + yield 1; + await new Promise(resolve => { release = resolve; }); + yield 2; + })(); + const iterator = ofca(source, value => value, {concurrency: 2})[Symbol.asyncIterator](); + const first = await Promise.race([ + iterator.next(), + delay(100).then(() => { throw new Error("first output was gated by the live source"); }), + ]); + assert.deepEqual(first, {done: false, value: 1}); + const second = iterator.next(); + await delay(1); + release(); + assert.deepEqual(await second, {done: false, value: 2}); + assert.deepEqual(await iterator.next(), {done: true, value: undefined}); +}); + +test("ofca refills a rolling window while an earlier item is delayed", async () => { + let release; + const started = []; + const output = collect(ofca([0, 1, 2], async value => { + started.push(value); + if (value === 0) await new Promise(resolve => { release = resolve; }); + return value; + }, {concurrency: 2})); + + await delay(10); + assert.deepEqual(started, [0, 1, 2]); + release(); + assert.deepEqual(await output, [0, 1, 2]); +}); + +test("ofca keeps a stalled first source read bounded", async () => { + let calls = 0; + let release; + const source = { + [Symbol.asyncIterator]() { return this; }, + next() { + calls++; + if (calls === 1) return new Promise(resolve => { release = () => resolve({done: false, value: 1}); }); + return Promise.resolve({done: true, value: undefined}); + } + }; + const iterator = ofca(source, value => value, {concurrency: 2})[Symbol.asyncIterator](); + const first = iterator.next(); + await delay(10); + assert.equal(calls, 1); + release(); + assert.deepEqual(await first, {done: false, value: 1}); + assert.deepEqual(await iterator.next(), {done: true, value: undefined}); +}); + +test("ofca cancellation closes a blocked input without dispatching late values", async () => { + let calls = 0; + let releaseFirst; + let returnCalls = 0; + const mapped = []; + const source = { + [Symbol.asyncIterator]() { return this; }, + next() { + calls++; + if (calls === 1) return Promise.resolve({done: false, value: 1}); + return new Promise(resolve => { releaseFirst = () => resolve({done: false, value: 2}); }); + }, + return() { + returnCalls++; + return Promise.resolve({done: true, value: undefined}); + } + }; + const iterator = ofca(source, async value => { + mapped.push(value); + return value; + }, {concurrency: 1})[Symbol.asyncIterator](); + + assert.deepEqual(await iterator.next(), {done: false, value: 1}); + const pending = iterator.next(); + await delay(1); + const returned = await Promise.race([ + iterator.return(), + delay(100).then(() => { throw new Error("consumer cancellation stalled"); }) + ]); + assert.deepEqual(returned, {done: true, value: undefined}); + assert.deepEqual(await pending, {done: true, value: undefined}); + assert.equal(returnCalls, 1); + releaseFirst(); + await delay(1); + assert.deepEqual(mapped, [1]); +});