@peerbit/stream 4.4.0 → 4.4.1-4a6b62b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,81 +1,133 @@
1
+ // A multi-lane async pushable with starvation-free scheduling.
2
+ // Inspired by it-pushable (MIT) and extended for multi-lane fairness.
3
+ //
4
+ // Features:
5
+ // - Async iterator you can .push() into
6
+ // - N lanes (priorities) with starvation-free scheduling (Weighted Round-Robin)
7
+ // - Optional strict priority mode for legacy behavior
8
+ // - Optional high-water mark (bytes) with overflow policy
9
+ //
10
+ // Usage:
11
+ // const p = pushableLanes<Uint8Array>({ lanes: 2 }); // default fairness = 'wrr'
12
+ // p.push(new Uint8Array([1]), 1); // slower lane
13
+ // p.push(new Uint8Array([0]), 0); // faster lane
14
+ // for await (const chunk of p) { ... }
15
+ //
16
+ // // Backpressure example (throw if > 8MB buffered):
17
+ // const q = pushableLanes<Uint8Array>({ lanes: 3, maxBufferedBytes: 8 * 1024 * 1024, overflow: 'throw' });
18
+ //
19
+ // Notes:
20
+ // - T must have a .byteLength number property (e.g. Uint8Array).
21
+ // - Lane indices are 0..(lanes-1). Defaults to lane 0.
22
+ import { AbortError } from "@peerbit/time";
1
23
  import GenericFIFO from "fast-fifo";
2
24
  import defer from "p-defer";
3
25
 
4
- export class AbortError extends Error {
5
- type: string;
6
- code: string;
7
-
8
- constructor(message?: string, code?: string) {
9
- super(message ?? "The operation was aborted");
10
- this.type = "aborted";
11
- this.code = code ?? "ABORT_ERR";
12
- }
13
- }
14
-
15
26
  export interface AbortOptions {
16
27
  signal?: AbortSignal;
17
28
  }
18
29
 
30
+ // -----------------------------
31
+ // Public API interfaces
32
+ // -----------------------------
33
+
19
34
  /**
20
35
  * An iterable that you can push values into.
21
36
  */
22
37
  export interface PushableLanes<T, R = void, N = unknown>
23
38
  extends AsyncGenerator<T, R, N> {
24
39
  /**
25
- * End the iterable after all values in the buffer (if any) have been yielded. If an
26
- * error is passed the buffer is cleared immediately and the next iteration will
27
- * throw the passed error
40
+ * End the iterable after all values in the buffer (if any) have been yielded.
41
+ * If an error is passed, the buffer is cleared immediately and the next
42
+ * iteration will throw the passed error.
28
43
  */
29
44
  end(err?: Error): this;
30
45
 
31
46
  /**
32
- * Push a value into the iterable. Values are yielded from the iterable in the order
33
- * they are pushed. Values not yet consumed from the iterable are buffered.
47
+ * Push a value into the iterable. Values are yielded in a lane-aware order.
48
+ * Values not yet consumed are buffered. Optional `lane` is 0-based (default 0).
34
49
  */
35
50
  push(value: T, lane?: number): this;
36
51
 
37
52
  /**
38
- * Returns a promise that resolves when the underlying queue becomes empty (e.g.
39
- * this.readableLength === 0).
40
- *
41
- * If an AbortSignal is passed as an option and that signal aborts, it only
42
- * causes the returned promise to reject - it does not end the pushable.
53
+ * Resolves when the underlying buffer becomes empty (no queued data).
54
+ * If an AbortSignal is given and it aborts, only this promise rejects;
55
+ * the pushable itself is not ended.
43
56
  */
44
57
  onEmpty(options?: AbortOptions): Promise<void>;
45
58
 
46
- /**
47
- * This property contains the total number of bytes in the queue ready to be read.
48
- *
49
- */
50
-
59
+ /** Total number of bytes buffered (across all lanes). */
51
60
  get readableLength(): number;
52
61
 
53
62
  /**
54
- * Get readable length for specific lane
55
- * @param lane
56
- * @returns readable length for the lane
63
+ * Get readable length for a specific lane (bytes) or total when `lane` is undefined.
57
64
  */
58
65
  getReadableLength(lane?: number): number;
59
66
  }
60
67
 
