Skip to content

Commit 0488d02

Browse files
jasnelladuh95
authored andcommitted
stream: update iterable streams to use budget backpressure
Updates the implementation to use the updated backpressure model that landed here: WinterTC55/iter-streams#23 Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #64464 Reviewed-By: Tim Perry <pimterry@gmail.com> Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com> Reviewed-By: Ethan Arrowood <ethan@arrowood.dev>
1 parent b122ca5 commit 0488d02

23 files changed

Lines changed: 410 additions & 372 deletions

doc/api/stream_iter.md

Lines changed: 68 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -186,22 +186,22 @@ The API supports two models:
186186
Pull streams have natural backpressure -- the consumer drives the pace, so
187187
the source is never read faster than the consumer can process. Push streams
188188
need explicit backpressure because the producer and consumer run
189-
independently. The `highWaterMark` and `backpressure` options on `push()`,
189+
independently. The `budget` and `backpressure` options on `push()`,
190190
`broadcast()`, and `share()` control how this works.
191191

192192
#### The two-buffer model
193193

194194
Push streams use a two-part buffering system. Think of it like a bucket
195-
(slots) being filled through a hose (pending writes), with a float valve
195+
(buffer) being filled through a hose (pending writes), with a float valve
196196
that closes when the bucket is full:
197197

198198
```text
199-
highWaterMark (e.g., 3)
199+
budget (e.g., 16384)
200200
|
201201
Producer v
202202
| +---------+
203203
v | |
204-
[ write() ] ----+ +--->| slots |---> Consumer pulls
204+
[ write() ] ----+ +--->| buffer |---> Consumer pulls
205205
[ write() ] | | | (bucket)| for await (...)
206206
[ write() ] v | +---------+
207207
+--------+ ^
@@ -214,29 +214,29 @@ that closes when the bucket is full:
214214
'strict' mode limits this too!
215215
```
216216

217-
* **Slots (the bucket)** -- data ready for the consumer, capped at
218-
`highWaterMark`. When the consumer pulls, it drains all slots at once
219-
into a single batch.
217+
* **Buffer (the bucket)** -- data ready for the consumer, capped at
218+
`budget` bytes. When the consumer pulls, it drains all buffered data
219+
at once into a single batch.
220220

221-
* **Pending writes (the hose)** -- writes waiting for slot space. After
221+
* **Pending writes (the hose)** -- writes waiting for buffer space. After
222222
the consumer drains, pending writes are promoted into the now-empty
223-
slots and their promises settle.
223+
buffer and their promises settle.
224224

225225
How each policy uses these buffers:
226226

227-
| Policy | Slots limit | Pending writes limit |
228-
| --------------- | --------------- | -------------------- |
229-
| `'strict'` | `highWaterMark` | `highWaterMark` |
230-
| `'block'` | `highWaterMark` | Unbounded |
231-
| `'drop-oldest'` | `highWaterMark` | N/A (never waits) |
232-
| `'drop-newest'` | `highWaterMark` | N/A (never waits) |
227+
| Policy | Buffer limit | Pending writes limit |
228+
| --------------- | ------------ | -------------------- |
229+
| `'strict'` | `budget` | `budget` |
230+
| `'unbounded'` | `budget` | Unbounded |
231+
| `'drop-oldest'` | `budget` | N/A (never waits) |
232+
| `'drop-newest'` | `budget` | N/A (never waits) |
233233

234234
#### Strict (default)
235235

236236
Strict mode catches "fire-and-forget" patterns where the producer calls
237237
`write()` without awaiting, which would cause unbounded memory growth.
238-
It limits both the slots buffer and the pending writes queue to
239-
`highWaterMark`.
238+
It limits both the buffer and the pending writes queue to
239+
`budget` bytes.
240240

