Skip to content

Commit e8ec62f

Browse files
committed
stream: read async sources more cheaply in stream/iter pipeTo()
from() reads an async iterable source through a layer that lets a cancellation of the normalization reject a pending read: for every batch, it waits with a PromiseWithResolvers() and three closures, and the normalizer handles the source's result in a second reaction. pipeTo() never cancels the normalization while a read is pending: it only calls return() after an error writing a batch. Without transforms or a signal, have it read through a method of the iterator returned by from() that reads the source without waiting for a cancellation, and handles the source's result in a single reaction. The source's results are checked as before, a write error still closes the source, and an error reading the source still does not. Piping an async generator yielding 16-byte chunks, one per batch, is about 1.5 times faster, with one promise and 620 bytes less per batch. Assisted-by: OpenCode
1 parent ca45ad4 commit e8ec62f

3 files changed

Lines changed: 180 additions & 0 deletions

File tree

‎lib/internal/streams/iter/from.js‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,44 @@ function waitForNormalization(value, context) {
144144
// in createSyncSourceNormalizer().
145145
const kNextSyncBatch = Symbol('kNextSyncBatch');
146146

147+
// The method of an iterator returned by from() for an async iterable that
148+
// reads like next() when nothing can cancel the normalization while a read
149+
// is pending, for pipeTo(): see createAsyncSourceNormalizer().
150+
const kNextUncancellable = Symbol('kNextUncancellable');
151+
152+
// Methods of the iterator returned by yieldNormalizationAbortable(), used by
153+
// createAsyncSourceNormalizer() for kNextUncancellable.
154+
const kReadUncancellable = Symbol('kReadUncancellable');
155+
const kToIterResult = Symbol('kToIterResult');
156+
const kReadFailed = Symbol('kReadFailed');
157+
// The result kReadUncancellable gives once the source is done.
158+
const kDoneSourceResult = ObjectFreeze({ __proto__: null, done: true, value: undefined });
159+
147160
function createNormalizationIterator(createIterator) {
148161
const context = createNormalizationContext();
149162
const iterator = createIterator(context);
163+
if (iterator[kNextUncancellable] !== undefined) {
164+
return ObjectSetPrototypeOf({
165+
next(value) {
166+
return FunctionPrototypeCall(iterator.next, iterator, value);
167+
},
168+
return(value) {
169+
cancelNormalization(
170+
context, lazyDOMException('Aborted', 'AbortError'));
171+
return FunctionPrototypeCall(iterator.return, iterator, value);
172+
},
173+
throw(error) {
174+
cancelNormalization(context, error, true);
175+
return FunctionPrototypeCall(iterator.throw, iterator, error);
176+
},
177+
[kNextUncancellable]() {
178+
return iterator[kNextUncancellable]();
179+
},
180+
[SymbolAsyncIterator]() {
181+
return this;
182+
},
183+
}, null);
184+
}
150185
if (iterator[kNextSyncBatch] !== undefined) {
151186
return ObjectSetPrototypeOf({
152187
next(value) {
@@ -468,6 +503,15 @@ function yieldNormalizationAbortable(source, context) {
468503
return new IterResult(false, value);
469504
}
470505

506+
function onUncancellableResult(result) {
507+
try {
508+
return toIterResult(result);
509+
} catch (error) {
510+
reading = false;
511+
throw error;
512+
}
513+
}
514+
471515
// Reject a pending next(), closing the source first if the
472516
// normalization has been cancelled.
473517
function rejectNext(reject, error) {
@@ -535,6 +579,30 @@ function yieldNormalizationAbortable(source, context) {
535579
});
536580
return promise;
537581
},
582+
// Like next(), when nothing cancels the normalization while the read
583+
// is pending, in two parts so that the caller handles the result in
584+
// the same reaction: kReadUncancellable starts the read, returning a
585+
// promise for the source's result, and the caller passes that result
586+
// to kToIterResult, or calls kReadFailed if the promise rejects.
587+
[kReadUncancellable]() {
588+
if (completed) {
589+
return PromiseResolve(kDoneSourceResult);
590+
}
591+
if (context.cancelled) return PromiseReject(context.reason);
592+
reading = true;
593+
let next;
594+
try {
595+
next = FunctionPrototypeCall(nextMethod, iterator);
596+
} catch (error) {
597+
reading = false;
598+
return PromiseReject(error);
599+
}
600+
return PromiseResolve(next);
601+
},
602+
[kToIterResult]: onUncancellableResult,
603+
[kReadFailed]() {
604+
reading = false;
605+
},
538606
async return(value) {
539607
await closeSource(
540608
context.suppressCleanup || (context.cancelled && reading));
@@ -723,6 +791,10 @@ function createAsyncSourceNormalizer(source, context) {
723791
// oversized batch, or a normalizeAsyncSourceValue() generator.
724792
let boundedBatches = null;
725793
let valueBatches = null;
794+
// Whether the source is read with kNextUncancellable: set by the
795+
// kNextUncancellable method, for callers that never cancel the
796+
// normalization while a read is pending.
797+
let uncancellable = false;
726798
const { run, settled } = createOperationQueue();
727799

728800
function finish(result) {
@@ -784,7 +856,28 @@ function createAsyncSourceNormalizer(source, context) {
784856
return finish(new IterResult(false, result.value));
785857
}
786858

859+
// A kReadUncancellable result: handled as the result of the source's
860+
// next() is, in the same reaction.
861+
function onUncancellableResult(result) {
862+
let iterResult;
863+
try {
864+
iterResult = iterator[kToIterResult](result);
865+
} catch (error) {
866+
return fail(error);
867+
}
868+
return onSourceResult(iterResult);
869+
}
870+
871+
function onUncancellableError(error) {
872+
iterator[kReadFailed]();
873+
return fail(error);
874+
}
875+
787876
function pullSource() {
877+
if (uncancellable) {
878+
return PromisePrototypeThen(iterator[kReadUncancellable](),
879+
onUncancellableResult, onUncancellableError);
880+
}
788881
return PromisePrototypeThen(iterator.next(), onSourceResult, fail);
789882
}
790883

@@ -866,6 +959,10 @@ function createAsyncSourceNormalizer(source, context) {
866959
next() { return run(doNext); },
867960
return(value) { return run(doReturn, value); },
868961
throw(error) { return run(doThrow, error); },
962+
[kNextUncancellable]() {
963+
uncancellable = true;
964+
return run(doNext);
965+
},
869966
}, null);
870967
}
871968

@@ -1344,6 +1441,7 @@ module.exports = {
13441441
isSyncIterable,
13451442
isUint8ArrayBatch,
13461443
kNextSyncBatch,
1444+
kNextUncancellable,
13471445
normalizeAsyncSource,
13481446
normalizeAsyncValue,
13491447
normalizeSyncSource,

‎lib/internal/streams/iter/pull.js‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const {
5353
isAsyncIterable,
5454
isUint8ArrayBatch,
5555
kNextSyncBatch,
56+
kNextUncancellable,
5657
} = require('internal/streams/iter/from');
5758

5859
const {
@@ -1737,6 +1738,27 @@ async function pipeTo(source, ...args) {
17371738
}
17381739
}
17391740

1741+
// Write the batches of an async source as the for await...of loop below
1742+
// does. Nothing cancels the normalization while a batch is read: return()
1743+
// is only called after an error writing one.
1744+
async function pipeAsyncSource(iterator) {
1745+
for (;;) {
1746+
const result = await iterator[kNextUncancellable]();
1747+
if (result.done) return;
1748+
try {
1749+
const p = writeBatch(result.value);
1750+
if (p) await p;
1751+
} catch (error) {
1752+
try {
1753+
await iterator.return();
1754+
} catch {
1755+
// The error writing the batch is thrown.
1756+
}
1757+
throw error;
1758+
}
1759+
}
1760+
}
1761+
17401762
function writeBatch(batch) {
17411763
// Single chunk, the common case: check the view around writeSync()
17421764
// without allocating a batch entry, and create one only to fall back to
@@ -1798,6 +1820,8 @@ async function pipeTo(source, ...args) {
17981820
}
17991821
} else if (normalized[kNextSyncBatch] !== undefined) {
18001822
await pipeSyncSource(normalized);
1823+
} else if (normalized[kNextUncancellable] !== undefined) {
1824+
await pipeAsyncSource(normalized);
18011825
} else {
18021826
for await (const batch of normalized) {
18031827
const p = writeBatch(batch);

‎test/parallel/test-stream-iter-pipeto.js‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,63 @@ async function testPipeToSyncIterableWriteError() {
391391
}), error);
392392
}
393393

394+
// pipeTo() reads async iterables without waiting for a cancellation, since
395+
// nothing cancels them while a read is pending. An error writing a batch
396+
// must still close the source, ignoring an error closing it, an error
397+
// reading the source must not close it, and the source's results are still
398+
// checked.
399+
async function testPipeToAsyncIterableErrors() {
400+
for (const returnRejects of [false, true]) {
401+
const error = new Error('write');
402+
let closed = false;
403+
const source = {
404+
[Symbol.asyncIterator]() {
405+
let i = 0;
406+
return {
407+
async next() {
408+
return { done: false, value: [new Uint8Array([i++])] };
409+
},
410+
async return() {
411+
closed = true;
412+
if (returnRejects) throw new Error('return');
413+
return { done: true };
414+
},
415+
};
416+
},
417+
};
418+
await assert.rejects(pipeTo(source, {
419+
write: common.mustNotCall(),
420+
writeSync: common.mustCall(() => { throw error; }),
421+
fail: common.mustCall((reason) => assert.strictEqual(reason, error)),
422+
}), error);
423+
assert.strictEqual(closed, true);
424+
}
425+
426+
const error = new Error('source');
427+
await assert.rejects(pipeTo({
428+
[Symbol.asyncIterator]() {
429+
return {
430+
next() { return Promise.reject(error); },
431+
return: common.mustNotCall(),
432+
};
433+
},
434+
}, {
435+
write: common.mustNotCall(),
436+
writeSync: common.mustNotCall(),
437+
fail: common.mustCall((reason) => assert.strictEqual(reason, error)),
438+
}), error);
439+
440+
await assert.rejects(pipeTo({
441+
[Symbol.asyncIterator]() {
442+
return { next: async () => 42 };
443+
},
444+
}, {
445+
write: common.mustNotCall(),
446+
writeSync: common.mustNotCall(),
447+
fail: common.mustCall(),
448+
}), { code: 'ERR_INVALID_RETURN_VALUE' });
449+
}
450+
394451
Promise.all([
395452
testPipeToSync(),
396453
testPipeTo(),
@@ -412,5 +469,6 @@ Promise.all([
412469
testPipeToSyncIterableWriteFallback(),
413470
testPipeToSyncIterableAsyncValue(),
414471
testPipeToSyncIterableWriteError(),
472+
testPipeToAsyncIterableErrors(),
415473
testPipeToSourceNormalizationIndependentOfWriter(),
416474
]).then(common.mustCall());

0 commit comments

Comments
 (0)