68
+ /** How to distribute turns between lanes. */
69
+ export type FairnessMode = "priority" | "wrr";
70
+
71
+ /** What to do when buffer would exceed `maxBufferedBytes`. */
72
+ export type OverflowPolicy = "throw" | "drop-newest";
73
+
61
74
  export interface Options {
62
75
  /**
63
- * A function called after *all* values have been yielded from the iterator (including
64
- * buffered values). In the case when the iterator is ended with an error it will be
65
- * passed the error as a parameter.
76
+ * Called after *all* values have been yielded from the iterator (including buffered values).
77
+ * If the iterator is ended with an error it will receive the error.
66
78
  */
67
79
  onEnd?(err?: Error): void;
68
80
 
69
81
  /**
70
- * How many lanes, lane 0 is fastest and will drain before lane 1 is consumed
82
+ * Number of lanes. Lane 0 is the "fastest"/most preferred lane.
83
+ * Default: 1
71
84
  */
72
85
  lanes?: number;
86
+
73
87
  /**
74
- * Optional hook invoked on every successful push with the value and lane
88
+ * Optional hook invoked on every successful push with the value and lane.
89
+ * Useful for metrics/telemetry.
75
90
  */
76
91
  onPush?(value: { byteLength: number }, lane: number): void;
92
+
93
+ /**
94
+ * Fairness mode:
95
+ * - 'priority': strict priority (original behavior).
96
+ * - 'wrr': weighted round-robin (starvation-free).
97
+ * Default: 'wrr'
98
+ */
99
+ fairness?: FairnessMode;
100
+
101
+ /**
102
+ * Weights per lane if fairness === 'wrr'. Larger weight = more service.
103
+ * Must have length === lanes and each weight >= 1.
104
+ * If omitted, weights are auto-generated from `bias`.
105
+ */
106
+ weights?: number[];
107
+
108
+ /**
109
+ * Bias factor for auto-generated weights when fairness === 'wrr'.
110
+ * For lanes L, weight[i] = floor(bias^(L-1-i)) with a minimum of 1.
111
+ * Default: 2 (e.g., lanes=4 -> [8,4,2,1])
112
+ */
113
+ bias?: number;
114
+
115
+ /**
116
+ * Optional high-water mark in **bytes** across all lanes.
117
+ * If a `push` would exceed this many buffered bytes:
118
+ * - overflow: 'throw' -> throw an Error (default policy)
119
+ * - overflow: 'drop-newest' -> silently drop this pushed item
120
+ */
121
+ maxBufferedBytes?: number;
122
+
123
+ /** Overflow policy when `maxBufferedBytes` would be exceeded. Default: 'throw' */
124
+ overflow?: OverflowPolicy;
77
125
  }
78
126
 
127
+ // -----------------------------
128
+ // Internal queue primitives
129
+ // -----------------------------
130
+
79
131
  export interface DoneResult {
80
132
  done: true;
81
133
  }
@@ -92,94 +144,185 @@ export interface Next<T> {
92
144
  }
93
145
 
94
146
  /**
95
- * Fifo but with total readableLength counter
147
+ * FIFO that tracks the total readable bytes (`.size`) of queued values.
96
148
  */