241241
If you properly await each write, you can only ever have one pending
242242
write at a time (yours), so you never hit the pending writes limit.
@@ -246,7 +246,7 @@ overflows:
246246
```mjs
247247
import { push, text } from 'node:stream/iter';
248248

249-
const { writer, readable } = push({ highWaterMark: 16 });
249+
const { writer, readable } = push({ budget: 16384 });
250250

251251
// Consumer must run concurrently -- without it, the first write
252252
// that fills the buffer blocks the producer forever.
@@ -265,7 +265,7 @@ console.log(await consuming);
265265
const { push, text } = require('node:stream/iter');
266266

267267
async function run() {
268-
const { writer, readable } = push({ highWaterMark: 16 });
268+
const { writer, readable } = push({ budget: 16384 });
269269

270270
// Consumer must run concurrently -- without it, the first write
271271
// that fills the buffer blocks the producer forever.
@@ -293,9 +293,9 @@ for (const item of dataset) {
293293
// --> throws "Backpressure violation: too many pending writes"
294294
```
295295

296-
#### Block
296+
#### Unbounded
297297

298-
Block mode caps slots at `highWaterMark` but places no limit on the
298+
Unbounded mode caps buffered bytes at `budget` but places no limit on the
299299
pending writes queue. Awaited writes block until the consumer makes room,
300300
just like strict mode. The difference is that unawaited writes silently
301301
queue forever instead of throwing -- a potential memory leak if the
@@ -309,8 +309,8 @@ properly, or when migrating code from those APIs.
309309
import { push, text } from 'node:stream/iter';
310310

311311
const { writer, readable } = push({
312-
highWaterMark: 16,
313-
backpressure: 'block',
312+
budget: 16384,
313+
backpressure: 'unbounded',
314314
});
315315

316316
const consuming = text(readable);
@@ -328,8 +328,8 @@ const { push, text } = require('node:stream/iter');
328328

