@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 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
+ }