@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.
- package/package.json +1 -1
- package/src/applyRenameRegex.test.ts +49 -0
- package/src/applyRenameRegex.ts +22 -0
- package/src/index.ts +42 -0
- package/src/logMessage.test.ts +112 -9
- package/src/logMessage.ts +29 -0
- package/src/logging/context.test.ts +57 -0
- package/src/logging/context.ts +23 -0
- package/src/logging/lineSink.test.ts +135 -0
- package/src/logging/lineSink.ts +74 -0
- package/src/logging/logger.test.ts +154 -0
- package/src/logging/logger.ts +96 -0
- package/src/logging/mode.test.ts +36 -0
- package/src/logging/mode.ts +29 -0
- package/src/logging/startSpan.test.ts +150 -0
- package/src/logging/startSpan.ts +51 -0
- package/src/sourcePath.test.ts +16 -0
- package/src/sourcePath.ts +17 -0
- package/src/taskScheduler.injection.test.ts +72 -0
- package/src/taskScheduler.test.ts +673 -0
- package/src/taskScheduler.ts +414 -0
|
@@ -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
|
+
})
|