@stamhoofd/queues 2.1.1
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 +17 -0
- package/src/QueueHandler.test.ts +84 -0
- package/src/QueueHandler.ts +108 -0
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stamhoofd/queues",
|
|
3
|
+
"version": "2.1.1",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/index.d.ts",
|
|
6
|
+
"license": "UNLICENCED",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -b",
|
|
13
|
+
"build:full": "rm -rf ./dist && yarn build",
|
|
14
|
+
"test": "jest --runInBand",
|
|
15
|
+
"test:reset": "yarn build:full && jest --runInBand"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { QueueHandler } from './QueueHandler';
|
|
3
|
+
|
|
4
|
+
describe('QueueHandler', () => {
|
|
5
|
+
it('Guards against deadlocks', async () => {
|
|
6
|
+
const result = await QueueHandler.schedule('test', async () => {
|
|
7
|
+
return QueueHandler.schedule('test', async () => {
|
|
8
|
+
return 'test';
|
|
9
|
+
});
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
expect(result).toBe('test');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('Guards against deep deadlocks', async () => {
|
|
16
|
+
const result = await QueueHandler.schedule('test', async () => {
|
|
17
|
+
return QueueHandler.schedule('other', async () => {
|
|
18
|
+
return QueueHandler.schedule('test', async () => {
|
|
19
|
+
return QueueHandler.schedule('other', async () => {
|
|
20
|
+
return QueueHandler.schedule('test', async () => {
|
|
21
|
+
return 'test';
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(result).toBe('test');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('Inherits the right AsyncLocalStorage context', async () => {
|
|
32
|
+
const context = new AsyncLocalStorage<string>();
|
|
33
|
+
|
|
34
|
+
const ranContexts: string[] = [];
|
|
35
|
+
const promises: Promise<void>[] = [];
|
|
36
|
+
|
|
37
|
+
// Do some quick scheduling
|
|
38
|
+
for (let i = 0; i < 20; i++) {
|
|
39
|
+
context.run(i.toString(), async () => {
|
|
40
|
+
promises.push(QueueHandler.schedule('test', async () => {
|
|
41
|
+
await new Promise<void>(resolve => {
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
resolve();
|
|
44
|
+
}, 100);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
ranContexts.push(context.getStore() ?? '');
|
|
48
|
+
}));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
await Promise.allSettled(promises);
|
|
53
|
+
|
|
54
|
+
expect(ranContexts).toHaveLength(20);
|
|
55
|
+
expect(ranContexts.sort()).toEqual(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19'].sort());
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('Inherits the right AsyncLocalStorage context with parallel execution', async () => {
|
|
59
|
+
const context = new AsyncLocalStorage<string>();
|
|
60
|
+
|
|
61
|
+
const ranContexts: string[] = [];
|
|
62
|
+
const promises: Promise<void>[] = [];
|
|
63
|
+
|
|
64
|
+
// Do some quick scheduling
|
|
65
|
+
for (let i = 0; i < 50; i++) {
|
|
66
|
+
context.run(i.toString(), async () => {
|
|
67
|
+
promises.push(QueueHandler.schedule('test', async () => {
|
|
68
|
+
await new Promise<void>(resolve => {
|
|
69
|
+
setTimeout(() => {
|
|
70
|
+
resolve();
|
|
71
|
+
}, 100);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
ranContexts.push(context.getStore() ?? '');
|
|
75
|
+
}, 5));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await Promise.allSettled(promises);
|
|
80
|
+
|
|
81
|
+
expect(ranContexts).toHaveLength(50);
|
|
82
|
+
expect(ranContexts.sort()).toEqual(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '40', '41', '42', '43', '44', '45', '46', '47', '48', '49'].sort());
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
|
|
4
|
+
class Queue {
|
|
5
|
+
name: string
|
|
6
|
+
items: QueueItem<any>[] = []
|
|
7
|
+
parallel = 1
|
|
8
|
+
runCount = 0
|
|
9
|
+
|
|
10
|
+
constructor(name: string, parallel = 1) {
|
|
11
|
+
this.name = name
|
|
12
|
+
this.parallel = parallel
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
addItem(item: QueueItem<any>) {
|
|
16
|
+
this.items.push(item)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class QueueItem<T> {
|
|
21
|
+
handler: () => Promise<T>
|
|
22
|
+
resolve: (value: T) => void
|
|
23
|
+
reject: (reason?: any) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Force the usage of a queue to prevent concurrency issues
|
|
28
|
+
*/
|
|
29
|
+
export class QueueHandler {
|
|
30
|
+
static queues = new Map<string, Queue>()
|
|
31
|
+
static asyncLocalStorage = new AsyncLocalStorage<string[]>();
|
|
32
|
+
|
|
33
|
+
static async schedule<T>(queue: string, handler: () => Promise<T>, parallel = 1): Promise<T> {
|
|
34
|
+
// console.log("[QUEUE] Schedule "+queue)
|
|
35
|
+
|
|
36
|
+
const currentQueues = this.asyncLocalStorage.getStore();
|
|
37
|
+
if (currentQueues !== undefined && currentQueues.includes(queue)) {
|
|
38
|
+
console.warn('Recursive usage of queues detected. Ignored running in queue', queue, currentQueues);
|
|
39
|
+
return await handler();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// We need to save the current AsyncLocalStorage context
|
|
43
|
+
// otherwise we could run items on the queue with the wrong context
|
|
44
|
+
const snapshot = AsyncLocalStorage.snapshot()
|
|
45
|
+
|
|
46
|
+
const item = new QueueItem<T>()
|
|
47
|
+
item.handler = () => snapshot(async () => {
|
|
48
|
+
const currentQueues = this.asyncLocalStorage.getStore() ?? [];
|
|
49
|
+
return await this.asyncLocalStorage.run([...currentQueues, queue], async () => {
|
|
50
|
+
return await handler();
|
|
51
|
+
});
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const promise = new Promise<T>((resolve, reject) => {
|
|
55
|
+
item.resolve = resolve
|
|
56
|
+
item.reject = reject
|
|
57
|
+
|
|
58
|
+
// We only add it here because resolve and reject is required
|
|
59
|
+
const q = this.queues.get(queue) ?? new Queue(queue, parallel)
|
|
60
|
+
q.addItem(item)
|
|
61
|
+
this.queues.set(queue, q)
|
|
62
|
+
|
|
63
|
+
// Run the next item if not already running
|
|
64
|
+
this.runNext(queue).catch(e => {
|
|
65
|
+
console.error("[QUEUE] Fatal error in queue logic", e)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return promise
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private static async runNext(queue: string) {
|
|
73
|
+
const q = this.queues.get(queue)
|
|
74
|
+
if (!q) {
|
|
75
|
+
// console.warn("[QUEUE] Queue not found (no items left)", queue)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (q.runCount >= q.parallel) {
|
|
80
|
+
// console.log("[QUEUE] Queue", queue, 'reached maximum of', q.parallel)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const next = q.items.shift()
|
|
85
|
+
|
|
86
|
+
if (next === undefined) {
|
|
87
|
+
this.queues.delete(queue)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
q.runCount += 1
|
|
92
|
+
// console.log("[QUEUE] ("+q.runCount+"/"+q.parallel+") Executing "+queue+" ("+q.items.length+" remaining)")
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
next.resolve(await next.handler())
|
|
96
|
+
// console.log("[QUEUE] ("+(q.runCount-1)+"/"+q.parallel+") Resolved "+queue+" ("+q.items.length+" remaining)")
|
|
97
|
+
} catch (e) {
|
|
98
|
+
next.reject(e)
|
|
99
|
+
if (STAMHOOFD.environment !== 'test') {
|
|
100
|
+
console.log("[QUEUE] ("+(q.runCount-1)+"/"+q.parallel+") Rejected "+queue+" ("+q.items.length+" remaining)")
|
|
101
|
+
console.error(e)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
q.runCount -= 1
|
|
106
|
+
await this.runNext(queue)
|
|
107
|
+
}
|
|
108
|
+
}
|