@tstdl/base 0.93.141 → 0.93.143
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/authentication/client/authentication.service.d.ts +1 -0
- package/authentication/client/authentication.service.js +3 -2
- package/circuit-breaker/circuit-breaker.d.ts +6 -4
- package/circuit-breaker/postgres/circuit-breaker.d.ts +1 -0
- package/circuit-breaker/postgres/circuit-breaker.js +8 -5
- package/circuit-breaker/tests/circuit-breaker.test.js +20 -0
- package/examples/document-management/main.js +2 -2
- package/notification/tests/notification-api.test.js +5 -1
- package/notification/tests/notification-flow.test.js +9 -6
- package/orm/decorators.d.ts +17 -4
- package/orm/decorators.js +9 -0
- package/orm/server/bootstrap.d.ts +11 -0
- package/orm/server/bootstrap.js +31 -0
- package/orm/server/drizzle/schema-converter.d.ts +3 -1
- package/orm/server/drizzle/schema-converter.js +71 -29
- package/orm/server/extension.d.ts +14 -0
- package/orm/server/extension.js +27 -0
- package/orm/server/index.d.ts +2 -0
- package/orm/server/index.js +2 -0
- package/orm/server/migration.d.ts +2 -3
- package/orm/server/migration.js +7 -21
- package/orm/server/repository.d.ts +1 -0
- package/orm/server/repository.js +19 -9
- package/orm/server/transaction.d.ts +1 -0
- package/orm/server/transaction.js +3 -0
- package/orm/tests/database-extension.test.js +63 -0
- package/orm/tests/database-migration.test.js +7 -6
- package/orm/tests/repository-compound-primary-key.test.d.ts +2 -0
- package/orm/tests/repository-compound-primary-key.test.js +234 -0
- package/orm/tests/schema-generation.test.d.ts +1 -0
- package/orm/tests/schema-generation.test.js +52 -5
- package/package.json +4 -4
- package/task-queue/README.md +0 -1
- package/task-queue/postgres/drizzle/0000_faithful_daimon_hellstrom.sql +84 -0
- package/task-queue/postgres/drizzle/meta/0000_snapshot.json +155 -72
- package/task-queue/postgres/drizzle/meta/_journal.json +2 -2
- package/task-queue/postgres/module.js +2 -1
- package/task-queue/postgres/schemas.d.ts +6 -0
- package/task-queue/postgres/task-queue.d.ts +18 -5
- package/task-queue/postgres/task-queue.js +577 -357
- package/task-queue/postgres/task.model.d.ts +10 -6
- package/task-queue/postgres/task.model.js +28 -28
- package/task-queue/task-context.d.ts +10 -5
- package/task-queue/task-context.js +5 -3
- package/task-queue/task-queue.d.ts +342 -38
- package/task-queue/task-queue.js +135 -31
- package/task-queue/tests/coverage-branch.test.js +46 -58
- package/task-queue/tests/coverage-enhancement.test.js +123 -117
- package/task-queue/tests/{extensive-dependencies.test.js → dag.test.js} +66 -37
- package/task-queue/tests/dependencies.test.js +143 -25
- package/task-queue/tests/enqueue-batch.test.js +125 -0
- package/task-queue/tests/fan-out-spawning.test.js +46 -5
- package/task-queue/tests/idempotent-replacement.test.js +54 -1
- package/task-queue/tests/missing-idempotent-tasks.test.js +9 -8
- package/task-queue/tests/queue.test.js +261 -25
- package/task-queue/tests/shutdown.test.js +41 -0
- package/task-queue/tests/transactions.test.d.ts +1 -0
- package/task-queue/tests/transactions.test.js +47 -0
- package/task-queue/tests/worker.test.js +46 -13
- package/task-queue/tests/zombie-parent.test.js +4 -4
- package/task-queue/tests/zombie-recovery.test.js +3 -3
- package/testing/integration-setup.js +5 -3
- package/utils/timing.d.ts +2 -2
- package/task-queue/postgres/drizzle/0000_wakeful_sunspot.sql +0 -82
- package/task-queue/tests/cascading-cancellations.test.js +0 -38
- package/task-queue/tests/complex.test.js +0 -122
- package/task-queue/tests/dag-dependencies.test.js +0 -41
- /package/{task-queue/tests/cascading-cancellations.test.d.ts → orm/tests/database-extension.test.d.ts} +0 -0
- /package/task-queue/tests/{complex.test.d.ts → dag.test.d.ts} +0 -0
- /package/task-queue/tests/{dag-dependencies.test.d.ts → enqueue-batch.test.d.ts} +0 -0
- /package/task-queue/tests/{extensive-dependencies.test.d.ts → shutdown.test.d.ts} +0 -0
|
@@ -2,7 +2,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from
|
|
|
2
2
|
import { CancellationToken } from '../../cancellation/index.js';
|
|
3
3
|
import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
4
4
|
import { setupIntegrationTest } from '../../testing/index.js';
|
|
5
|
-
import { timeout } from '../../utils/timing.js';
|
|
6
5
|
describe('Queue Dependencies & Tree Tests', () => {
|
|
7
6
|
let injector;
|
|
8
7
|
let queue;
|
|
@@ -11,7 +10,7 @@ describe('Queue Dependencies & Tree Tests', () => {
|
|
|
11
10
|
});
|
|
12
11
|
beforeEach(() => {
|
|
13
12
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
14
|
-
const queueName = `dep-queue-${
|
|
13
|
+
const queueName = `dep-queue-${crypto.randomUUID()}`;
|
|
15
14
|
queue = queueProvider.get(queueName, {
|
|
16
15
|
visibilityTimeout: 1000,
|
|
17
16
|
});
|
|
@@ -22,15 +21,6 @@ describe('Queue Dependencies & Tree Tests', () => {
|
|
|
22
21
|
afterAll(async () => {
|
|
23
22
|
await injector?.dispose();
|
|
24
23
|
});
|
|
25
|
-
async function waitForStatus(id, status) {
|
|
26
|
-
for (let i = 0; i < 20; i++) {
|
|
27
|
-
const task = await queue.getTask(id);
|
|
28
|
-
if (task?.status == status) {
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
await timeout(50);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
24
|
describe('Dependencies (DAG)', () => {
|
|
35
25
|
it('should schedule a task only after dependency completes (completeAfter)', async () => {
|
|
36
26
|
// 1. Prereq
|
|
@@ -63,7 +53,7 @@ describe('Queue Dependencies & Tree Tests', () => {
|
|
|
63
53
|
// 3. Complete prereq
|
|
64
54
|
const dequeued = await queue.dequeue({ types: ['prereq'] });
|
|
65
55
|
await queue.complete(dequeued);
|
|
66
|
-
await
|
|
56
|
+
await queue.waitForTasks([dependent.id], { statuses: [TaskStatus.Pending] });
|
|
67
57
|
// 5. Dependent should be Pending (ready to run)
|
|
68
58
|
const updatedDependent = await queue.getTask(dependent.id);
|
|
69
59
|
expect(updatedDependent?.status).toBe(TaskStatus.Pending);
|
|
@@ -71,56 +61,165 @@ describe('Queue Dependencies & Tree Tests', () => {
|
|
|
71
61
|
const d2 = await queue.dequeue({ types: ['dependent'] });
|
|
72
62
|
expect(d2?.id).toBe(dependent.id);
|
|
73
63
|
});
|
|
74
|
-
it('should
|
|
64
|
+
it('should abort if dependency fails', async () => {
|
|
75
65
|
const prereq = await queue.enqueue('prereq', {});
|
|
76
66
|
const dependent = await queue.enqueue('dependent', {}, {
|
|
77
67
|
scheduleAfter: [prereq.id],
|
|
78
|
-
|
|
68
|
+
abortOnDependencyFailure: true,
|
|
79
69
|
});
|
|
80
70
|
const dequeued = await queue.dequeue({ types: ['prereq'] });
|
|
81
71
|
// Fail fatally
|
|
82
72
|
await queue.fail(dequeued, new Error('boom'), { fatal: true });
|
|
83
73
|
await queue.waitForTasks([dependent.id]);
|
|
84
74
|
const updatedDependent = await queue.getTask(dependent.id);
|
|
85
|
-
expect(updatedDependent?.status).toBe(TaskStatus.
|
|
75
|
+
expect(updatedDependent?.status).toBe(TaskStatus.Skipped);
|
|
86
76
|
expect(updatedDependent?.error?.code).toBe('DependencyFailed');
|
|
87
77
|
});
|
|
78
|
+
it('should NOT overwrite terminal states during cancellation (abortOnDependencyFailure + complete)', async () => {
|
|
79
|
+
const dep = await queue.enqueue('dep', {});
|
|
80
|
+
// completeAfter allows 'main' to be Running while 'dep' is not finished
|
|
81
|
+
const main = await queue.enqueue('main', {}, { completeAfter: [dep.id], abortOnDependencyFailure: true });
|
|
82
|
+
const runningDep = await queue.dequeue();
|
|
83
|
+
expect(runningDep?.id).toBe(dep.id);
|
|
84
|
+
const runningMain = await queue.dequeue();
|
|
85
|
+
expect(runningMain?.id).toBe(main.id);
|
|
86
|
+
expect(runningMain?.status).toBe(TaskStatus.Running);
|
|
87
|
+
// Fail 'dep' -> this should make 'main' Skipped
|
|
88
|
+
await queue.fail(runningDep, new Error('Dependency failed'), { fatal: true });
|
|
89
|
+
const skippedMain = await queue.getTask(main.id);
|
|
90
|
+
expect(skippedMain?.status).toBe(TaskStatus.Skipped);
|
|
91
|
+
expect(skippedMain?.token).toBeNull();
|
|
92
|
+
// Call complete() on the old task reference (which still has the token)
|
|
93
|
+
await queue.complete(runningMain);
|
|
94
|
+
const finalMain = await queue.getTask(main.id);
|
|
95
|
+
expect(finalMain?.status).toBe(TaskStatus.Skipped);
|
|
96
|
+
});
|
|
97
|
+
it('should resolve dependencies across namespaces', async () => {
|
|
98
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
99
|
+
const queueA = queueProvider.get(`QueueA-${crypto.randomUUID()}`);
|
|
100
|
+
const queueB = queueProvider.get(`QueueB-${crypto.randomUUID()}`);
|
|
101
|
+
const taskA = await queueA.enqueue('test', { value: 'A' });
|
|
102
|
+
const taskB = await queueB.enqueue('test', { value: 'B' }, { scheduleAfter: [{ id: taskA.id }] });
|
|
103
|
+
expect(taskB.status).toBe(TaskStatus.Waiting);
|
|
104
|
+
const dequeuedA = await queueA.dequeue();
|
|
105
|
+
await queueA.complete(dequeuedA);
|
|
106
|
+
queueB.notify();
|
|
107
|
+
await queueB.waitForTasks([taskB.id], { statuses: [TaskStatus.Pending], timeout: 10000 });
|
|
108
|
+
const updatedB = await queueB.getTask(taskB.id);
|
|
109
|
+
expect(updatedB?.status).toBe(TaskStatus.Pending);
|
|
110
|
+
await queueA.clear();
|
|
111
|
+
await queueB.clear();
|
|
112
|
+
});
|
|
113
|
+
it('should correctly skip dependent tasks across namespaces when abortOnDependencyFailure is triggered', async () => {
|
|
114
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
115
|
+
const queueA = queueProvider.get(`QueueA-${crypto.randomUUID()}`);
|
|
116
|
+
const queueB = queueProvider.get(`QueueB-${crypto.randomUUID()}`);
|
|
117
|
+
const taskA = await queueA.enqueue('test', { value: 'A' });
|
|
118
|
+
const taskB = await queueB.enqueue('test', { value: 'B' }, {
|
|
119
|
+
abortOnDependencyFailure: true,
|
|
120
|
+
scheduleAfter: [{ id: taskA.id }],
|
|
121
|
+
});
|
|
122
|
+
const dequeuedA = await queueA.dequeue();
|
|
123
|
+
await queueA.fail(dequeuedA, new Error('Failed'), { fatal: true });
|
|
124
|
+
queueB.notify();
|
|
125
|
+
await queueB.waitForTasks([taskB.id], { statuses: [TaskStatus.Skipped], timeout: 10000 });
|
|
126
|
+
const updatedB = await queueB.getTask(taskB.id);
|
|
127
|
+
expect(updatedB?.status).toBe(TaskStatus.Skipped);
|
|
128
|
+
await queueA.clear();
|
|
129
|
+
await queueB.clear();
|
|
130
|
+
});
|
|
88
131
|
it('should respect requiredStatuses', async () => {
|
|
89
132
|
const prereq = await queue.enqueue('prereq', {});
|
|
90
133
|
// Wait for prereq to be Dead or Completed
|
|
91
134
|
const dependent = await queue.enqueue('dependent', {}, {
|
|
92
|
-
scheduleAfter: [{ id: prereq.id, requiredStatuses: [TaskStatus.Dead, TaskStatus.Completed] }]
|
|
135
|
+
scheduleAfter: [{ id: prereq.id, requiredStatuses: [TaskStatus.Dead, TaskStatus.Completed] }],
|
|
93
136
|
});
|
|
94
137
|
// Fail fatally -> Dead
|
|
95
138
|
const d1 = await queue.dequeue({ types: ['prereq'] });
|
|
96
139
|
await queue.fail(d1, new Error('boom'), { fatal: true });
|
|
97
|
-
await
|
|
140
|
+
await queue.waitForTasks([dependent.id], { statuses: [TaskStatus.Pending] });
|
|
98
141
|
const updated = await queue.getTask(dependent.id);
|
|
99
142
|
expect(updated?.status).toBe(TaskStatus.Pending);
|
|
100
143
|
});
|
|
144
|
+
it('should schedule a task immediately if dependency is already completed (Bug 1)', async () => {
|
|
145
|
+
// 1. Enqueue and complete task A
|
|
146
|
+
const taskA = await queue.enqueue('taskA', {});
|
|
147
|
+
const dequeuedA = await queue.dequeue({ types: ['taskA'] });
|
|
148
|
+
await queue.complete(dequeuedA);
|
|
149
|
+
const finishedA = await queue.getTask(taskA.id);
|
|
150
|
+
expect(finishedA?.status).toBe(TaskStatus.Completed);
|
|
151
|
+
// 2. Enqueue task B with scheduleAfter: [A.id]
|
|
152
|
+
const taskB = await queue.enqueue('taskB', {}, {
|
|
153
|
+
scheduleAfter: [taskA.id],
|
|
154
|
+
});
|
|
155
|
+
// Wait for proactive resolution to trigger
|
|
156
|
+
await queue.waitForTasks([taskB.id], { statuses: [TaskStatus.Pending] });
|
|
157
|
+
const updatedB = await queue.getTask(taskB.id);
|
|
158
|
+
expect(updatedB?.status).toBe(TaskStatus.Pending);
|
|
159
|
+
});
|
|
160
|
+
it('should transition to Pending even if duplicate dependencies are specified (Bug 2)', async () => {
|
|
161
|
+
// 1. Enqueue task A
|
|
162
|
+
const taskA = await queue.enqueue('taskA', {});
|
|
163
|
+
// 2. Enqueue task B with duplicate scheduleAfter: [A.id, A.id]
|
|
164
|
+
const taskB = await queue.enqueue('taskB', {}, {
|
|
165
|
+
scheduleAfter: [taskA.id, taskA.id],
|
|
166
|
+
});
|
|
167
|
+
expect(taskB.status).toBe(TaskStatus.Waiting);
|
|
168
|
+
// 3. Complete task A
|
|
169
|
+
const dequeuedA = await queue.dequeue({ types: ['taskA'] });
|
|
170
|
+
await queue.complete(dequeuedA);
|
|
171
|
+
await queue.waitForTasks([taskB.id], { statuses: [TaskStatus.Pending] });
|
|
172
|
+
const updatedB = await queue.getTask(taskB.id);
|
|
173
|
+
expect(updatedB?.status).toBe(TaskStatus.Pending);
|
|
174
|
+
});
|
|
175
|
+
it('should NOT resurrect terminal tasks when calling rescheduleMany (Issue 1)', async () => {
|
|
176
|
+
const task = await queue.enqueue('test', { val: 1 });
|
|
177
|
+
const dequeued = await queue.dequeue();
|
|
178
|
+
await queue.complete(dequeued);
|
|
179
|
+
const completedTask = await queue.getTask(task.id);
|
|
180
|
+
expect(completedTask?.status).toBe(TaskStatus.Completed);
|
|
181
|
+
// Attempt to reschedule
|
|
182
|
+
await queue.rescheduleMany([task.id], Date.now());
|
|
183
|
+
const stillCompletedTask = await queue.getTask(task.id);
|
|
184
|
+
expect(stillCompletedTask?.status).toBe(TaskStatus.Completed);
|
|
185
|
+
expect(stillCompletedTask?.completeTimestamp).toBeDefined();
|
|
186
|
+
});
|
|
101
187
|
});
|
|
102
188
|
describe('Tree Operations & Cancellation', () => {
|
|
103
|
-
it('should retrieve task tree and cancel hierarchy', async () => {
|
|
189
|
+
it('should retrieve task tree and cancel hierarchy (multiple children)', async () => {
|
|
104
190
|
// Root
|
|
105
191
|
const root = await queue.enqueue('root', {});
|
|
106
|
-
//
|
|
107
|
-
const
|
|
192
|
+
// Children
|
|
193
|
+
const child1 = await queue.enqueue('child1', {}, { parentId: root.id });
|
|
194
|
+
const child2 = await queue.enqueue('child2', {}, { parentId: root.id });
|
|
108
195
|
// Grandchild
|
|
109
|
-
const grandchild = await queue.enqueue('grandchild', {}, { parentId:
|
|
196
|
+
const grandchild = await queue.enqueue('grandchild', {}, { parentId: child1.id });
|
|
110
197
|
// Get Tree
|
|
111
198
|
const tree = await queue.getTree(root.id);
|
|
112
|
-
expect(tree.length).toBe(
|
|
199
|
+
expect(tree.length).toBe(4);
|
|
113
200
|
expect(tree.map((t) => t.id)).toContain(grandchild.id);
|
|
201
|
+
expect(tree.map((t) => t.id)).toContain(child2.id);
|
|
114
202
|
// Cancel Root
|
|
115
203
|
await queue.cancel(root.id);
|
|
116
204
|
// Verify all cancelled
|
|
117
205
|
const tRoot = await queue.getTask(root.id);
|
|
118
|
-
const
|
|
206
|
+
const tChild1 = await queue.getTask(child1.id);
|
|
207
|
+
const tChild2 = await queue.getTask(child2.id);
|
|
119
208
|
const tGrand = await queue.getTask(grandchild.id);
|
|
120
209
|
expect(tRoot?.status).toBe(TaskStatus.Cancelled);
|
|
121
|
-
expect(
|
|
210
|
+
expect(tChild1?.status).toBe(TaskStatus.Cancelled);
|
|
211
|
+
expect(tChild2?.status).toBe(TaskStatus.Cancelled);
|
|
122
212
|
expect(tGrand?.status).toBe(TaskStatus.Cancelled);
|
|
123
213
|
});
|
|
214
|
+
it('should retrieve subtree via getTreeByQuery', async () => {
|
|
215
|
+
const root = await queue.enqueue('root', { val: 'root' });
|
|
216
|
+
const child = await queue.enqueue('child', { val: 'child' }, { parentId: root.id });
|
|
217
|
+
await queue.enqueue('other', { val: 'other' });
|
|
218
|
+
const tree = await queue.getTreeByQuery({ id: root.id });
|
|
219
|
+
expect(tree.length).toBe(2);
|
|
220
|
+
expect(tree.map(t => t.id)).toContain(root.id);
|
|
221
|
+
expect(tree.map(t => t.id)).toContain(child.id);
|
|
222
|
+
});
|
|
124
223
|
it('should cancel many by tags', async () => {
|
|
125
224
|
await queue.enqueue('t1', {}, { tags: ['group-a'] });
|
|
126
225
|
await queue.enqueue('t2', {}, { tags: ['group-a'] });
|
|
@@ -133,6 +232,25 @@ describe('Queue Dependencies & Tree Tests', () => {
|
|
|
133
232
|
const tasksB = await queue.getManyByTags('group-b');
|
|
134
233
|
expect(tasksB[0]?.status).toBe(TaskStatus.Pending);
|
|
135
234
|
});
|
|
235
|
+
it('should NOT resolve completed tasks as Cancelled when calling cancelMany (Issue 3)', async () => {
|
|
236
|
+
const taskA = await queue.enqueue('test', { name: 'A' });
|
|
237
|
+
const taskB = await queue.enqueue('test', { name: 'B' }, { scheduleAfter: [taskA.id], abortOnDependencyFailure: true });
|
|
238
|
+
const dequeuedA = await queue.dequeue();
|
|
239
|
+
await queue.complete(dequeuedA);
|
|
240
|
+
// B should have unresolvedScheduleDependencies = 0 and be Pending because A completed
|
|
241
|
+
const freshB = await queue.getTask(taskB.id);
|
|
242
|
+
expect(freshB?.unresolvedScheduleDependencies).toBe(0);
|
|
243
|
+
expect(freshB?.status).toBe(TaskStatus.Pending);
|
|
244
|
+
// Cancel A. Since A is Completed, it shouldn't be touched in DB.
|
|
245
|
+
// Importantly, it shouldn't trigger dependency resolution for B as 'Cancelled'.
|
|
246
|
+
// In the old code, this would have caused B to be transitioned to 'Skipped' because A was resolved as 'Cancelled' but B required 'Completed' (and abortOnDependencyFailure is true).
|
|
247
|
+
await queue.cancelMany([taskA.id]);
|
|
248
|
+
const stillFreshA = await queue.getTask(taskA.id);
|
|
249
|
+
expect(stillFreshA?.status).toBe(TaskStatus.Completed);
|
|
250
|
+
const stillFreshB = await queue.getTask(taskB.id);
|
|
251
|
+
expect(stillFreshB?.status).toBe(TaskStatus.Pending); // Should STILL be Pending
|
|
252
|
+
expect(stillFreshB?.unresolvedScheduleDependencies).toBe(0);
|
|
253
|
+
});
|
|
136
254
|
});
|
|
137
255
|
describe('Restart & Consumers', () => {
|
|
138
256
|
it('should restart a dead/cancelled task', async () => {
|
|
@@ -162,7 +280,7 @@ describe('Queue Dependencies & Tree Tests', () => {
|
|
|
162
280
|
await queue.enqueueMany([
|
|
163
281
|
{ type: 'b', data: { val: 1 } },
|
|
164
282
|
{ type: 'b', data: { val: 2 } },
|
|
165
|
-
{ type: 'b', data: { val: 3 } }
|
|
283
|
+
{ type: 'b', data: { val: 3 } },
|
|
166
284
|
]);
|
|
167
285
|
const token = new CancellationToken();
|
|
168
286
|
const batchConsumer = queue.getBatchConsumer(2, token);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { eq, inArray } from 'drizzle-orm';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { Database } from '../../orm/server/index.js';
|
|
4
|
+
import { TaskQueueEnqueueBatch } from '../../task-queue/enqueue-batch.js';
|
|
5
|
+
import { TaskQueueProvider } from '../../task-queue/index.js';
|
|
6
|
+
import { taskDependency as taskDependencyTable } from '../../task-queue/postgres/schemas.js';
|
|
7
|
+
import { setupIntegrationTest } from '../../testing/index.js';
|
|
8
|
+
describe('TaskQueueEnqueueBatch', () => {
|
|
9
|
+
let injector;
|
|
10
|
+
let queue;
|
|
11
|
+
let database;
|
|
12
|
+
let queueProvider;
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
15
|
+
queueProvider = injector.resolve(TaskQueueProvider);
|
|
16
|
+
queue = queueProvider.get(`batch-queue-${crypto.randomUUID()}`);
|
|
17
|
+
database = injector.resolve(Database);
|
|
18
|
+
});
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
await injector?.dispose();
|
|
21
|
+
});
|
|
22
|
+
it('should restore items if enqueueMany fails', async () => {
|
|
23
|
+
const batch = new TaskQueueEnqueueBatch(queue);
|
|
24
|
+
batch.add('t1', { val: 1 });
|
|
25
|
+
batch.add('t2', { val: 2 });
|
|
26
|
+
expect(batch.items.length).toBe(2);
|
|
27
|
+
// Mock enqueueMany to throw
|
|
28
|
+
vi.spyOn(queue, 'enqueueMany').mockRejectedValueOnce(new Error('db failure'));
|
|
29
|
+
await expect(batch.enqueue()).rejects.toThrow('db failure');
|
|
30
|
+
// Check items restored
|
|
31
|
+
expect(batch.items.length).toBe(2);
|
|
32
|
+
expect(batch.items[0].type).toBe('t1');
|
|
33
|
+
expect(batch.items[1].type).toBe('t2');
|
|
34
|
+
// Should work on second attempt (without mock)
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
const tasks = await batch.enqueue({ returnTasks: true });
|
|
37
|
+
expect(batch.items.length).toBe(0);
|
|
38
|
+
const t1 = await queue.getTask(tasks[0].id);
|
|
39
|
+
expect(t1).toBeDefined();
|
|
40
|
+
expect(t1?.type).toBe('t1');
|
|
41
|
+
});
|
|
42
|
+
it('should work correctly with queue.batch()', async () => {
|
|
43
|
+
const batch = queue.batch();
|
|
44
|
+
batch.add('b1', { x: 1 });
|
|
45
|
+
batch.add('b2', { x: 2 });
|
|
46
|
+
const tasks = await batch.enqueue({ returnTasks: true });
|
|
47
|
+
expect(tasks.length).toBe(2);
|
|
48
|
+
expect(batch.items.length).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
describe('enqueueMany Alignment and Dependencies', () => {
|
|
51
|
+
it('should correctly map dependencies when enqueuing a mix of tasks with and without idempotency keys', async () => {
|
|
52
|
+
const q = queueProvider.get(`q-${crypto.randomUUID()}`);
|
|
53
|
+
const items = [
|
|
54
|
+
{ type: 'test', data: { value: '1' } },
|
|
55
|
+
{ type: 'test', data: { value: '2' }, idempotencyKey: 'key-2' },
|
|
56
|
+
{ type: 'test', data: { value: '3' }, scheduleAfter: [0] },
|
|
57
|
+
{ type: 'test', data: { value: '4' }, idempotencyKey: 'key-4' },
|
|
58
|
+
{ type: 'test', data: { value: '5' }, scheduleAfter: [0] },
|
|
59
|
+
];
|
|
60
|
+
const tasks = await q.enqueueMany(items, { returnTasks: true });
|
|
61
|
+
expect(tasks).toHaveLength(5);
|
|
62
|
+
const task1Id = tasks[0].id;
|
|
63
|
+
const task3Id = tasks[2].id;
|
|
64
|
+
const task5Id = tasks[4].id;
|
|
65
|
+
const deps = await database
|
|
66
|
+
.select()
|
|
67
|
+
.from(taskDependencyTable)
|
|
68
|
+
.where(inArray(taskDependencyTable.taskId, [task3Id, task5Id]));
|
|
69
|
+
expect(deps).toHaveLength(2);
|
|
70
|
+
expect(deps.every(d => d.dependencyTaskId === task1Id)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
it('should correctly map dependencies when batch-enqueuing with pre-existing idempotency keys', async () => {
|
|
73
|
+
const q = queueProvider.get(`q-${crypto.randomUUID()}`);
|
|
74
|
+
const existingTask = await q.enqueue('test', { value: 'existing' }, { idempotencyKey: 'shared-key' });
|
|
75
|
+
const items = [
|
|
76
|
+
{ type: 'test', data: { value: 'new' } },
|
|
77
|
+
{ type: 'test', data: { value: 'duplicate' }, idempotencyKey: 'shared-key' },
|
|
78
|
+
{ type: 'test', data: { value: 'dependent' }, scheduleAfter: [1] },
|
|
79
|
+
];
|
|
80
|
+
const tasks = await q.enqueueMany(items, { returnTasks: true, replace: false });
|
|
81
|
+
expect(tasks[1].id).toBe(existingTask.id);
|
|
82
|
+
const dependentTaskId = tasks[2].id;
|
|
83
|
+
const deps = await database
|
|
84
|
+
.select()
|
|
85
|
+
.from(taskDependencyTable)
|
|
86
|
+
.where(eq(taskDependencyTable.taskId, dependentTaskId));
|
|
87
|
+
expect(deps).toHaveLength(1);
|
|
88
|
+
expect(deps[0].dependencyTaskId).toBe(existingTask.id);
|
|
89
|
+
});
|
|
90
|
+
it('should accurately increment parent unresolvedCompleteDependencies across multiple namespaces in a single batch', async () => {
|
|
91
|
+
const queueA = queueProvider.get(`QueueA-${crypto.randomUUID()}`);
|
|
92
|
+
const queueB = queueProvider.get(`QueueB-${crypto.randomUUID()}`);
|
|
93
|
+
const queueC = queueProvider.get(`QueueC-${crypto.randomUUID()}`);
|
|
94
|
+
const parentA = await queueA.enqueue('test', { value: 'parentA' });
|
|
95
|
+
const parentB = await queueB.enqueue('test', { value: 'parentB' });
|
|
96
|
+
await queueC.enqueueMany([
|
|
97
|
+
{ type: 'test', data: { value: 'child1' }, parentId: parentA.id },
|
|
98
|
+
{ type: 'test', data: { value: 'child2' }, parentId: parentA.id },
|
|
99
|
+
{ type: 'test', data: { value: 'child3' }, parentId: parentB.id },
|
|
100
|
+
]);
|
|
101
|
+
const upA = await queueA.getTask(parentA.id);
|
|
102
|
+
const upB = await queueB.getTask(parentB.id);
|
|
103
|
+
expect(upA?.unresolvedCompleteDependencies).toBe(2);
|
|
104
|
+
expect(upB?.unresolvedCompleteDependencies).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
it('should correctly map dependencies when using object with index', async () => {
|
|
107
|
+
const q = queueProvider.get(`q-${crypto.randomUUID()}`);
|
|
108
|
+
const items = [
|
|
109
|
+
{ type: 'test', data: { value: '1' } },
|
|
110
|
+
{ type: 'test', data: { value: '2' }, scheduleAfter: [0] },
|
|
111
|
+
{ type: 'test', data: { value: '3' }, scheduleAfter: [{ index: 0 }] },
|
|
112
|
+
];
|
|
113
|
+
const tasks = await q.enqueueMany(items, { returnTasks: true });
|
|
114
|
+
const task1Id = tasks[0].id;
|
|
115
|
+
const task2Id = tasks[1].id;
|
|
116
|
+
const task3Id = tasks[2].id;
|
|
117
|
+
const deps = await database
|
|
118
|
+
.select()
|
|
119
|
+
.from(taskDependencyTable)
|
|
120
|
+
.where(inArray(taskDependencyTable.taskId, [task2Id, task3Id]));
|
|
121
|
+
expect(deps).toHaveLength(2);
|
|
122
|
+
expect(deps.every(d => d.dependencyTaskId === task1Id)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { Database } from '../../orm/server/index.js';
|
|
2
3
|
import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
4
|
+
import { task as taskTable } from '../../task-queue/postgres/schemas.js';
|
|
3
5
|
import { setupIntegrationTest } from '../../testing/index.js';
|
|
4
|
-
import {
|
|
6
|
+
import { currentTimestamp } from '../../utils/date-time.js';
|
|
7
|
+
import { eq } from 'drizzle-orm';
|
|
5
8
|
describe('Fan-Out Spawning', () => {
|
|
6
9
|
let injector;
|
|
7
10
|
let queue;
|
|
11
|
+
let database;
|
|
8
12
|
beforeAll(async () => {
|
|
9
13
|
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
14
|
+
database = injector.resolve(Database);
|
|
10
15
|
});
|
|
11
16
|
beforeEach(() => {
|
|
12
17
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
13
|
-
const queueName = `fan-out-queue-${
|
|
18
|
+
const queueName = `fan-out-queue-${crypto.randomUUID()}`;
|
|
14
19
|
queue = queueProvider.get(queueName, {
|
|
15
20
|
visibilityTimeout: 1000,
|
|
16
21
|
});
|
|
@@ -40,14 +45,50 @@ describe('Fan-Out Spawning', () => {
|
|
|
40
45
|
const fParent = await queue.getTask(parent.id);
|
|
41
46
|
expect(fParent?.status).toBe(TaskStatus.Completed);
|
|
42
47
|
});
|
|
43
|
-
it('should NOT transition parent to Waiting if
|
|
48
|
+
it('should NOT transition parent to Waiting if blockParent is false', async () => {
|
|
44
49
|
const parent = await queue.enqueue('parent', {});
|
|
45
50
|
const dParent = await queue.dequeue();
|
|
46
|
-
// Spawn child with
|
|
47
|
-
await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id,
|
|
51
|
+
// Spawn child with blockParent: false
|
|
52
|
+
await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, blockParent: false }], { returnTasks: true });
|
|
48
53
|
await queue.complete(dParent);
|
|
49
54
|
const uParent = await queue.getTask(parent.id);
|
|
50
55
|
expect(uParent?.status).toBe(TaskStatus.Completed); // Finished immediately
|
|
51
56
|
expect(uParent?.unresolvedCompleteDependencies).toBe(0);
|
|
52
57
|
});
|
|
58
|
+
it('should transition parent from WaitingChildren to Completed exactly when the last child finishes', async () => {
|
|
59
|
+
const parent = await queue.enqueue('parent', {});
|
|
60
|
+
const c1 = await queue.enqueue('child', { val: 1 }, { parentId: parent.id });
|
|
61
|
+
const c2 = await queue.enqueue('child', { val: 2 }, { parentId: parent.id });
|
|
62
|
+
const dp = await queue.dequeue();
|
|
63
|
+
await queue.complete(dp);
|
|
64
|
+
const up1 = await queue.getTask(parent.id);
|
|
65
|
+
expect(up1?.status).toBe(TaskStatus.WaitingChildren);
|
|
66
|
+
const dc1 = await queue.dequeue();
|
|
67
|
+
await queue.complete(dc1);
|
|
68
|
+
const up2 = await queue.getTask(parent.id);
|
|
69
|
+
expect(up2?.status).toBe(TaskStatus.WaitingChildren);
|
|
70
|
+
const dc2 = await queue.dequeue();
|
|
71
|
+
await queue.complete(dc2);
|
|
72
|
+
const up3 = await queue.getTask(parent.id);
|
|
73
|
+
expect(up3?.status).toBe(TaskStatus.Completed);
|
|
74
|
+
});
|
|
75
|
+
it('should protect parent tasks from archival if orphaned child tasks still exist', async () => {
|
|
76
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
77
|
+
const ephemeralQueue = queueProvider.get(`ephemeral-${crypto.randomUUID()}`, { retention: 0 });
|
|
78
|
+
const parent = await ephemeralQueue.enqueue('parent', {});
|
|
79
|
+
await ephemeralQueue.enqueue('child', {}, { parentId: parent.id });
|
|
80
|
+
// Manually complete parent while child is still pending
|
|
81
|
+
await database
|
|
82
|
+
.update(taskTable)
|
|
83
|
+
.set({
|
|
84
|
+
status: TaskStatus.Completed,
|
|
85
|
+
completeTimestamp: currentTimestamp() - 1000,
|
|
86
|
+
})
|
|
87
|
+
.where(eq(taskTable.id, parent.id));
|
|
88
|
+
await ephemeralQueue.maintenance();
|
|
89
|
+
const updatedParent = await ephemeralQueue.getTask(parent.id);
|
|
90
|
+
expect(updatedParent).toBeDefined(); // Should still be in main table
|
|
91
|
+
expect(updatedParent?.status).toBe(TaskStatus.Completed);
|
|
92
|
+
await ephemeralQueue.clear();
|
|
93
|
+
});
|
|
53
94
|
});
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
1
2
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { Database } from '../../orm/server/index.js';
|
|
2
4
|
import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
5
|
+
import { taskDependency as taskDependencyTable } from '../../task-queue/postgres/schemas.js';
|
|
3
6
|
import { setupIntegrationTest } from '../../testing/index.js';
|
|
4
7
|
import { timeout } from '../../utils/timing.js';
|
|
5
8
|
describe('Idempotent Replacement', () => {
|
|
6
9
|
let injector;
|
|
7
10
|
let queue;
|
|
11
|
+
let database;
|
|
8
12
|
beforeAll(async () => {
|
|
9
13
|
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
14
|
+
database = injector.resolve(Database);
|
|
10
15
|
});
|
|
11
16
|
beforeEach(() => {
|
|
12
17
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
13
|
-
const queueName = `idempotent-queue-${
|
|
18
|
+
const queueName = `idempotent-queue-${crypto.randomUUID()}`;
|
|
14
19
|
queue = queueProvider.get(queueName, {
|
|
15
20
|
visibilityTimeout: 1000,
|
|
16
21
|
});
|
|
@@ -58,4 +63,52 @@ describe('Idempotent Replacement', () => {
|
|
|
58
63
|
const fTask = await queue.getTask(taskV1.id);
|
|
59
64
|
expect(fTask?.status).toBe(TaskStatus.Pending);
|
|
60
65
|
});
|
|
66
|
+
it('should update parentId on replacement', async () => {
|
|
67
|
+
const parent1 = await queue.enqueue('parent', { id: 1 });
|
|
68
|
+
const parent2 = await queue.enqueue('parent', { id: 2 });
|
|
69
|
+
const idempotencyKey = 'task-1';
|
|
70
|
+
const taskV1 = await queue.enqueue('child', {}, { idempotencyKey, parentId: parent1.id });
|
|
71
|
+
expect(taskV1.parentId).toBe(parent1.id);
|
|
72
|
+
// Replace with new parent
|
|
73
|
+
const taskV2 = await queue.enqueue('child', {}, { idempotencyKey, parentId: parent2.id, replace: true });
|
|
74
|
+
expect(taskV2.id).toBe(taskV1.id);
|
|
75
|
+
expect(taskV2.parentId).toBe(parent2.id);
|
|
76
|
+
});
|
|
77
|
+
it('should NOT corrupt state on idempotent enqueues (replace: false)', async () => {
|
|
78
|
+
const dep = await queue.enqueue('dep', {});
|
|
79
|
+
const idempotencyKey = 'task-2';
|
|
80
|
+
// Initial enqueue without dependency
|
|
81
|
+
const taskV1 = await queue.enqueue('main', {}, { idempotencyKey });
|
|
82
|
+
expect(taskV1.status).toBe(TaskStatus.Pending);
|
|
83
|
+
// Deduplicated enqueue with dependency
|
|
84
|
+
const taskV2 = await queue.enqueue('main', {}, { idempotencyKey, scheduleAfter: [dep.id], replace: false });
|
|
85
|
+
expect(taskV2.id).toBe(taskV1.id);
|
|
86
|
+
const task = await queue.getTask(taskV2.id);
|
|
87
|
+
expect(task?.status).toBe(TaskStatus.Pending);
|
|
88
|
+
expect(task?.unresolvedScheduleDependencies).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
it('should NOT orphan children on task replacement', async () => {
|
|
91
|
+
const idempotencyKey = 'parent-task';
|
|
92
|
+
const parent = await queue.enqueue('parent', {}, { idempotencyKey });
|
|
93
|
+
// Enqueue child
|
|
94
|
+
await queue.enqueue('child', {}, { parentId: parent.id });
|
|
95
|
+
const parentAfterChild = await queue.getTask(parent.id);
|
|
96
|
+
expect(parentAfterChild?.unresolvedCompleteDependencies).toBe(1);
|
|
97
|
+
// Replace parent
|
|
98
|
+
const replacedParent = await queue.enqueue('parent', { replaced: true }, { idempotencyKey, replace: true });
|
|
99
|
+
expect(replacedParent.unresolvedCompleteDependencies).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
it('should clear and recreate dependencies when enqueuing existing tasks with replace: true', async () => {
|
|
102
|
+
const dep1 = await queue.enqueue('test', { value: 'dep1' });
|
|
103
|
+
const dep2 = await queue.enqueue('test', { value: 'dep2' });
|
|
104
|
+
const task = await queue.enqueue('test', { value: 'main' }, { idempotencyKey: 'main-key', scheduleAfter: [dep1.id] });
|
|
105
|
+
// Re-enqueue with replace: true and different dependency
|
|
106
|
+
await queue.enqueue('test', { value: 'main-replaced' }, { idempotencyKey: 'main-key', replace: true, scheduleAfter: [dep2.id] });
|
|
107
|
+
const deps = await database
|
|
108
|
+
.select()
|
|
109
|
+
.from(taskDependencyTable)
|
|
110
|
+
.where(eq(taskDependencyTable.taskId, task.id));
|
|
111
|
+
expect(deps).toHaveLength(1);
|
|
112
|
+
expect(deps[0].dependencyTaskId).toBe(dep2.id);
|
|
113
|
+
});
|
|
61
114
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
-
import { TaskQueueProvider } from '../../task-queue/index.js';
|
|
2
|
+
import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
3
3
|
import { setupIntegrationTest } from '../../testing/index.js';
|
|
4
4
|
describe('Missing Idempotent Tasks Bug', () => {
|
|
5
5
|
let injector;
|
|
@@ -9,7 +9,7 @@ describe('Missing Idempotent Tasks Bug', () => {
|
|
|
9
9
|
});
|
|
10
10
|
beforeEach(() => {
|
|
11
11
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
12
|
-
const queueName = `missing-idem-queue-${
|
|
12
|
+
const queueName = `missing-idem-queue-${crypto.randomUUID()}`;
|
|
13
13
|
queue = queueProvider.get(queueName, {
|
|
14
14
|
visibilityTimeout: 1000,
|
|
15
15
|
});
|
|
@@ -24,15 +24,16 @@ describe('Missing Idempotent Tasks Bug', () => {
|
|
|
24
24
|
const key = 'idem-key';
|
|
25
25
|
const dep = await queue.enqueue('dep', {});
|
|
26
26
|
// 1. Initial enqueue
|
|
27
|
-
await queue.enqueue('main', {}, { idempotencyKey: key });
|
|
27
|
+
const task1 = await queue.enqueue('main', {}, { idempotencyKey: key });
|
|
28
28
|
// 2. Second enqueue with same key and a dependency, returnTasks: false
|
|
29
29
|
// This should NOT crash.
|
|
30
|
-
//
|
|
31
|
-
// a) Ignore the new dependency because it's a duplicate and replace=false
|
|
32
|
-
// b) Add the dependency to the existing task (though replace=false usually means ignore)
|
|
33
|
-
// The current bug is that it CRASHES with TypeError.
|
|
30
|
+
// replace=false means we should ignore the new data AND the new dependency for the existing task.
|
|
34
31
|
await expect(queue.enqueueMany([
|
|
35
|
-
{ type: 'main', data: {}, idempotencyKey: key, scheduleAfter: [dep.id] }
|
|
32
|
+
{ type: 'main', data: { new: 'data' }, idempotencyKey: key, scheduleAfter: [dep.id] }
|
|
36
33
|
], { replace: false, returnTasks: false })).resolves.toBeUndefined();
|
|
34
|
+
const updatedTask = await queue.getTask(task1.id);
|
|
35
|
+
expect(updatedTask?.data).toEqual({}); // Data should not have changed
|
|
36
|
+
expect(updatedTask?.unresolvedScheduleDependencies).toBe(0); // Dependency should not have been added
|
|
37
|
+
expect(updatedTask?.status).toBe(TaskStatus.Pending); // Should still be Pending, not Waiting
|
|
37
38
|
});
|
|
38
39
|
});
|