329329
async function run() {
330330
const { writer, readable } = push({
331-
highWaterMark: 16,
332-
backpressure: 'block',
331+
budget: 16384,
332+
backpressure: 'unbounded',
333333
});
334334

335335
const consuming = text(readable);
@@ -355,19 +355,19 @@ any scenario where stale data is less valuable than current data.
355355
```mjs
356356
import { push } from 'node:stream/iter';
357357

358-
// Keep only the 5 most recent readings
358+
// Keep only the most recent ~16 KB of readings
359359
const { writer, readable } = push({
360-
highWaterMark: 5,
360+
budget: 16384,
361361
backpressure: 'drop-oldest',
362362
});
363363
```
364364

365365
```cjs
366366
const { push } = require('node:stream/iter');
367367

368-
// Keep only the 5 most recent readings
368+
// Keep only the most recent ~16 KB of readings
369369
const { writer, readable } = push({
370-
highWaterMark: 5,
370+
budget: 16384,
371371
backpressure: 'drop-oldest',
372372
});
373373
```
@@ -382,19 +382,19 @@ shedding load under pressure.
382382
```mjs
383383
import { push } from 'node:stream/iter';
384384

385-
// Accept up to 10 buffered items; discard anything beyond that
385+
// Accept up to 16 KB of buffered data; discard anything beyond that
386386
const { writer, readable } = push({
387-
highWaterMark: 10,
387+
budget: 16384,
388388
backpressure: 'drop-newest',
389389
});
390390
```
391391

392392
```cjs
393393
const { push } = require('node:stream/iter');
394394

395-
// Accept up to 10 buffered items; discard anything beyond that
395+
// Accept up to 16 KB of buffered data; discard anything beyond that
396396
const { writer, readable } = push({
397-
highWaterMark: 10,
397+
budget: 16384,
398398
backpressure: 'drop-newest',
399399
});
400400
```
@@ -416,14 +416,16 @@ if (writer.endSync() < 0) await writer.end();
416416
writer.fail(err); // Always synchronous, no fallback needed
417417
```
418418

419-
#### `writer.desiredSize`
419+
#### `writer.canWrite`
420420

421-
* {number|null}
421+
* {boolean|null}
422422

423-
The number of buffer slots available before the high water mark is reached.
424-
Returns `null` if the writer is closed or the consumer has disconnected.
423+
Returns `true` if the next write is likely to be accepted (buffered data is
424+
below capacity), `false` if backpressure is active, or `null` if the writer
425+
is closed or the consumer has disconnected.
425426

426-
The value is always non-negative.
427+
This is a hint, not a guarantee: the state can change between the check and
428+
the write. Use [`ondrain()`][] to wait for capacity rather than polling.
427429

428430
#### `writer.end([options])`
429431

@@ -759,10 +761,10 @@ added: v25.9.0
759761
* `...transforms` {Function|Object} Optional transforms applied to the
760762
readable side.
761763
* `options` {Object}
762-
* `highWaterMark` {number} Maximum number of buffered slots before
763-
backpressure is applied. Must be >= 1; values below 1 are clamped to 1.
764-
**Default:** `4`.
765-
* `backpressure` {string} Backpressure policy: `'strict'`, `'block'`,
764+
* `budget` {number} Maximum number of buffered bytes before
765+
backpressure is applied. Must be >= 16384.
766+
**Default:** `16384`.
767+
* `backpressure` {string} Backpressure policy: `'strict'`, `'unbounded'`,
766768
`'drop-oldest'`, or `'drop-newest'`. **Default:** `'strict'`.
767769
* `signal` {AbortSignal} Abort the stream.
768770
* Returns: {Object}
@@ -821,18 +823,18 @@ added: v25.9.0
821823
-->
822824

823825
* `options` {Object}
824-
* `highWaterMark` {number} Buffer size for both directions.
825-
**Default:** `4`.
826+
* `budget` {number} Buffer size in bytes for both directions.
827+
**Default:** `16384`.
826828
* `backpressure` {string} Policy for both directions.
827829
**Default:** `'strict'`.
828830
* `signal` {AbortSignal} Cancellation signal for both channels.
829831
* `a` {Object} Options specific to the A-to-B direction. Overrides
830832
shared options.
831-
* `highWaterMark` {number}
833+
* `budget` {number}
832834
* `backpressure` {string}
833835
* `b` {Object} Options specific to the B-to-A direction. Overrides
834836
shared options.
835-
* `highWaterMark` {number}
837+
* `budget` {number}
836838
* `backpressure` {string}
837839
* Returns: {Array} A pair `[channelA, channelB]` of duplex channels.
838840

@@ -1062,9 +1064,10 @@ fulfills with `true` when the writer can accept more data.
10621064
```mjs
10631065
import { push, ondrain, text } from 'node:stream/iter';
10641066

1065-
const { writer, readable } = push({ highWaterMark: 2 });
1066-
writer.writeSync('a');
1067-
writer.writeSync('b');
1067+
const { writer, readable } = push({ budget: 16384 });
1068+
const chunk = new Uint8Array(8192); // 8 KB
1069+
writer.writeSync(chunk);
1070+
writer.writeSync(chunk); // 16 KB total -- buffer full
10681071

10691072
// Start consuming so the buffer can actually drain
10701073
const consuming = text(readable);
@@ -1082,9 +1085,10 @@ await consuming;
10821085
const { push, ondrain, text } = require('node:stream/iter');
10831086

10841087
async function run() {
1085-
const { writer, readable } = push({ highWaterMark: 2 });
1086-
writer.writeSync('a');
1087-
writer.writeSync('b');
1088+
const { writer, readable } = push({ budget: 16384 });
1089+
const chunk = new Uint8Array(8192); // 8 KB
1090+
writer.writeSync(chunk);
1091+
writer.writeSync(chunk); // 16 KB total -- buffer full
10881092

10891093
// Start consuming so the buffer can actually drain
10901094
const consuming = text(readable);
@@ -1193,9 +1197,9 @@ added: v25.9.0
11931197
-->
11941198

11951199
* `options` {Object}
1196-
* `highWaterMark` {number} Buffer size in slots. Must be >= 1; values
1197-
below 1 are clamped to 1. **Default:** `16`.
1198-
* `backpressure` {string} `'strict'`, `'block'`, `'drop-oldest'`, or
1200+
* `budget` {number} Buffer size in bytes. Must be >= 16384.
1201+
**Default:** `65536`.
1202+
* `backpressure` {string} `'strict'`, `'unbounded'`, `'drop-oldest'`, or
11991203
`'drop-newest'`. **Default:** `'strict'`.
12001204
* `signal` {AbortSignal}
12011205
* Returns: {Object}
@@ -1254,12 +1258,6 @@ async function run() {
12541258
run().catch(console.error);
12551259
```
12561260

1257-
#### `broadcast.bufferSize`
1258-
1259-
* {number}
1260-
1261-
The number of chunks currently buffered.
1262-
12631261
#### `broadcast.cancel([reason])`
12641262

12651263
* `reason` {Error}
@@ -1308,9 +1306,9 @@ added: v25.9.0
13081306

13091307
* `source` {AsyncIterable} The source to share.
13101308
* `options` {Object}
1311-
* `highWaterMark` {number} Buffer size. Must be >= 1; values below 1
1312-
are clamped to 1. **Default:** `16`.
1313-
* `backpressure` {string} `'strict'`, `'block'`, `'drop-oldest'`, or
1309+
* `budget` {number} Buffer size in bytes. Must be >= 16384.
1310+
**Default:** `65536`.
1311+
* `backpressure` {string} `'strict'`, `'unbounded'`, `'drop-oldest'`, or
13141312
`'drop-newest'`. **Default:** `'strict'`.
13151313
* Returns: {Share}
13161314

@@ -1350,12 +1348,6 @@ async function run() {
13501348
run().catch(console.error);
13511349
```
13521350

1353-
#### `share.bufferSize`
1354-
1355-
* {number}
1356-
1357-
The number of chunks currently buffered.
1358-
13591351
#### `share.cancel([reason])`
13601352

13611353
* `reason` {Error}
@@ -1401,8 +1393,8 @@ added: v25.9.0
14011393

14021394
* `source` {Iterable} The sync source to share.
14031395
* `options` {Object}
1404-
* `highWaterMark` {number} Must be >= 1; values below 1 are clamped
1405-
to 1. **Default:** `16`.
1396+
* `budget` {number} Must be >= 16384.
1397+
**Default:** `65536`.
14061398
* `backpressure` {string} **Default:** `'strict'`.
14071399
* Returns: {SyncShare}
14081400

@@ -1502,7 +1494,7 @@ added: v26.1.0
15021494
* `backpressure` {string} Backpressure policy. **Default:** `'strict'`.
15031495
* `'strict'` -- writes are rejected when the buffer is full. Catches
15041496
callers that ignore backpressure.
1505-
* `'block'` -- writes wait for drain when the buffer is full. Recommended
1497+
* `'unbounded'` -- writes wait for drain when the buffer is full. Recommended
15061498
for use with [`pipeTo()`][].
15071499
* `'drop-newest'` -- writes are silently discarded when the buffer is full.
15081500
* `'drop-oldest'` -- **not supported**. Throws `ERR_INVALID_ARG_VALUE`.
@@ -1535,7 +1527,7 @@ const writable = new Writable({
15351527
});
15361528

15371529
await pipeTo(from('hello world'),
1538-
fromWritable(writable, { backpressure: 'block' }));
1530+
fromWritable(writable, { backpressure: 'unbounded' }));
15391531
```
15401532

15411533
```cjs
@@ -1548,7 +1540,7 @@ async function run() {
15481540
});
15491541

15501542
await pipeTo(from('hello world'),
1551-
fromWritable(writable, { backpressure: 'block' }));
1543+
fromWritable(writable, { backpressure: 'unbounded' }));
15521544
}
15531545
run();
15541546
```
@@ -2071,6 +2063,7 @@ console.log(textSync(stream)); // 'hello world'
20712063
[`from()`]: #frominput
20722064
[`fromSync()`]: #fromsyncinput
20732065
[`node:zlib/iter`]: zlib.md#iterable-compression
2066+
[`ondrain()`]: #ondraindrainable
20742067
[`pipeTo()`]: #pipetosource-transforms-writer-options
20752068
[`pull()`]: #pullsource-transforms-options
20762069
[`pullSync()`]: #pullsyncsource-transforms-options

0 commit comments

Comments
 (0)