@peerbit/stream 4.4.0-58d3d09 → 4.4.0-780f7ce

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