Skip to content

Commit b9ed467

Browse files
committed
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 82c823e commit b9ed467

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

@@ -766,10 +768,10 @@ added:
766768
* `...transforms` {Function|Object} Optional transforms applied to the
767769
readable side.
768770
* `options` {Object}
769-
* `highWaterMark` {number} Maximum number of buffered slots before
770-
backpressure is applied. Must be >= 1; values below 1 are clamped to 1.
771-
**Default:** `4`.
772-
* `backpressure` {string} Backpressure policy: `'strict'`, `'block'`,
771+
* `budget` {number} Maximum number of buffered bytes before
772+
backpressure is applied. Must be >= 16384.
773+
**Default:** `16384`.
774+
* `backpressure` {string} Backpressure policy: `'strict'`, `'unbounded'`,
773775
`'drop-oldest'`, or `'drop-newest'`. **Default:** `'strict'`.
774776
* `signal` {AbortSignal} Abort the stream.
775777
* Returns: {Object}
@@ -829,18 +831,18 @@ added:
829831
-->
830832

831833
* `options` {Object}
832-
* `highWaterMark` {number} Buffer size for both directions.
833-
**Default:** `4`.
834+
* `budget` {number} Buffer size in bytes for both directions.
835+
**Default:** `16384`.
834836
* `backpressure` {string} Policy for both directions.
835837
**Default:** `'strict'`.
836838
* `signal` {AbortSignal} Cancellation signal for both channels.
837839
* `a` {Object} Options specific to the A-to-B direction. Overrides
838840
shared options.
839-
* `highWaterMark` {number}
841+
* `budget` {number}
840842
* `backpressure` {string}
841843
* `b` {Object} Options specific to the B-to-A direction. Overrides
842844
shared options.
843-
* `highWaterMark` {number}
845+
* `budget` {number}
844846
* `backpressure` {string}
845847
* Returns: {Array} A pair `[channelA, channelB]` of duplex channels.
846848

@@ -1079,9 +1081,10 @@ fulfills with `true` when the writer can accept more data.
10791081
```mjs
10801082
import { push, ondrain, text } from 'node:stream/iter';
10811083

1082-
const { writer, readable } = push({ highWaterMark: 2 });
1083-
writer.writeSync('a');
1084-
writer.writeSync('b');
1084+
const { writer, readable } = push({ budget: 16384 });
1085+
const chunk = new Uint8Array(8192); // 8 KB
1086+
writer.writeSync(chunk);
1087+
writer.writeSync(chunk); // 16 KB total -- buffer full
10851088

10861089
// Start consuming so the buffer can actually drain
10871090
const consuming = text(readable);
@@ -1099,9 +1102,10 @@ await consuming;
10991102
const { push, ondrain, text } = require('node:stream/iter');
11001103

11011104
async function run() {
1102-
const { writer, readable } = push({ highWaterMark: 2 });
1103-
writer.writeSync('a');
1104-
writer.writeSync('b');
1105+
const { writer, readable } = push({ budget: 16384 });
1106+
const chunk = new Uint8Array(8192); // 8 KB
1107+
writer.writeSync(chunk);
1108+
writer.writeSync(chunk); // 16 KB total -- buffer full
11051109

11061110
// Start consuming so the buffer can actually drain
11071111
const consuming = text(readable);
@@ -1214,9 +1218,9 @@ added:
12141218
-->
12151219

12161220
* `options` {Object}
1217-
* `highWaterMark` {number} Buffer size in slots. Must be >= 1; values
1218-
below 1 are clamped to 1. **Default:** `16`.
1219-
* `backpressure` {string} `'strict'`, `'block'`, `'drop-oldest'`, or
1221+
* `budget` {number} Buffer size in bytes. Must be >= 16384.
1222+
**Default:** `65536`.
1223+
* `backpressure` {string} `'strict'`, `'unbounded'`, `'drop-oldest'`, or
12201224
`'drop-newest'`. **Default:** `'strict'`.
12211225
* `signal` {AbortSignal}
12221226
* Returns: {Object}
@@ -1275,12 +1279,6 @@ async function run() {
12751279
run().catch(console.error);
12761280
```
12771281

