@mux-magic/tools 0.1.2 → 1.1.0

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.
@@ -0,0 +1,673 @@
1
+ import { defer, EMPTY, Observable, of, Subject } from "rxjs"
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ test,
8
+ } from "vitest"
9
+
10
+ import {
11
+ __resetTaskSchedulerForTests,
12
+ initTaskScheduler,
13
+ mergeMapOrdered,
14
+ registerJobClaim,
15
+ runTask,
16
+ runTasks,
17
+ runTasksOrdered,
18
+ unregisterJobClaim,
19
+ } from "./taskScheduler.js"
20
+
21
+ beforeEach(() => {
22
+ // vitest.setup.ts initializes the scheduler at Infinity for the global
23
+ // suite. These tests need explicit per-case concurrency, so reset and
24
+ // re-init inside each test.
25
+ __resetTaskSchedulerForTests()
26
+ })
27
+
28
+ afterEach(() => {
29
+ __resetTaskSchedulerForTests()
30
+ initTaskScheduler(Infinity)
31
+ })
32
+
33
+ describe(runTask.name, () => {
34
+ test("forwards values from the wrapped observable", async () => {
35
+ initTaskScheduler(2)
36
+
37
+ const collected: number[] = []
38
+
39
+ await new Promise<void>((resolve, reject) => {
40
+ runTask(of(1, 2, 3)).subscribe({
41
+ next: (value) => collected.push(value),
42
+ error: reject,
43
+ complete: () => resolve(),
44
+ })
45
+ })
46
+
47
+ expect(collected).toEqual([1, 2, 3])
48
+ })
49
+
50
+ test("caps concurrent in-flight Tasks at the configured limit", () => {
51
+ initTaskScheduler(2)
52
+
53
+ let runningCount = 0
54
+ let peakRunningCount = 0
55
+ const completers: (() => void)[] = []
56
+
57
+ const makeWork = () =>
58
+ new Observable<void>((subscriber) => {
59
+ runningCount += 1
60
+ peakRunningCount = Math.max(
61
+ peakRunningCount,
62
+ runningCount,
63
+ )
64
+
65
+ completers.push(() => {
66
+ runningCount -= 1
67
+ subscriber.complete()
68
+ })
69
+ })
70
+
71
+ let completedCount = 0
72
+
73
+ Array.from({ length: 4 }).forEach(() => {
74
+ runTask(makeWork()).subscribe({
75
+ complete: () => {
76
+ completedCount += 1
77
+ },
78
+ })
79
+ })
80
+
81
+ // Two slots, four submissions: only the first two are running.
82
+ expect(runningCount).toBe(2)
83
+ expect(completers.length).toBe(2)
84
+
85
+ // As each running Task completes, the next queued Task picks up its
86
+ // slot synchronously — runningCount stays at 2 until the queue
87
+ // empties, and completers.length stays at 2 until the last two run.
88
+ completers.shift()?.()
89
+ expect(runningCount).toBe(2)
90
+ expect(completers.length).toBe(2)
91
+
92
+ completers.shift()?.()
93
+ expect(runningCount).toBe(2)
94
+ expect(completers.length).toBe(2)
95
+
96
+ completers.shift()?.()
97
+ expect(runningCount).toBe(1)
98
+ expect(completers.length).toBe(1)
99
+
100
+ completers.shift()?.()
101
+ expect(runningCount).toBe(0)
102
+ expect(completers.length).toBe(0)
103
+
104
+ expect(peakRunningCount).toBe(2)
105
+ expect(completedCount).toBe(4)
106
+ })
107
+
108
+ test("does not start work for a queued Task that's unsubscribed before its slot opens", () => {
109
+ initTaskScheduler(1)
110
+
111
+ let hasFirstStarted = false
112
+ let hasSecondStarted = false
113
+ let hasThirdStarted = false
114
+ let firstCompleter: (() => void) | null = null
115
+
116
+ const firstSubscription = runTask(
117
+ new Observable<void>((subscriber) => {
118
+ hasFirstStarted = true
119
+ firstCompleter = () => subscriber.complete()
120
+ }),
121
+ ).subscribe()
122
+
123
+ // Second is queued behind the first.
124
+ const secondSubscription = runTask(
125
+ defer(() => {
126
+ hasSecondStarted = true
127
+ return of(undefined)
128
+ }),
129
+ ).subscribe()
130
+
131
+ // Third is queued behind the second.
132
+ runTask(
133
+ defer(() => {
134
+ hasThirdStarted = true
135
+ return of(undefined)
136
+ }),
137
+ ).subscribe()
138
+
139
+ expect(hasFirstStarted).toBe(true)
140
+ expect(hasSecondStarted).toBe(false)
141
+ expect(hasThirdStarted).toBe(false)
142
+
143
+ // Cancel the queued second BEFORE its slot opens.
144
+ secondSubscription.unsubscribe()
145
+
146
+ // First completes → slot frees. Second was cancelled, so the third
147
+ // should run instead — and the second's defer must NOT fire.
148
+ // TypeScript 6 CFA narrows firstCompleter to null (sees only initial
149
+ // value; can't prove Observable callback ran synchronously). The cast
150
+ // bypasses that — the callback always runs sync on subscribe().
151
+ ;(firstCompleter as (() => void) | null)?.()
152
+
153
+ expect(hasSecondStarted).toBe(false)
154
+ expect(hasThirdStarted).toBe(true)
155
+
156
+ firstSubscription.unsubscribe()
157
+ })
158
+
159
+ test("releases its slot when an in-flight Task is cancelled", () => {
160
+ initTaskScheduler(1)
161
+
162
+ let firstSubscribeCount = 0
163
+ let firstUnsubscribeCount = 0
164
+ let hasSecondStarted = false
165
+
166
+ const firstSubscription = runTask(
167
+ new Observable<void>(() => {
168
+ firstSubscribeCount += 1
169
+ return () => {
170
+ firstUnsubscribeCount += 1
171
+ }
172
+ }),
173
+ ).subscribe()
174
+
175
+ runTask(
176
+ defer(() => {
177
+ hasSecondStarted = true
178
+ return of(undefined)
179
+ }),
180
+ ).subscribe()
181
+
182
+ expect(firstSubscribeCount).toBe(1)
183
+ expect(hasSecondStarted).toBe(false)
184
+
185
+ // Cancel the in-flight first Task → slot frees → second runs.
186
+ firstSubscription.unsubscribe()
187
+
188
+ expect(firstUnsubscribeCount).toBe(1)
189
+ expect(hasSecondStarted).toBe(true)
190
+ })
191
+
192
+ test("propagates errors from the wrapped observable to the caller", async () => {
193
+ initTaskScheduler(1)
194
+
195
+ const error = new Error("boom")
196
+
197
+ const result = await new Promise<unknown>((resolve) => {
198
+ runTask(
199
+ new Observable<never>((subscriber) => {
200
+ subscriber.error(error)
201
+ }),
202
+ ).subscribe({
203
+ next: () => resolve("unexpected next"),
204
+ error: (caughtError) => resolve(caughtError),
205
+ complete: () => resolve("unexpected complete"),
206
+ })
207
+ })
208
+
209
+ expect(result).toBe(error)
210
+
211
+ // Slot must release on error too — verify by running another Task.
212
+ let hasNextStarted = false
213
+ runTask(
214
+ defer(() => {
215
+ hasNextStarted = true
216
+ return of(undefined)
217
+ }),
218
+ ).subscribe()
219
+
220
+ expect(hasNextStarted).toBe(true)
221
+ })
222
+ })
223
+
224
+ describe(runTasks.name, () => {
225
+ test("schedules each upstream emission as a Task and forwards values", async () => {
226
+ initTaskScheduler(Infinity)
227
+
228
+ const collected = await new Promise<number[]>(
229
+ (resolve, reject) => {
230
+ const results: number[] = []
231
+
232
+ of(1, 2, 3)
233
+ .pipe(runTasks((value) => of(value * 10)))
234
+ .subscribe({
235
+ next: (value) => results.push(value),
236
+ error: reject,
237
+ complete: () => resolve(results),
238
+ })
239
+ },
240
+ )
241
+
242
+ expect(collected.sort()).toEqual([10, 20, 30])
243
+ })
244
+ })
245
+
246
+ describe(runTasksOrdered.name, () => {
247
+ test("emits results in input-index order even when later Tasks finish first", () => {
248
+ initTaskScheduler(Infinity)
249
+
250
+ const collected: string[] = []
251
+ const completers = new Map<number, () => void>()
252
+
253
+ const work = (value: string, index: number) =>
254
+ new Observable<string>((subscriber) => {
255
+ completers.set(index, () => {
256
+ subscriber.next(`done-${value}`)
257
+ subscriber.complete()
258
+ })
259
+ })
260
+
261
+ of("a", "b", "c", "d")
262
+ .pipe(runTasksOrdered(work))
263
+ .subscribe({
264
+ next: (value) => {
265
+ collected.push(value)
266
+ },
267
+ })
268
+
269
+ // All four Tasks are running (Infinity concurrency).
270
+ expect(completers.size).toBe(4)
271
+ // Nothing has emitted yet — no Task completed.
272
+ expect(collected).toEqual([])
273
+
274
+ // Complete in reverse order: d, c, b, a.
275
+ completers.get(3)?.()
276
+ expect(collected).toEqual([])
277
+ completers.get(2)?.()
278
+ expect(collected).toEqual([])
279
+ completers.get(1)?.()
280
+ expect(collected).toEqual([])
281
+ // Now the head-of-queue completes — all four flush at once in input order.
282
+ completers.get(0)?.()
283
+ expect(collected).toEqual([
284
+ "done-a",
285
+ "done-b",
286
+ "done-c",
287
+ "done-d",
288
+ ])
289
+ })
290
+
291
+ test("releases each result as soon as the head-of-queue completes", () => {
292
+ initTaskScheduler(Infinity)
293
+
294
+ const collected: string[] = []
295
+ const completers = new Map<number, () => void>()
296
+
297
+ const work = (value: string, index: number) =>
298
+ new Observable<string>((subscriber) => {
299
+ completers.set(index, () => {
300
+ subscriber.next(`done-${value}`)
301
+ subscriber.complete()
302
+ })
303
+ })
304
+
305
+ of("a", "b", "c")
306
+ .pipe(runTasksOrdered(work))
307
+ .subscribe({
308
+ next: (value) => {
309
+ collected.push(value)
310
+ },
311
+ })
312
+
313
+ // Complete head first → emits immediately.
314
+ completers.get(0)?.()
315
+ expect(collected).toEqual(["done-a"])
316
+
317
+ // Skip 1, complete 2 → must wait for 1.
318
+ completers.get(2)?.()
319
+ expect(collected).toEqual(["done-a"])
320
+
321
+ // Complete 1 → both 1 and 2 flush.
322
+ completers.get(1)?.()
323
+ expect(collected).toEqual([
324
+ "done-a",
325
+ "done-b",
326
+ "done-c",
327
+ ])
328
+ })
329
+
330
+ test("preserves multi-emission order within a Task and across Tasks", () => {
331
+ initTaskScheduler(Infinity)
332
+
333
+ const collected: string[] = []
334
+
335
+ of("a", "b")
336
+ .pipe(
337
+ runTasksOrdered((value) =>
338
+ of(`${value}-1`, `${value}-2`),
339
+ ),
340
+ )
341
+ .subscribe({
342
+ next: (collected_value) => {
343
+ collected.push(collected_value)
344
+ },
345
+ })
346
+
347
+ expect(collected).toEqual(["a-1", "a-2", "b-1", "b-2"])
348
+ })
349
+
350
+ test("propagates errors from upstream", () => {
351
+ initTaskScheduler(Infinity)
352
+
353
+ const errored = new Subject<void>()
354
+ let caughtError: unknown = null
355
+
356
+ const upstream = new Subject<number>()
357
+ upstream
358
+ .pipe(runTasksOrdered((value) => of(value * 10)))
359
+ .subscribe({
360
+ error: (error) => {
361
+ caughtError = error
362
+ errored.next()
363
+ },
364
+ })
365
+
366
+ upstream.error(new Error("upstream boom"))
367
+
368
+ expect((caughtError as Error).message).toBe(
369
+ "upstream boom",
370
+ )
371
+ })
372
+
373
+ test("propagates errors from a Task", () => {
374
+ initTaskScheduler(Infinity)
375
+
376
+ let caughtError: unknown = null
377
+
378
+ of(1, 2, 3)
379
+ .pipe(
380
+ runTasksOrdered((value) =>
381
+ value === 2
382
+ ? new Observable<number>((subscriber) => {
383
+ subscriber.error(new Error("task-2 failed"))
384
+ })
385
+ : of(value),
386
+ ),
387
+ )
388
+ .subscribe({
389
+ error: (error) => {
390
+ caughtError = error
391
+ },
392
+ })
393
+
394
+ expect((caughtError as Error).message).toBe(
395
+ "task-2 failed",
396
+ )
397
+ })
398
+
399
+ test("completes immediately on empty upstream", () => {
400
+ initTaskScheduler(Infinity)
401
+
402
+ let isComplete = false
403
+
404
+ EMPTY.pipe(
405
+ runTasksOrdered((value: number) => of(value)),
406
+ ).subscribe({
407
+ complete: () => {
408
+ isComplete = true
409
+ },
410
+ })
411
+
412
+ expect(isComplete).toBe(true)
413
+ })
414
+
415
+ test("unsubscribe tears down in-flight Tasks", () => {
416
+ initTaskScheduler(Infinity)
417
+
418
+ let workSubscribeCount = 0
419
+ let workUnsubscribeCount = 0
420
+
421
+ const subscription = of(1, 2, 3)
422
+ .pipe(
423
+ runTasksOrdered(
424
+ () =>
425
+ new Observable(() => {
426
+ workSubscribeCount += 1
427
+ return () => {
428
+ workUnsubscribeCount += 1
429
+ }
430
+ }),
431
+ ),
432
+ )
433
+ .subscribe()
434
+
435
+ expect(workSubscribeCount).toBe(3)
436
+
437
+ subscription.unsubscribe()
438
+
439
+ expect(workUnsubscribeCount).toBe(3)
440
+ })
441
+ })
442
+
443
+ describe(mergeMapOrdered.name, () => {
444
+ test("orders results without involving the Task scheduler", () => {
445
+ // No initTaskScheduler call — proves the operator works when the
446
+ // scheduler isn't initialized at all (it shouldn't reach runTask).
447
+ __resetTaskSchedulerForTests()
448
+
449
+ const collected: string[] = []
450
+ const completers = new Map<number, () => void>()
451
+
452
+ const work = (value: string, index: number) =>
453
+ new Observable<string>((subscriber) => {
454
+ completers.set(index, () => {
455
+ subscriber.next(`done-${value}`)
456
+ subscriber.complete()
457
+ })
458
+ })
459
+
460
+ of("a", "b", "c")
461
+ .pipe(mergeMapOrdered(work))
462
+ .subscribe({
463
+ next: (value) => {
464
+ collected.push(value)
465
+ },
466
+ })
467
+
468
+ expect(completers.size).toBe(3)
469
+
470
+ completers.get(2)?.()
471
+ completers.get(1)?.()
472
+ expect(collected).toEqual([])
473
+
474
+ completers.get(0)?.()
475
+ expect(collected).toEqual([
476
+ "done-a",
477
+ "done-b",
478
+ "done-c",
479
+ ])
480
+ })
481
+ })
482
+
483
+ describe("per-job quota — runTask with explicit jobId", () => {
484
+ const makeWork = (
485
+ runningRef: { count: number },
486
+ completers: Array<() => void>,
487
+ ) =>
488
+ new Observable<void>((subscriber) => {
489
+ runningRef.count += 1
490
+ completers.push(() => {
491
+ runningRef.count -= 1
492
+ subscriber.complete()
493
+ })
494
+ })
495
+
496
+ test("single job: per-job cap of 1 serialises tasks even when global pool has room", () => {
497
+ initTaskScheduler(8)
498
+ registerJobClaim("job-a", 1)
499
+
500
+ const runningA = { count: 0 }
501
+ const completersA: Array<() => void> = []
502
+
503
+ runTask(
504
+ makeWork(runningA, completersA),
505
+ "job-a",
506
+ ).subscribe()
507
+ runTask(
508
+ makeWork(runningA, completersA),
509
+ "job-a",
510
+ ).subscribe()
511
+
512
+ expect(runningA.count).toBe(1)
513
+ expect(completersA.length).toBe(1)
514
+
515
+ completersA[0]()
516
+ expect(runningA.count).toBe(1)
517
+ expect(completersA.length).toBe(2)
518
+
519
+ completersA[1]()
520
+ expect(runningA.count).toBe(0)
521
+
522
+ unregisterJobClaim("job-a")
523
+ })
524
+
525
+ test("per-job cap does not block tasks from a different job", () => {
526
+ initTaskScheduler(8)
527
+ registerJobClaim("job-a", 1)
528
+ registerJobClaim("job-b", 4)
529
+
530
+ const runningA = { count: 0 }
531
+ const runningB = { count: 0 }
532
+ const completersA: Array<() => void> = []
533
+ const completersB: Array<() => void> = []
534
+
535
+ runTask(
536
+ makeWork(runningA, completersA),
537
+ "job-a",
538
+ ).subscribe()
539
+ runTask(
540
+ makeWork(runningA, completersA),
541
+ "job-a",
542
+ ).subscribe()
543
+ runTask(
544
+ makeWork(runningB, completersB),
545
+ "job-b",
546
+ ).subscribe()
547
+ runTask(
548
+ makeWork(runningB, completersB),
549
+ "job-b",
550
+ ).subscribe()
551
+
552
+ // job-a capped at 1; job-b can run 2 (both fit in global pool)
553
+ expect(runningA.count).toBe(1)
554
+ expect(runningB.count).toBe(2)
555
+
556
+ completersA.forEach((completer) => {
557
+ completer()
558
+ })
559
+ completersB.forEach((completer) => {
560
+ completer()
561
+ })
562
+
563
+ unregisterJobClaim("job-a")
564
+ unregisterJobClaim("job-b")
565
+ })
566
+
567
+ test("global pool limits job B to remaining slots while job A occupies its claim", () => {
568
+ // MAX_THREADS=8, job A claim=4, job B claim=8.
569
+ // While A occupies 4 global slots, B can only use the remaining 4.
570
+ // After A finishes, B can use all 8.
571
+ initTaskScheduler(8)
572
+ registerJobClaim("job-a", 4)
573
+ registerJobClaim("job-b", 8)
574
+
575
+ const runningA = { count: 0 }
576
+ const runningB = { count: 0 }
577
+ const completersA: Array<() => void> = []
578
+ const completersB: Array<() => void> = []
579
+
580
+ // 4 tasks for A + 8 for B
581
+ Array.from({ length: 4 }).forEach(() => {
582
+ runTask(
583
+ makeWork(runningA, completersA),
584
+ "job-a",
585
+ ).subscribe()
586
+ })
587
+ Array.from({ length: 8 }).forEach(() => {
588
+ runTask(
589
+ makeWork(runningB, completersB),
590
+ "job-b",
591
+ ).subscribe()
592
+ })
593
+
594
+ // A fills its claim (4); B gets the remaining 4 global slots.
595
+ expect(runningA.count).toBe(4)
596
+ expect(runningB.count).toBe(4)
597
+ // 4 B tasks are still queued.
598
+ expect(completersB.length).toBe(4)
599
+
600
+ // Complete all A tasks one by one — each frees a slot that B claims.
601
+ completersA[0]()
602
+ expect(runningB.count).toBe(5)
603
+ completersA[1]()
604
+ expect(runningB.count).toBe(6)
605
+ completersA[2]()
606
+ expect(runningB.count).toBe(7)
607
+ completersA[3]()
608
+ expect(runningB.count).toBe(8)
609
+
610
+ expect(runningA.count).toBe(0)
611
+
612
+ completersB.forEach((completer) => {
613
+ completer()
614
+ })
615
+
616
+ unregisterJobClaim("job-a")
617
+ unregisterJobClaim("job-b")
618
+ })
619
+
620
+ test("tasks with no jobId (null) are gated only by the global cap", () => {
621
+ initTaskScheduler(2)
622
+
623
+ const running = { count: 0 }
624
+ const completers: Array<() => void> = []
625
+
626
+ runTask(makeWork(running, completers), null).subscribe()
627
+ runTask(makeWork(running, completers), null).subscribe()
628
+ runTask(makeWork(running, completers), null).subscribe()
629
+
630
+ // Only 2 admitted (global cap); no per-job quota applies.
631
+ expect(running.count).toBe(2)
632
+ expect(completers.length).toBe(2)
633
+
634
+ completers[0]()
635
+ expect(running.count).toBe(2)
636
+ expect(completers.length).toBe(3)
637
+
638
+ completers[1]()
639
+ completers[2]()
640
+ expect(running.count).toBe(0)
641
+ })
642
+ })
643
+
644
+ describe(initTaskScheduler.name, () => {
645
+ test("throws when re-initialized with a different concurrency", () => {
646
+ initTaskScheduler(2)
647
+
648
+ expect(() => initTaskScheduler(4)).toThrow(
649
+ /already initialized/,
650
+ )
651
+ })
652
+
653
+ test("is idempotent when called with the same concurrency", () => {
654
+ initTaskScheduler(2)
655
+
656
+ expect(() => initTaskScheduler(2)).not.toThrow()
657
+ })
658
+
659
+ test("surfaces an error when runTask is called before initialization", () => {
660
+ let caughtError: unknown = null
661
+
662
+ runTask(of(1)).subscribe({
663
+ error: (error) => {
664
+ caughtError = error
665
+ },
666
+ })
667
+
668
+ expect(caughtError).toBeInstanceOf(Error)
669
+ expect((caughtError as Error).message).toMatch(
670
+ /not initialized/,
671
+ )
672
+ })
673
+ })