97
- class Uint8ArrayFifo<T extends { byteLength: number }> extends GenericFIFO<
98
- Next<T>
99
- > {
100
- size: number = 0;
149
+ class ByteFifo<T extends { byteLength: number }> extends GenericFIFO<Next<T>> {
150
+ size = 0;
151
+
101
152
  push(val: Next<T>): void {
102
- if (val.value) {
103
- this.size += val.value.byteLength;
104
- }
153
+ if (val.value) this.size += val.value.byteLength;
105
154
  return super.push(val);
106
155
  }
107
156
 
108
157
  shift(): Next<T> | undefined {
109
158
  const shifted = super.shift();
110
- if (shifted?.value) {
111
- this.size -= shifted.value.byteLength;
112
- }
159
+ if (shifted?.value) this.size -= shifted.value.byteLength;
113
160
  return shifted;
114
161
  }
115
162
  }
116
163
 
117
164
  /**
118
- * A queue consisting of multiple 'lanes' with different priority to be emptied.
119
- * The lane with index 0 will empty before lane with index 1 etc..
120
- * TODO add an additional proprty to control whether we we pick objects from slower lanes
121
- * so no lane get really "stuck"
165
+ * A multi-lane queue with configurable fairness.
166
+ * - 'priority': probe lanes in order 0..L-1 each shift.
167
+ * - 'wrr': service lanes according to weights in a repeating schedule.
122
168
  */
123
- class Uint8arrayPriorityQueue<T extends { byteLength: number }> {
124
- lanes: Uint8ArrayFifo<T>[];
125
- constructor(options: { lanes: number } = { lanes: 1 }) {
126
- this.lanes = new Array(options.lanes);
127
- for (let i = 0; i < this.lanes.length; i++) {
128
- this.lanes[i] = new Uint8ArrayFifo();
169
+ class LaneQueue<T extends { byteLength: number }> {
170
+ public readonly lanes: ByteFifo<T>[];
171
+ private readonly mode: FairnessMode;
172
+ private readonly schedule: number[]; // WRR: repeated lane indices per weight
173
+ private cursor = 0;
174
+
175
+ constructor(init: {
176
+ lanes: number;
177
+ fairness?: FairnessMode;
178
+ weights?: number[];
179
+ bias?: number;
180
+ }) {
181
+ const L = Math.max(1, init.lanes | 0);
182
+ this.mode = init.fairness ?? "wrr";
183
+ this.lanes = Array.from({ length: L }, () => new ByteFifo<T>());
184
+
185
+ if (this.mode === "wrr") {
186
+ const bias = init.bias ?? 2;
187
+ const auto = Array.from({ length: L }, (_, i) =>
188
+ Math.max(1, Math.floor(Math.pow(bias, L - 1 - i))),
189
+ );
190
+ const w = normalizeWeights(init.weights ?? auto, L);
191
+
192
+ // Build a simple round-robin schedule by repeating lanes according to weight.
193
+ this.schedule = [];
194
+ for (let i = 0; i < L; i++) {
195
+ for (let k = 0; k < w[i]; k++) this.schedule.push(i);
196
+ }
197
+ // Edge case: if all weights collapsed to zero (shouldn't), fall back to priority.
198
+ if (this.schedule.length === 0) {
199
+ this.schedule = Array.from({ length: L }, (_, i) => i);
200
+ }
201
+ } else {
202
+ // strict priority
203
+ this.schedule = Array.from({ length: L }, (_, i) => i);
129
204
  }
130
205
  }
131
206
 
132
- get size() {
207
+ get size(): number {
133
208
  let sum = 0;
134
- for (const lane of this.lanes) {
135
- sum += lane.size;
136
- }
209
+ for (const lane of this.lanes) sum += lane.size;
137
210
  return sum;
138
211
  }
139
- push(val: Next<T>, lane: number) {
140
- return this.lanes[lane].push(val);
212
+
213
+ /** Enqueue a value into a specific lane. */
214
+ push(val: Next<T>, lane: number): void {
215
+ const idx = clampLane(lane, this.lanes.length);
216
+ this.lanes[idx].push(val);
217
+ }
218
+
219
+ /** True if all lanes are empty. */
220
+ isEmpty(): boolean {
221
+ for (const lane of this.lanes) if (!lane.isEmpty()) return false;
222
+ return true;
141
223
  }
224
+
225
+ /**
226
+ * Dequeue the next value by fairness rules.
227
+ * Ensures progress even if some schedule slots map to empty lanes.
228
+ */
142
229
  shift(): Next<T> | undefined {
143
- // fetch the first non undefined item.
144
- // by iterating from index 0 up we define that lanes with lower index have higher prioirity
145
- for (const lane of this.lanes) {
146
- const element = lane.shift();
147
- if (element) {
148
- return element;
230
+ if (this.isEmpty()) return undefined;
231
+
232
+ if (this.mode === "priority") {
233
+ // strict priority: always scan from lane 0
234
+ for (let i = 0; i < this.lanes.length; i++) {
235
+ const item = this.lanes[i].shift();
236
+ if (item) return item;
149
237
  }
238
+ return undefined;
239
+ }
240
+
241
+ // WRR mode: use rotating schedule
242
+ const L = this.schedule.length;
243
+ for (let probes = 0; probes < L; probes++) {
244
+ const laneIdx = this.schedule[this.cursor];
245
+ this.cursor = (this.cursor + 1) % L;
246
+
247
+ const item = this.lanes[laneIdx].shift();
248
+ if (item) return item;
249
+ }
250
+
251
+ // (very unlikely) nothing was found despite size>0 – linear scan fallback
252
+ for (let i = 0; i < this.lanes.length; i++) {
253
+ const item = this.lanes[i].shift();
254
+ if (item) return item;
150
255
  }
151
256
  return undefined;
152
257
  }
153
- isEmpty(): boolean {
154
- for (const lane of this.lanes) {
155
- if (!lane.isEmpty()) {
156
- return false;
157
- }
158
- }
159
- return true;
258
+ }
259
+
260
+ function normalizeWeights(weights: number[], lanes: number): number[] {
261
+ if (weights.length !== lanes) {
262
+ throw new Error(
263
+ `weights length (${weights.length}) must equal lanes (${lanes})`,
264
+ );
160
265
  }
266
+ const w = weights.map((x) => (x && x > 0 ? Math.floor(x) : 0));
267
+ if (w.every((x) => x === 0)) {
268
+ // ensure at least 1 for all lanes to retain progress guarantees
269
+ return Array.from({ length: lanes }, () => 1);
270
+ }
271
+ return w;
272
+ }
273
+
274
+ function clampLane(lane: number, lanes: number): number {
275
+ if (!Number.isFinite(lane)) return 0;
276
+ lane = lane | 0;
277
+ if (lane < 0) return 0;
278
+ if (lane >= lanes) return lanes - 1;
279
+ return lane;
161
280
  }
162
281
 
282
+ // -----------------------------
283
+ // Factory
284
+ // -----------------------------
285
+
163
286
  export function pushableLanes<T extends { byteLength: number } = Uint8Array>(
164
287
  options: Options = {},
165
288
  ): PushableLanes<T> {
166
289
  return _pushable<Uint8Array, T, PushableLanes<T>>(options);
167
290
  }
168
291
 
169
- // Modified from https://github.com/alanshaw/it-pushable
292
+ // -----------------------------
293
+ // Core implementation
294
+ // -----------------------------
295
+ // Based on it-pushable, adapted to multi-lane buffered queues with fairness.
296
+ // Important invariants:
297
+ // - We resolve the internal "drain" promise whenever the buffer *becomes empty*.
298
+ // - After end(err), the iterator finishes; push() becomes a no-op.
299
+
170
300
  function _pushable<PushType extends Uint8Array, ValueType, ReturnType>(
171
301
  options?: Options,
172
302
  ): ReturnType {
173
303
  options = options ?? {};
174
304
  let onEnd = options.onEnd;
175
- let buffer: Uint8arrayPriorityQueue<PushType> | Uint8ArrayFifo<PushType> =
176
- new Uint8arrayPriorityQueue<PushType>(
177
- options.lanes ? { lanes: options.lanes } : undefined,
178
- );
305
+
306
+ // Main buffer: multi-lane with fairness
307
+ let buffer: LaneQueue<PushType> | ByteFifo<PushType> =
308
+ new LaneQueue<PushType>({
309
+ lanes: options.lanes ?? 1,
310
+ fairness: options.fairness ?? "wrr",
311
+ weights: options.weights,
312
+ bias: options.bias ?? 2,
313
+ });
314
+
315
+ // After end(err) we may swap buffer to a simple ByteFifo to deliver the terminal signal/error.
316
+ const isLaneQueue = (buffer: any): buffer is LaneQueue<PushType> =>
317
+ buffer instanceof LaneQueue;
318
+
179
319
  let pushable: any;
180
320
  let onNext: ((next: Next<PushType>, lane: number) => ReturnType) | null;
181
- let ended: boolean;
182
- let drain = defer();
321
+ let ended = false;
322
+ let drain = defer<void>();
323
+
324
+ const maxBytes = options.maxBufferedBytes;
325
+ const overflow: OverflowPolicy = options.overflow ?? "throw";
183
326
 
184
327
  const getNext = (): NextResult<ValueType> => {
185
328
  const next: Next<PushType> | undefined = buffer.shift();
@@ -187,11 +330,9 @@ function _pushable<PushType extends Uint8Array, ValueType, ReturnType>(
187
330
  if (next == null) {
188
331
  return { done: true };
189
332
  }
190
-
191
333
  if (next.error != null) {
192
334
  throw next.error;
193
335
  }
194
-
195
336
  return {
196
337
  done: next.done === true,
197
338
  // @ts-expect-error if done is false, value will be present
@@ -204,7 +345,6 @@ function _pushable<PushType extends Uint8Array, ValueType, ReturnType>(
204
345
  if (!buffer.isEmpty()) {
205
346
  return getNext();
206
347
  }
207
-
208
348
  if (ended) {
209
349
  return { done: true };
210
350
  }
@@ -213,23 +353,20 @@ function _pushable<PushType extends Uint8Array, ValueType, ReturnType>(
213
353
  onNext = (next: Next<PushType>, lane: number) => {
214
354
  onNext = null;
215
355
  buffer.push(next, lane);
216
-
217
356
  try {
218
357
  resolve(getNext());
219
358
  } catch (err: any) {
220
359
  reject(err);
221
360
  }
222
-
223
361
  return pushable;
224
362
  };
225
363
  });
226
364
  } finally {
365
+ // If buffer is empty after this turn, resolve the drain promise (in a microtask)
227
366
  if (buffer.isEmpty()) {
228
- // settle promise in the microtask queue to give consumers a chance to
229
- // await after calling .push
230
367
  queueMicrotask(() => {
231
368
  drain.resolve();
232
- drain = defer();
369
+ drain = defer<void>();
233
370
  });
234
371
  }
235
372
  }
@@ -239,46 +376,69 @@ function _pushable<PushType extends Uint8Array, ValueType, ReturnType>(
239
376
  if (onNext != null) {
240
377
  return onNext(next, lane);
241
378
  }
242
-
243
379
  buffer.push(next, lane);
244
380
  return pushable;
245
381
  };
246
382
 
247
383
  const bufferError = (err: Error): ReturnType => {
248
- buffer = new Uint8ArrayFifo();
249
-
384
+ // swap to ByteFifo to deliver a single terminal error
385
+ buffer = new ByteFifo<PushType>();
250
386
  if (onNext != null) {
251
387
  return onNext({ error: err }, 0);
252
388
  }
253
-
254
389
  buffer.push({ error: err });
255
390
  return pushable;
256
391
  };
257
392
 
393
+ const totalBufferedBytes = (): number => {
394
+ if (isLaneQueue(buffer)) return buffer.size;
395
+ return (buffer as ByteFifo<PushType>).size;
396
+ };
397
+
258
398
  const push = (value: PushType, lane: number = 0): ReturnType => {
259
399
  if (ended) {
400
+ // Ignore pushes after end() for safety (consistent with it-pushable).
260
401
  return pushable;
261
402
  }
262
403
 
404
+ // Simple backpressure: enforce HWM if configured
405
+ if (maxBytes != null && maxBytes > 0) {
406
+ const wouldBe = totalBufferedBytes() + value.byteLength;
407
+ if (wouldBe > maxBytes) {
408
+ if (overflow === "drop-newest") {
409
+ // silently drop this item
410
+ return pushable;
411
+ }
412
+ // default 'throw'
413
+ throw new Error(
414
+ `pushableLanes buffer overflow: ${wouldBe} bytes > maxBufferedBytes=${maxBytes}`,
415
+ );
416
+ }
417
+ }
418
+
263
419
  const out = bufferNext({ done: false, value }, lane);
264
- options?.onPush?.(value, lane);
420
+ options?.onPush?.(
421
+ value,
422
+ clampLane(lane, isLaneQueue(buffer) ? buffer.lanes.length : 1),
423
+ );
265
424
  return out;
266
425
  };
426
+
267
427
  const end = (err?: Error): ReturnType => {
268
428
  if (ended) return pushable;
269
429
  ended = true;
270
-
271
430
  return err != null ? bufferError(err) : bufferNext({ done: true }, 0);
272
431
  };
432
+
273
433
  const _return = (): DoneResult => {
274
- buffer = new Uint8ArrayFifo();
434
+ // Ensure prompt termination
435
+ buffer = new ByteFifo<PushType>();
275
436
  end();
276
-
277
437
  return { done: true };
278
438
  };
439
+
279
440
  const _throw = (err: Error): DoneResult => {
280
441
  end(err);
281
-
282
442
  return { done: true };
283
443
  };
284
444
 
@@ -291,45 +451,41 @@ function _pushable<PushType extends Uint8Array, ValueType, ReturnType>(
291
451
  throw: _throw,
292
452
  push,
293
453
  end,
454
+
294
455
  get readableLength(): number {
295
- return buffer.size;
456
+ return totalBufferedBytes();
296
457
  },
297
458
 
298
459
  getReadableLength(lane?: number): number {
299
- if (lane == null) {
300
- return buffer.size;
460
+ if (lane == null) return totalBufferedBytes();
461
+ if (isLaneQueue(buffer)) {
462
+ const idx = clampLane(lane, buffer.lanes.length);
463
+ return buffer.lanes[idx].size;
301
464
  }
302
-
303
- if (buffer instanceof Uint8arrayPriorityQueue) {
304
- return buffer.lanes[lane].size;
305
- }
306
- return buffer.size; // we can only arrive here if we are "done" or "err" or "end" where we reasign the buffer to a simple one and put 1 message into it
465
+ // After end/error we swap to a ByteFifo: only "total" makes sense.
466
+ return (buffer as ByteFifo<PushType>).size;
307
467
  },
308
- onEmpty: async (options?: AbortOptions) => {
309
- const signal = options?.signal;
310
- signal?.throwIfAborted();
311
468
 
312
- if (buffer.isEmpty()) {
313
- return;
314
- }
469
+ onEmpty: async (opts?: AbortOptions) => {
470
+ const signal = opts?.signal;
471
+ signal?.throwIfAborted?.();
472
+
473
+ if (buffer.isEmpty()) return;
315
474
 
316
475
  let cancel: Promise<void> | undefined;
317
476
  let listener: (() => void) | undefined;
318
477
 
319
478
  if (signal != null) {
320
- cancel = new Promise((resolve, reject) => {
321
- listener = () => {
322
- reject(new AbortError());
323
- };
324
-
325
- signal.addEventListener("abort", listener);
479
+ cancel = new Promise<void>((_resolve, reject) => {
480
+ listener = () => reject(new AbortError());
481
+ signal.addEventListener("abort", listener!);
326
482
  });
327
483
  }
328
484
 
329
485
  try {
330
486
  await Promise.race([drain.promise, cancel]);
331
487
  } finally {
332
- if (listener != null && signal != null) {
488
+ if (listener != null) {
333
489
  signal?.removeEventListener("abort", listener);
334
490
  }
335
491
  }
@@ -340,6 +496,7 @@ function _pushable<PushType extends Uint8Array, ValueType, ReturnType>(
340
496
  return pushable;
341
497
  }
342
498
 
499
+ // Wrap with onEnd notifier
343
500
  const _pushable = pushable;
344
501
 
345
502
  pushable = {
@@ -351,39 +508,36 @@ function _pushable<PushType extends Uint8Array, ValueType, ReturnType>(
351
508
  },
352
509
  throw(err: Error) {
353
510
  _pushable.throw(err);
354
-
355
511
  if (onEnd != null) {
356
512
  onEnd(err);
357
513
  onEnd = undefined;
358
514
  }
359
-
360
515
  return { done: true };
361
516
  },
362
517
  return() {
363
518
  _pushable.return();
364
-
365
519
  if (onEnd != null) {
366
520
  onEnd();
367
521
  onEnd = undefined;
368
522
  }
369
-
370
523
  return { done: true };
371
524
  },
372
525
  push,
373
- end(err: Error) {
526
+ end(err?: Error) {
374
527
  _pushable.end(err);
375
-
376
528
  if (onEnd != null) {
377
529
  onEnd(err);
378
530
  onEnd = undefined;
379
531
  }
380
-
381
532
  return pushable;
382
533
  },
383
534
  get readableLength() {
384
535
  return _pushable.readableLength;
385
536
  },
386
- onEmpty: (opts?: AbortOptions) => {
537
+ getReadableLength(lane?: number) {
538
+ return _pushable.getReadableLength(lane);
539
+ },
540
+ onEmpty(opts?: AbortOptions) {
387
541
  return _pushable.onEmpty(opts);
388
542
  },
389
543
  };