1278-
#### `broadcast.bufferSize`
1279-
1280-
* {number}
1281-
1282-
The number of chunks currently buffered.
1283-
12841282
#### `broadcast.cancel([reason])`
12851283

12861284
* `reason` {Error}
@@ -1331,9 +1329,9 @@ added:
13311329

13321330
* `source` {AsyncIterable} The source to share.
13331331
* `options` {Object}
1334-
* `highWaterMark` {number} Buffer size. Must be >= 1; values below 1
1335-
are clamped to 1. **Default:** `16`.
1336-
* `backpressure` {string} `'strict'`, `'block'`, `'drop-oldest'`, or
1332+
* `budget` {number} Buffer size in bytes. Must be >= 16384.
1333+
**Default:** `65536`.
1334+
* `backpressure` {string} `'strict'`, `'unbounded'`, `'drop-oldest'`, or
13371335
`'drop-newest'`. **Default:** `'strict'`.
13381336
* Returns: {Share}
13391337

@@ -1373,12 +1371,6 @@ async function run() {
13731371
run().catch(console.error);
13741372
```
13751373

1376-
#### `share.bufferSize`
1377-
1378-
* {number}
1379-
1380-
The number of chunks currently buffered.
1381-
13821374
#### `share.cancel([reason])`
13831375

13841376
* `reason` {Error}
@@ -1426,8 +1418,8 @@ added:
14261418

14271419
* `source` {Iterable} The sync source to share.
14281420
* `options` {Object}
1429-
* `highWaterMark` {number} Must be >= 1; values below 1 are clamped
1430-
to 1. **Default:** `16`.
1421+
* `budget` {number} Must be >= 16384.
1422+
**Default:** `65536`.
14311423
* `backpressure` {string} **Default:** `'strict'`.
14321424
* Returns: {SyncShare}
14331425

@@ -1528,7 +1520,7 @@ added: v26.1.0
15281520
* `backpressure` {string} Backpressure policy. **Default:** `'strict'`.
15291521
* `'strict'` -- writes are rejected when the buffer is full. Catches
15301522
callers that ignore backpressure.
1531-
* `'block'` -- writes wait for drain when the buffer is full. Recommended
1523+
* `'unbounded'` -- writes wait for drain when the buffer is full. Recommended
15321524
for use with [`pipeTo()`][].
15331525
* `'drop-newest'` -- writes are silently discarded when the buffer is full.
15341526
* `'drop-oldest'` -- **not supported**. Throws `ERR_INVALID_ARG_VALUE`.
@@ -1561,7 +1553,7 @@ const writable = new Writable({
15611553
});
15621554

15631555
await pipeTo(from('hello world'),
1564-
fromWritable(writable, { backpressure: 'block' }));
1556+
fromWritable(writable, { backpressure: 'unbounded' }));
15651557
```
15661558

15671559
```cjs
@@ -1574,7 +1566,7 @@ async function run() {
15741566
});
15751567

15761568
await pipeTo(from('hello world'),
1577-
fromWritable(writable, { backpressure: 'block' }));
1569+
fromWritable(writable, { backpressure: 'unbounded' }));
15781570
}
15791571
run();
15801572
```
@@ -2097,6 +2089,7 @@ console.log(textSync(stream)); // 'hello world'
20972089
[`from()`]: #frominput
20982090
[`fromSync()`]: #fromsyncinput
20992091
[`node:zlib/iter`]: zlib.md#iterable-compression
2092+
[`ondrain()`]: #ondraindrainable
21002093
[`pipeTo()`]: #pipetosource-transforms-writer-options
21012094
[`pull()`]: #pullsource-transforms-options
21022095
[`pullSync()`]: #pullsyncsource-transforms-options

0 commit comments

Comments
 (0)