@furystack/utils 8.1.9 → 8.2.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/CHANGELOG.md +66 -0
- package/esm/event-hub.d.ts +21 -0
- package/esm/event-hub.d.ts.map +1 -1
- package/esm/event-hub.js +48 -1
- package/esm/event-hub.js.map +1 -1
- package/esm/event-hub.spec.js +112 -0
- package/esm/event-hub.spec.js.map +1 -1
- package/esm/index.d.ts +1 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +1 -0
- package/esm/index.js.map +1 -1
- package/esm/observable-value.d.ts +9 -0
- package/esm/observable-value.d.ts.map +1 -1
- package/esm/observable-value.js +13 -2
- package/esm/observable-value.js.map +1 -1
- package/esm/observable-value.spec.d.ts.map +1 -1
- package/esm/observable-value.spec.js +160 -76
- package/esm/observable-value.spec.js.map +1 -1
- package/esm/semaphore.d.ts +104 -0
- package/esm/semaphore.d.ts.map +1 -0
- package/esm/semaphore.js +185 -0
- package/esm/semaphore.js.map +1 -0
- package/esm/semaphore.spec.d.ts +2 -0
- package/esm/semaphore.spec.d.ts.map +1 -0
- package/esm/semaphore.spec.js +356 -0
- package/esm/semaphore.spec.js.map +1 -0
- package/package.json +2 -2
- package/src/event-hub.spec.ts +153 -0
- package/src/event-hub.ts +57 -1
- package/src/index.ts +1 -0
- package/src/observable-value.spec.ts +197 -81
- package/src/observable-value.ts +18 -2
- package/src/semaphore.spec.ts +467 -0
- package/src/semaphore.ts +237 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { sleepAsync } from './sleep-async.js'
|
|
3
|
+
import { using } from './using.js'
|
|
4
|
+
import { Semaphore, SemaphoreDisposedError } from './semaphore.js'
|
|
5
|
+
|
|
6
|
+
export const semaphoreTests = describe('Semaphore', () => {
|
|
7
|
+
it('should be constructed with a given concurrency limit', () => {
|
|
8
|
+
using(new Semaphore(3), (s) => {
|
|
9
|
+
expect(s).toBeInstanceOf(Semaphore)
|
|
10
|
+
expect(s.getMaxConcurrent()).toBe(3)
|
|
11
|
+
expect(s.pendingCount.getValue()).toBe(0)
|
|
12
|
+
expect(s.runningCount.getValue()).toBe(0)
|
|
13
|
+
expect(s.completedCount.getValue()).toBe(0)
|
|
14
|
+
expect(s.failedCount.getValue()).toBe(0)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('should execute a single task and return its result', async () => {
|
|
19
|
+
const s = new Semaphore(2)
|
|
20
|
+
const result = await s.execute(async () => 42)
|
|
21
|
+
expect(result).toBe(42)
|
|
22
|
+
expect(s.completedCount.getValue()).toBe(1)
|
|
23
|
+
expect(s.runningCount.getValue()).toBe(0)
|
|
24
|
+
s[Symbol.dispose]()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should execute up to N tasks concurrently and queue the rest', async () => {
|
|
28
|
+
const s = new Semaphore(2)
|
|
29
|
+
const running: string[] = []
|
|
30
|
+
const resolvers: Array<() => void> = []
|
|
31
|
+
|
|
32
|
+
const createTask = (name: string) =>
|
|
33
|
+
s.execute(async () => {
|
|
34
|
+
running.push(name)
|
|
35
|
+
await new Promise<void>((resolve) => resolvers.push(resolve))
|
|
36
|
+
return name
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const p1 = createTask('a')
|
|
40
|
+
const p2 = createTask('b')
|
|
41
|
+
const p3 = createTask('c')
|
|
42
|
+
|
|
43
|
+
await sleepAsync(10)
|
|
44
|
+
|
|
45
|
+
expect(running).toEqual(['a', 'b'])
|
|
46
|
+
expect(s.runningCount.getValue()).toBe(2)
|
|
47
|
+
expect(s.pendingCount.getValue()).toBe(1)
|
|
48
|
+
|
|
49
|
+
resolvers[0]()
|
|
50
|
+
await p1
|
|
51
|
+
|
|
52
|
+
await sleepAsync(10)
|
|
53
|
+
|
|
54
|
+
expect(running).toEqual(['a', 'b', 'c'])
|
|
55
|
+
expect(s.runningCount.getValue()).toBe(2)
|
|
56
|
+
expect(s.pendingCount.getValue()).toBe(0)
|
|
57
|
+
|
|
58
|
+
resolvers[1]()
|
|
59
|
+
resolvers[2]()
|
|
60
|
+
await Promise.all([p2, p3])
|
|
61
|
+
|
|
62
|
+
expect(s.completedCount.getValue()).toBe(3)
|
|
63
|
+
expect(s.runningCount.getValue()).toBe(0)
|
|
64
|
+
s[Symbol.dispose]()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should propagate task rejection to the caller and continue processing', async () => {
|
|
68
|
+
const s = new Semaphore(1)
|
|
69
|
+
const taskError = new Error('task failed')
|
|
70
|
+
|
|
71
|
+
const p1 = s.execute(async () => {
|
|
72
|
+
throw taskError
|
|
73
|
+
})
|
|
74
|
+
const p2 = s.execute(async () => 'ok')
|
|
75
|
+
|
|
76
|
+
await expect(p1).rejects.toThrow('task failed')
|
|
77
|
+
|
|
78
|
+
const result = await p2
|
|
79
|
+
expect(result).toBe('ok')
|
|
80
|
+
expect(s.failedCount.getValue()).toBe(1)
|
|
81
|
+
expect(s.completedCount.getValue()).toBe(1)
|
|
82
|
+
s[Symbol.dispose]()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('ObservableValue counters', () => {
|
|
86
|
+
it('should update pendingCount and runningCount on transitions', async () => {
|
|
87
|
+
const s = new Semaphore(1)
|
|
88
|
+
const pendingChanges: number[] = []
|
|
89
|
+
const runningChanges: number[] = []
|
|
90
|
+
|
|
91
|
+
s.pendingCount.subscribe((v) => {
|
|
92
|
+
pendingChanges.push(v)
|
|
93
|
+
})
|
|
94
|
+
s.runningCount.subscribe((v) => {
|
|
95
|
+
runningChanges.push(v)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
let resolve!: () => void
|
|
99
|
+
const p1 = s.execute(async () => {
|
|
100
|
+
await new Promise<void>((r) => (resolve = r))
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const p2 = s.execute(async () => 'done')
|
|
104
|
+
|
|
105
|
+
await sleepAsync(10)
|
|
106
|
+
|
|
107
|
+
expect(pendingChanges).toContain(1)
|
|
108
|
+
expect(runningChanges).toContain(1)
|
|
109
|
+
|
|
110
|
+
resolve()
|
|
111
|
+
await p1
|
|
112
|
+
await sleepAsync(10)
|
|
113
|
+
await p2
|
|
114
|
+
|
|
115
|
+
expect(s.pendingCount.getValue()).toBe(0)
|
|
116
|
+
expect(s.runningCount.getValue()).toBe(0)
|
|
117
|
+
expect(s.completedCount.getValue()).toBe(2)
|
|
118
|
+
s[Symbol.dispose]()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should update completedCount and failedCount correctly', async () => {
|
|
122
|
+
const s = new Semaphore(2)
|
|
123
|
+
|
|
124
|
+
await s.execute(async () => 'ok')
|
|
125
|
+
await s
|
|
126
|
+
.execute(async () => {
|
|
127
|
+
throw new Error('fail')
|
|
128
|
+
})
|
|
129
|
+
.catch(() => {})
|
|
130
|
+
|
|
131
|
+
expect(s.completedCount.getValue()).toBe(1)
|
|
132
|
+
expect(s.failedCount.getValue()).toBe(1)
|
|
133
|
+
s[Symbol.dispose]()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('AbortSignal support', () => {
|
|
138
|
+
it('should abort a pending task when the caller signal aborts', async () => {
|
|
139
|
+
const s = new Semaphore(1)
|
|
140
|
+
let resolve!: () => void
|
|
141
|
+
|
|
142
|
+
const p1 = s.execute(async () => {
|
|
143
|
+
await new Promise<void>((r) => (resolve = r))
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const controller = new AbortController()
|
|
147
|
+
const p2 = s.execute(async () => 'should not run', { signal: controller.signal })
|
|
148
|
+
|
|
149
|
+
await sleepAsync(10)
|
|
150
|
+
expect(s.pendingCount.getValue()).toBe(1)
|
|
151
|
+
|
|
152
|
+
controller.abort(new Error('cancelled'))
|
|
153
|
+
await expect(p2).rejects.toThrow('cancelled')
|
|
154
|
+
expect(s.pendingCount.getValue()).toBe(0)
|
|
155
|
+
|
|
156
|
+
resolve()
|
|
157
|
+
await p1
|
|
158
|
+
s[Symbol.dispose]()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should reject immediately if the caller signal is already aborted', async () => {
|
|
162
|
+
const s = new Semaphore(1)
|
|
163
|
+
const controller = new AbortController()
|
|
164
|
+
controller.abort(new Error('pre-aborted'))
|
|
165
|
+
|
|
166
|
+
await expect(s.execute(async () => 'should not run', { signal: controller.signal })).rejects.toThrow(
|
|
167
|
+
'pre-aborted',
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
expect(s.pendingCount.getValue()).toBe(0)
|
|
171
|
+
expect(s.runningCount.getValue()).toBe(0)
|
|
172
|
+
s[Symbol.dispose]()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should clean up the caller signal listener when the task completes normally', async () => {
|
|
176
|
+
const s = new Semaphore(1)
|
|
177
|
+
const controller = new AbortController()
|
|
178
|
+
|
|
179
|
+
const removeSpy = vi.spyOn(controller.signal, 'removeEventListener')
|
|
180
|
+
|
|
181
|
+
await s.execute(async () => 'done', { signal: controller.signal })
|
|
182
|
+
|
|
183
|
+
expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function))
|
|
184
|
+
removeSpy.mockRestore()
|
|
185
|
+
s[Symbol.dispose]()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('should abort the signal passed to a running task when the caller signal aborts', async () => {
|
|
189
|
+
const s = new Semaphore(1)
|
|
190
|
+
const signalAborted = vi.fn()
|
|
191
|
+
|
|
192
|
+
const controller = new AbortController()
|
|
193
|
+
const p = s.execute(
|
|
194
|
+
async ({ signal }) => {
|
|
195
|
+
signal.addEventListener('abort', signalAborted)
|
|
196
|
+
await new Promise<void>((resolve) => {
|
|
197
|
+
signal.addEventListener('abort', () => resolve())
|
|
198
|
+
})
|
|
199
|
+
throw signal.reason
|
|
200
|
+
},
|
|
201
|
+
{ signal: controller.signal },
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
await sleepAsync(10)
|
|
205
|
+
expect(s.runningCount.getValue()).toBe(1)
|
|
206
|
+
|
|
207
|
+
controller.abort(new Error('stop'))
|
|
208
|
+
await expect(p).rejects.toThrow('stop')
|
|
209
|
+
expect(signalAborted).toBeCalledTimes(1)
|
|
210
|
+
s[Symbol.dispose]()
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('EventHub events', () => {
|
|
215
|
+
it('should emit taskStarted when a task begins running', async () => {
|
|
216
|
+
const s = new Semaphore(1)
|
|
217
|
+
const listener = vi.fn()
|
|
218
|
+
s.subscribe('taskStarted', listener)
|
|
219
|
+
|
|
220
|
+
await s.execute(async () => 'done')
|
|
221
|
+
|
|
222
|
+
expect(listener).toBeCalledTimes(1)
|
|
223
|
+
s[Symbol.dispose]()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('should emit taskCompleted when a task resolves', async () => {
|
|
227
|
+
const s = new Semaphore(1)
|
|
228
|
+
const listener = vi.fn()
|
|
229
|
+
s.subscribe('taskCompleted', listener)
|
|
230
|
+
|
|
231
|
+
await s.execute(async () => 'done')
|
|
232
|
+
|
|
233
|
+
expect(listener).toBeCalledTimes(1)
|
|
234
|
+
s[Symbol.dispose]()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('should emit taskFailed with the error when a task rejects', async () => {
|
|
238
|
+
const s = new Semaphore(1)
|
|
239
|
+
const listener = vi.fn()
|
|
240
|
+
s.subscribe('taskFailed', listener)
|
|
241
|
+
|
|
242
|
+
const taskError = new Error('boom')
|
|
243
|
+
await s
|
|
244
|
+
.execute(async () => {
|
|
245
|
+
throw taskError
|
|
246
|
+
})
|
|
247
|
+
.catch(() => {})
|
|
248
|
+
|
|
249
|
+
expect(listener).toBeCalledTimes(1)
|
|
250
|
+
expect(listener).toBeCalledWith({ error: taskError })
|
|
251
|
+
s[Symbol.dispose]()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should emit events in correct order for queued tasks', async () => {
|
|
255
|
+
const s = new Semaphore(1)
|
|
256
|
+
const events: string[] = []
|
|
257
|
+
|
|
258
|
+
s.subscribe('taskStarted', () => {
|
|
259
|
+
events.push('started')
|
|
260
|
+
})
|
|
261
|
+
s.subscribe('taskCompleted', () => {
|
|
262
|
+
events.push('completed')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
await Promise.all([s.execute(async () => 'a'), s.execute(async () => 'b')])
|
|
266
|
+
|
|
267
|
+
await sleepAsync(10)
|
|
268
|
+
|
|
269
|
+
expect(events).toEqual(['started', 'completed', 'started', 'completed'])
|
|
270
|
+
s[Symbol.dispose]()
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('setMaxConcurrent', () => {
|
|
275
|
+
it('should return the updated value from getMaxConcurrent', () => {
|
|
276
|
+
const s = new Semaphore(2)
|
|
277
|
+
s.setMaxConcurrent(5)
|
|
278
|
+
expect(s.getMaxConcurrent()).toBe(5)
|
|
279
|
+
s[Symbol.dispose]()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should throw when given a non-positive integer', () => {
|
|
283
|
+
const s = new Semaphore(2)
|
|
284
|
+
expect(() => s.setMaxConcurrent(0)).toThrow('maxConcurrent must be a positive integer')
|
|
285
|
+
expect(() => s.setMaxConcurrent(-1)).toThrow('maxConcurrent must be a positive integer')
|
|
286
|
+
expect(() => s.setMaxConcurrent(1.5)).toThrow('maxConcurrent must be a positive integer')
|
|
287
|
+
s[Symbol.dispose]()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('should immediately start queued tasks when increased', async () => {
|
|
291
|
+
const s = new Semaphore(1)
|
|
292
|
+
const running: string[] = []
|
|
293
|
+
const resolvers: Array<() => void> = []
|
|
294
|
+
|
|
295
|
+
const createTask = (name: string) =>
|
|
296
|
+
s.execute(async () => {
|
|
297
|
+
running.push(name)
|
|
298
|
+
await new Promise<void>((resolve) => resolvers.push(resolve))
|
|
299
|
+
return name
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const p1 = createTask('a')
|
|
303
|
+
const p2 = createTask('b')
|
|
304
|
+
const p3 = createTask('c')
|
|
305
|
+
|
|
306
|
+
await sleepAsync(10)
|
|
307
|
+
expect(running).toEqual(['a'])
|
|
308
|
+
expect(s.runningCount.getValue()).toBe(1)
|
|
309
|
+
expect(s.pendingCount.getValue()).toBe(2)
|
|
310
|
+
|
|
311
|
+
s.setMaxConcurrent(3)
|
|
312
|
+
|
|
313
|
+
await sleepAsync(10)
|
|
314
|
+
expect(running).toEqual(['a', 'b', 'c'])
|
|
315
|
+
expect(s.runningCount.getValue()).toBe(3)
|
|
316
|
+
expect(s.pendingCount.getValue()).toBe(0)
|
|
317
|
+
|
|
318
|
+
resolvers.forEach((r) => r())
|
|
319
|
+
await Promise.all([p1, p2, p3])
|
|
320
|
+
s[Symbol.dispose]()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('should not abort running tasks when decreased', async () => {
|
|
324
|
+
const s = new Semaphore(3)
|
|
325
|
+
const resolvers: Array<() => void> = []
|
|
326
|
+
|
|
327
|
+
const createTask = () =>
|
|
328
|
+
s.execute(async () => {
|
|
329
|
+
await new Promise<void>((resolve) => resolvers.push(resolve))
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
const p1 = createTask()
|
|
333
|
+
const p2 = createTask()
|
|
334
|
+
const p3 = createTask()
|
|
335
|
+
|
|
336
|
+
await sleepAsync(10)
|
|
337
|
+
expect(s.runningCount.getValue()).toBe(3)
|
|
338
|
+
|
|
339
|
+
s.setMaxConcurrent(1)
|
|
340
|
+
|
|
341
|
+
expect(s.runningCount.getValue()).toBe(3)
|
|
342
|
+
|
|
343
|
+
resolvers.forEach((r) => r())
|
|
344
|
+
await Promise.all([p1, p2, p3])
|
|
345
|
+
expect(s.completedCount.getValue()).toBe(3)
|
|
346
|
+
s[Symbol.dispose]()
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('should not start new tasks until running count drops below new lower limit', async () => {
|
|
350
|
+
const s = new Semaphore(2)
|
|
351
|
+
const running: string[] = []
|
|
352
|
+
const resolvers: Array<() => void> = []
|
|
353
|
+
|
|
354
|
+
const createTask = (name: string) =>
|
|
355
|
+
s.execute(async () => {
|
|
356
|
+
running.push(name)
|
|
357
|
+
await new Promise<void>((resolve) => resolvers.push(resolve))
|
|
358
|
+
return name
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const p1 = createTask('a')
|
|
362
|
+
const p2 = createTask('b')
|
|
363
|
+
const p3 = createTask('c')
|
|
364
|
+
|
|
365
|
+
await sleepAsync(10)
|
|
366
|
+
expect(running).toEqual(['a', 'b'])
|
|
367
|
+
expect(s.pendingCount.getValue()).toBe(1)
|
|
368
|
+
|
|
369
|
+
s.setMaxConcurrent(1)
|
|
370
|
+
|
|
371
|
+
resolvers[0]()
|
|
372
|
+
await p1
|
|
373
|
+
await sleepAsync(10)
|
|
374
|
+
|
|
375
|
+
expect(running).toEqual(['a', 'b'])
|
|
376
|
+
expect(s.pendingCount.getValue()).toBe(1)
|
|
377
|
+
|
|
378
|
+
resolvers[1]()
|
|
379
|
+
await p2
|
|
380
|
+
await sleepAsync(10)
|
|
381
|
+
|
|
382
|
+
expect(running).toEqual(['a', 'b', 'c'])
|
|
383
|
+
expect(s.pendingCount.getValue()).toBe(0)
|
|
384
|
+
|
|
385
|
+
resolvers[2]()
|
|
386
|
+
await p3
|
|
387
|
+
s[Symbol.dispose]()
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
describe('Disposal', () => {
|
|
392
|
+
it('should reject all pending tasks with SemaphoreDisposedError', async () => {
|
|
393
|
+
const s = new Semaphore(1)
|
|
394
|
+
let resolve!: () => void
|
|
395
|
+
|
|
396
|
+
const p1 = s.execute(async () => {
|
|
397
|
+
await new Promise<void>((r) => (resolve = r))
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const p2 = s.execute(async () => 'pending1')
|
|
401
|
+
const p3 = s.execute(async () => 'pending2')
|
|
402
|
+
|
|
403
|
+
await sleepAsync(10)
|
|
404
|
+
expect(s.pendingCount.getValue()).toBe(2)
|
|
405
|
+
|
|
406
|
+
s[Symbol.dispose]()
|
|
407
|
+
|
|
408
|
+
await expect(p2).rejects.toThrow('Semaphore already disposed')
|
|
409
|
+
await expect(p3).rejects.toThrow('Semaphore already disposed')
|
|
410
|
+
await expect(p2).rejects.toBeInstanceOf(SemaphoreDisposedError)
|
|
411
|
+
await expect(p3).rejects.toBeInstanceOf(SemaphoreDisposedError)
|
|
412
|
+
|
|
413
|
+
resolve()
|
|
414
|
+
await p1
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('should abort signals of running tasks on disposal', async () => {
|
|
418
|
+
const s = new Semaphore(1)
|
|
419
|
+
const signalAborted = vi.fn()
|
|
420
|
+
|
|
421
|
+
const p = s.execute(async ({ signal }) => {
|
|
422
|
+
signal.addEventListener('abort', signalAborted)
|
|
423
|
+
await new Promise<void>((resolve) => {
|
|
424
|
+
signal.addEventListener('abort', () => resolve())
|
|
425
|
+
})
|
|
426
|
+
throw signal.reason
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
await sleepAsync(10)
|
|
430
|
+
expect(s.runningCount.getValue()).toBe(1)
|
|
431
|
+
|
|
432
|
+
s[Symbol.dispose]()
|
|
433
|
+
|
|
434
|
+
await expect(p).rejects.toBeInstanceOf(SemaphoreDisposedError)
|
|
435
|
+
expect(signalAborted).toBeCalledTimes(1)
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('should throw SemaphoreDisposedError when calling execute() after disposal', () => {
|
|
439
|
+
const s = new Semaphore(1)
|
|
440
|
+
s[Symbol.dispose]()
|
|
441
|
+
|
|
442
|
+
expect(() => s.execute(async () => 'too late')).toThrow('Semaphore already disposed')
|
|
443
|
+
expect(() => s.execute(async () => 'too late')).toThrow(SemaphoreDisposedError)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('should dispose all ObservableValues', () => {
|
|
447
|
+
const s = new Semaphore(1)
|
|
448
|
+
s[Symbol.dispose]()
|
|
449
|
+
|
|
450
|
+
expect(s.pendingCount.isDisposed).toBe(true)
|
|
451
|
+
expect(s.runningCount.isDisposed).toBe(true)
|
|
452
|
+
expect(s.completedCount.isDisposed).toBe(true)
|
|
453
|
+
expect(s.failedCount.isDisposed).toBe(true)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('should clear event listeners via super', () => {
|
|
457
|
+
const s = new Semaphore(1)
|
|
458
|
+
const listener = vi.fn()
|
|
459
|
+
s.subscribe('taskStarted', listener)
|
|
460
|
+
|
|
461
|
+
s[Symbol.dispose]()
|
|
462
|
+
|
|
463
|
+
s.emit('taskStarted', undefined)
|
|
464
|
+
expect(listener).not.toBeCalled()
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
})
|
package/src/semaphore.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { EventHub, type ListenerErrorPayload } from './event-hub.js'
|
|
2
|
+
import { ObservableValue } from './observable-value.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Error thrown when you try to execute on a semaphore that is already disposed,
|
|
6
|
+
* or when pending tasks are rejected due to disposal.
|
|
7
|
+
*/
|
|
8
|
+
export class SemaphoreDisposedError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super('Semaphore already disposed')
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Event map for the Semaphore's EventHub.
|
|
16
|
+
*/
|
|
17
|
+
export type SemaphoreEvents = {
|
|
18
|
+
/** Fired when a queued task begins execution */
|
|
19
|
+
taskStarted: undefined
|
|
20
|
+
/** Fired when a running task resolves successfully */
|
|
21
|
+
taskCompleted: undefined
|
|
22
|
+
/** Fired when a running task rejects, carrying the thrown error */
|
|
23
|
+
taskFailed: { error: unknown }
|
|
24
|
+
/** Fired when an event listener throws during emission */
|
|
25
|
+
onListenerError: ListenerErrorPayload
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type QueuedTask<T = unknown> = {
|
|
29
|
+
task: (options: { signal: AbortSignal }) => Promise<T>
|
|
30
|
+
resolve: (value: T) => void
|
|
31
|
+
reject: (reason: unknown) => void
|
|
32
|
+
abortController: AbortController
|
|
33
|
+
callerSignal?: AbortSignal
|
|
34
|
+
callerAbortHandler?: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* An async semaphore that limits concurrent task execution to a fixed number of slots.
|
|
39
|
+
*
|
|
40
|
+
* Extends {@link EventHub} with {@link SemaphoreEvents} for per-task lifecycle events
|
|
41
|
+
* (`taskStarted`, `taskCompleted`, `taskFailed`).
|
|
42
|
+
*
|
|
43
|
+
* Exposes individual {@link ObservableValue} counters for reactive state monitoring.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* const results = await usingAsync(new Semaphore(3), async (semaphore) => {
|
|
48
|
+
* semaphore.pendingCount.subscribe((count) => console.log('Pending:', count))
|
|
49
|
+
* semaphore.subscribe('taskCompleted', () => console.log('A task completed'))
|
|
50
|
+
*
|
|
51
|
+
* return await Promise.all(
|
|
52
|
+
* urls.map((url) => semaphore.execute(({ signal }) => fetch(url, { signal }))),
|
|
53
|
+
* )
|
|
54
|
+
* })
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export class Semaphore extends EventHub<SemaphoreEvents> {
|
|
58
|
+
private readonly queue: Array<QueuedTask<any>> = []
|
|
59
|
+
private readonly running = new Set<QueuedTask>()
|
|
60
|
+
private disposed = false
|
|
61
|
+
|
|
62
|
+
/** The number of tasks waiting in the queue to be started */
|
|
63
|
+
public readonly pendingCount = new ObservableValue<number>(0)
|
|
64
|
+
/** The number of tasks currently executing */
|
|
65
|
+
public readonly runningCount = new ObservableValue<number>(0)
|
|
66
|
+
/** The total number of tasks that have resolved successfully */
|
|
67
|
+
public readonly completedCount = new ObservableValue<number>(0)
|
|
68
|
+
/** The total number of tasks that have rejected */
|
|
69
|
+
public readonly failedCount = new ObservableValue<number>(0)
|
|
70
|
+
|
|
71
|
+
private _maxConcurrent: number
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param maxConcurrent The maximum number of tasks that can run concurrently
|
|
75
|
+
*/
|
|
76
|
+
constructor(maxConcurrent: number) {
|
|
77
|
+
super()
|
|
78
|
+
this._maxConcurrent = maxConcurrent
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Returns the current maximum number of tasks that can run concurrently.
|
|
83
|
+
* @returns The current concurrency limit
|
|
84
|
+
*/
|
|
85
|
+
public getMaxConcurrent(): number {
|
|
86
|
+
return this._maxConcurrent
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Updates the maximum number of tasks that can run concurrently.
|
|
91
|
+
*
|
|
92
|
+
* If the new limit is higher than the current one, queued tasks will
|
|
93
|
+
* be started immediately to fill the new slots.
|
|
94
|
+
* If the new limit is lower, already-running tasks will not be aborted,
|
|
95
|
+
* but no new tasks will start until the running count drops below the new limit.
|
|
96
|
+
*
|
|
97
|
+
* @param value The new concurrency limit (must be a positive integer)
|
|
98
|
+
*/
|
|
99
|
+
public setMaxConcurrent(value: number): void {
|
|
100
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
101
|
+
throw new Error('maxConcurrent must be a positive integer')
|
|
102
|
+
}
|
|
103
|
+
this._maxConcurrent = value
|
|
104
|
+
this.drain()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Queues a task for execution. Resolves or rejects with the task's own result.
|
|
109
|
+
*
|
|
110
|
+
* The task function receives an `AbortSignal` that is aborted when:
|
|
111
|
+
* - the caller's signal aborts (if provided via `options.signal`)
|
|
112
|
+
* - the semaphore is disposed
|
|
113
|
+
*
|
|
114
|
+
* @param task The async function to execute
|
|
115
|
+
* @param options Optional configuration including an AbortSignal
|
|
116
|
+
* @returns A promise that resolves/rejects with the task's result
|
|
117
|
+
*/
|
|
118
|
+
public execute<T>(
|
|
119
|
+
task: (options: { signal: AbortSignal }) => Promise<T>,
|
|
120
|
+
options?: { signal?: AbortSignal },
|
|
121
|
+
): Promise<T> {
|
|
122
|
+
if (this.disposed) {
|
|
123
|
+
throw new SemaphoreDisposedError()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (options?.signal?.aborted) {
|
|
127
|
+
return Promise.reject(options.signal.reason as Error)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return new Promise<T>((resolve, reject) => {
|
|
131
|
+
const abortController = new AbortController()
|
|
132
|
+
|
|
133
|
+
const entry: QueuedTask<T> = { task, resolve, reject, abortController }
|
|
134
|
+
|
|
135
|
+
if (options?.signal) {
|
|
136
|
+
const callerAbortHandler = () => {
|
|
137
|
+
abortController.abort(options.signal!.reason)
|
|
138
|
+
this.removePending(entry)
|
|
139
|
+
}
|
|
140
|
+
entry.callerSignal = options.signal
|
|
141
|
+
entry.callerAbortHandler = callerAbortHandler
|
|
142
|
+
options.signal.addEventListener('abort', callerAbortHandler, { once: true })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.queue.push(entry)
|
|
146
|
+
this.pendingCount.setValue(this.pendingCount.getValue() + 1)
|
|
147
|
+
|
|
148
|
+
this.drain()
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private removePending<T>(entry: QueuedTask<T>): void {
|
|
153
|
+
const idx = this.queue.indexOf(entry)
|
|
154
|
+
if (idx !== -1) {
|
|
155
|
+
this.queue.splice(idx, 1)
|
|
156
|
+
this.pendingCount.setValue(this.pendingCount.getValue() - 1)
|
|
157
|
+
entry.reject(entry.abortController.signal.reason)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private drain(): void {
|
|
162
|
+
while (this.running.size < this._maxConcurrent && this.queue.length > 0) {
|
|
163
|
+
const entry = this.queue.shift()!
|
|
164
|
+
this.pendingCount.setValue(this.pendingCount.getValue() - 1)
|
|
165
|
+
this.startTask(entry)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private cleanupCallerSignal(entry: QueuedTask): void {
|
|
170
|
+
if (entry.callerSignal && entry.callerAbortHandler) {
|
|
171
|
+
entry.callerSignal.removeEventListener('abort', entry.callerAbortHandler)
|
|
172
|
+
entry.callerSignal = undefined
|
|
173
|
+
entry.callerAbortHandler = undefined
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private startTask(entry: QueuedTask): void {
|
|
178
|
+
this.running.add(entry)
|
|
179
|
+
this.runningCount.setValue(this.runningCount.getValue() + 1)
|
|
180
|
+
this.emit('taskStarted', undefined)
|
|
181
|
+
|
|
182
|
+
entry
|
|
183
|
+
.task({ signal: entry.abortController.signal })
|
|
184
|
+
.then(
|
|
185
|
+
(value) => {
|
|
186
|
+
this.running.delete(entry)
|
|
187
|
+
this.cleanupCallerSignal(entry)
|
|
188
|
+
if (!this.disposed) {
|
|
189
|
+
this.runningCount.setValue(this.runningCount.getValue() - 1)
|
|
190
|
+
this.completedCount.setValue(this.completedCount.getValue() + 1)
|
|
191
|
+
this.emit('taskCompleted', undefined)
|
|
192
|
+
}
|
|
193
|
+
entry.resolve(value)
|
|
194
|
+
},
|
|
195
|
+
(error: unknown) => {
|
|
196
|
+
this.running.delete(entry)
|
|
197
|
+
this.cleanupCallerSignal(entry)
|
|
198
|
+
if (!this.disposed) {
|
|
199
|
+
this.runningCount.setValue(this.runningCount.getValue() - 1)
|
|
200
|
+
this.failedCount.setValue(this.failedCount.getValue() + 1)
|
|
201
|
+
this.emit('taskFailed', { error })
|
|
202
|
+
}
|
|
203
|
+
entry.reject(error)
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
.finally(() => {
|
|
207
|
+
if (!this.disposed) {
|
|
208
|
+
this.drain()
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Disposes the semaphore: rejects all pending tasks with {@link SemaphoreDisposedError},
|
|
215
|
+
* aborts the signal of every running task, and disposes all observable counters and event listeners.
|
|
216
|
+
*/
|
|
217
|
+
public override [Symbol.dispose](): void {
|
|
218
|
+
this.disposed = true
|
|
219
|
+
|
|
220
|
+
for (const entry of [...this.queue]) {
|
|
221
|
+
this.queue.shift()
|
|
222
|
+
entry.reject(new SemaphoreDisposedError())
|
|
223
|
+
}
|
|
224
|
+
this.pendingCount.setValue(0)
|
|
225
|
+
|
|
226
|
+
for (const entry of this.running) {
|
|
227
|
+
entry.abortController.abort(new SemaphoreDisposedError())
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.pendingCount[Symbol.dispose]()
|
|
231
|
+
this.runningCount[Symbol.dispose]()
|
|
232
|
+
this.completedCount[Symbol.dispose]()
|
|
233
|
+
this.failedCount[Symbol.dispose]()
|
|
234
|
+
|
|
235
|
+
super[Symbol.dispose]()
|
|
236
|
+
}
|
|
237
|
+
}
|