@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
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { eq, or } from 'drizzle-orm';
|
|
1
2
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
2
3
|
import { CancellationToken } from '../../cancellation/index.js';
|
|
4
|
+
import { Database } from '../../orm/server/index.js';
|
|
3
5
|
import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
6
|
+
import { task as taskTable } from '../../task-queue/postgres/schemas.js';
|
|
4
7
|
import { setupIntegrationTest } from '../../testing/index.js';
|
|
5
8
|
import { currentTimestamp } from '../../utils/date-time.js';
|
|
6
9
|
import { Timer } from '../../utils/timer.js';
|
|
@@ -70,7 +73,7 @@ describe('Queue Integration Tests', () => {
|
|
|
70
73
|
await queue.complete(second);
|
|
71
74
|
});
|
|
72
75
|
it('Deduplication (Idempotency Keys)', async () => {
|
|
73
|
-
const key = `unique-${
|
|
76
|
+
const key = `unique-${crypto.randomUUID()}`;
|
|
74
77
|
// 1. Initial Insert
|
|
75
78
|
const t1 = await queue.enqueue('test', { value: 'original' }, { idempotencyKey: key });
|
|
76
79
|
// 2. Default Strategy (replace: false): Should return existing task, ignore new data
|
|
@@ -92,6 +95,8 @@ describe('Queue Integration Tests', () => {
|
|
|
92
95
|
const attempt1 = await queue.dequeue();
|
|
93
96
|
expect(attempt1?.id).toBe(task.id);
|
|
94
97
|
await queue.fail(attempt1, { message: 'oops' });
|
|
98
|
+
// Verify Retrying status
|
|
99
|
+
await assertTaskStatus(task.id, TaskStatus.Retrying, 'Task enters Retrying status after failure');
|
|
95
100
|
// Force reschedule to now to bypass retryDelay
|
|
96
101
|
await queue.reschedule(task.id, currentTimestamp());
|
|
97
102
|
// Try 2
|
|
@@ -131,12 +136,14 @@ describe('Queue Integration Tests', () => {
|
|
|
131
136
|
describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
132
137
|
let injector;
|
|
133
138
|
let queue;
|
|
139
|
+
let database;
|
|
134
140
|
beforeAll(async () => {
|
|
135
141
|
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
142
|
+
database = injector.resolve(Database);
|
|
136
143
|
});
|
|
137
144
|
beforeEach(() => {
|
|
138
145
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
139
|
-
const queueName = `pg-test-queue-${
|
|
146
|
+
const queueName = `pg-test-queue-${crypto.randomUUID()}`;
|
|
140
147
|
queue = queueProvider.get(queueName, {
|
|
141
148
|
visibilityTimeout: 50, // Short timeout for testing
|
|
142
149
|
retryDelayMinimum: 50,
|
|
@@ -174,11 +181,96 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
174
181
|
const dequeued = await queue.dequeue();
|
|
175
182
|
await queue.fail(dequeued, new Error('temp failure'));
|
|
176
183
|
const updated = await queue.getTask(task.id);
|
|
177
|
-
expect(updated?.status).toBe(TaskStatus.
|
|
184
|
+
expect(updated?.status).toBe(TaskStatus.Retrying);
|
|
178
185
|
expect(updated?.tries).toBe(1);
|
|
179
186
|
expect(updated?.error).toBeDefined();
|
|
180
187
|
});
|
|
181
188
|
});
|
|
189
|
+
describe('Hierarchy and Cross-Namespace', () => {
|
|
190
|
+
it('should correctly increment parent unresolved dependencies when a child is spawned in a different namespace', async () => {
|
|
191
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
192
|
+
const nameA = `QueueA-${crypto.randomUUID()}`;
|
|
193
|
+
const nameB = `QueueB-${crypto.randomUUID()}`;
|
|
194
|
+
const queueA = queueProvider.get(nameA);
|
|
195
|
+
const queueB = queueProvider.get(nameB);
|
|
196
|
+
const parent = await queueA.enqueue('test', { value: 'parent' });
|
|
197
|
+
expect(parent.unresolvedCompleteDependencies).toBe(0);
|
|
198
|
+
await queueB.enqueue('test', { value: 'child' }, { parentId: parent.id, blockParent: true });
|
|
199
|
+
const updatedParent = await queueA.getTask(parent.id);
|
|
200
|
+
expect(updatedParent?.unresolvedCompleteDependencies).toBe(1);
|
|
201
|
+
await database.update(taskTable).set({ parentId: null }).where(or(eq(taskTable.namespace, nameA), eq(taskTable.namespace, nameB)));
|
|
202
|
+
await queueB.clear();
|
|
203
|
+
await queueA.clear();
|
|
204
|
+
});
|
|
205
|
+
it('should recursively cancel the entire task tree spanning multiple namespaces', async () => {
|
|
206
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
207
|
+
const nameA = `NamespaceA-${crypto.randomUUID()}`;
|
|
208
|
+
const nameB = `NamespaceB-${crypto.randomUUID()}`;
|
|
209
|
+
const queueA = queueProvider.get(nameA);
|
|
210
|
+
const queueB = queueProvider.get(nameB);
|
|
211
|
+
const parent = await queueA.enqueue('test', { value: 'parent' });
|
|
212
|
+
const child = await queueB.enqueue('test', { value: 'child' }, { parentId: parent.id });
|
|
213
|
+
const grandchild = await queueA.enqueue('test', { value: 'grandchild' }, { parentId: child.id });
|
|
214
|
+
await queueA.cancel(parent.id);
|
|
215
|
+
await queueB.waitForTasks([child.id], { statuses: [TaskStatus.Cancelled] });
|
|
216
|
+
await queueA.waitForTasks([grandchild.id], { statuses: [TaskStatus.Cancelled] });
|
|
217
|
+
const updatedChild = await queueB.getTask(child.id);
|
|
218
|
+
const updatedGrandchild = await queueA.getTask(grandchild.id);
|
|
219
|
+
expect(updatedChild?.status).toBe(TaskStatus.Cancelled);
|
|
220
|
+
expect(updatedGrandchild?.status).toBe(TaskStatus.Cancelled);
|
|
221
|
+
await database.update(taskTable).set({ parentId: null }).where(or(eq(taskTable.namespace, nameA), eq(taskTable.namespace, nameB)));
|
|
222
|
+
await queueB.clear();
|
|
223
|
+
await queueA.clear();
|
|
224
|
+
});
|
|
225
|
+
it('should successfully cancel tasks from other namespaces (Bug 4)', async () => {
|
|
226
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
227
|
+
const nameA = `QueueA-${crypto.randomUUID()}`;
|
|
228
|
+
const nameB = `QueueB-${crypto.randomUUID()}`;
|
|
229
|
+
const queueA = queueProvider.get(nameA);
|
|
230
|
+
const queueB = queueProvider.get(nameB);
|
|
231
|
+
// 1. Enqueue task in queueB
|
|
232
|
+
const taskB = await queueB.enqueue('test', {});
|
|
233
|
+
expect(taskB.status).toBe(TaskStatus.Pending);
|
|
234
|
+
// 2. Cancel task in queueA (using taskB.id which is from other namespace)
|
|
235
|
+
await queueA.cancel(taskB.id);
|
|
236
|
+
// 3. Verify taskB IS cancelled (because ID is global)
|
|
237
|
+
const updatedB = await queueB.getTask(taskB.id);
|
|
238
|
+
expect(updatedB?.status).toBe(TaskStatus.Cancelled);
|
|
239
|
+
await database.update(taskTable).set({ parentId: null }).where(or(eq(taskTable.namespace, nameA), eq(taskTable.namespace, nameB)));
|
|
240
|
+
await queueB.clear();
|
|
241
|
+
await queueA.clear();
|
|
242
|
+
});
|
|
243
|
+
it('should complete parent task if idempotent child is already completed (Bug 6)', async () => {
|
|
244
|
+
const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
|
|
245
|
+
// 1. Enqueue and complete child task with idempotency key
|
|
246
|
+
const idempotencyKey = 'child-idempotency-key';
|
|
247
|
+
const child = await q.enqueue('test', {}, { idempotencyKey });
|
|
248
|
+
const dequeuedChild = await q.dequeue();
|
|
249
|
+
await q.complete(dequeuedChild);
|
|
250
|
+
const finishedChild = await q.getTask(child.id);
|
|
251
|
+
expect(finishedChild?.status).toBe(TaskStatus.Completed);
|
|
252
|
+
// 2. Enqueue parent task that spawns the same child (via idempotency key)
|
|
253
|
+
const parent = await q.enqueue('test', {});
|
|
254
|
+
await q.enqueue('test', {}, {
|
|
255
|
+
idempotencyKey,
|
|
256
|
+
parentId: parent.id,
|
|
257
|
+
blockParent: true,
|
|
258
|
+
});
|
|
259
|
+
// Dequeue and complete parent
|
|
260
|
+
const dequeuedParent = await q.dequeue();
|
|
261
|
+
await q.complete(dequeuedParent);
|
|
262
|
+
const updatedParent = await q.getTask(parent.id);
|
|
263
|
+
expect(updatedParent?.status).toBe(TaskStatus.Completed);
|
|
264
|
+
await q.clear();
|
|
265
|
+
});
|
|
266
|
+
it('should increment unresolvedCompleteDependencies for children with blockParent: true (Bug 6-2)', async () => {
|
|
267
|
+
const parent = await queue.enqueue('parent', {});
|
|
268
|
+
expect(parent.unresolvedCompleteDependencies).toBe(0);
|
|
269
|
+
await queue.enqueue('child', {}, { parentId: parent.id, blockParent: true });
|
|
270
|
+
const updatedParent = await queue.getTask(parent.id);
|
|
271
|
+
expect(updatedParent?.unresolvedCompleteDependencies).toBe(1);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
182
274
|
describe('Priority and Scheduling', () => {
|
|
183
275
|
it('should dequeue tasks in priority order (lower number first)', async () => {
|
|
184
276
|
await queue.enqueue('foo', { foo: 'low' }, { priority: 2000 });
|
|
@@ -202,24 +294,18 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
202
294
|
});
|
|
203
295
|
});
|
|
204
296
|
describe('Concurrency Control', () => {
|
|
205
|
-
it('should
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
expect(t2).toBeDefined();
|
|
218
|
-
expect(t3).toBeUndefined(); // Limit reached
|
|
219
|
-
await limitedQueue.complete(t1);
|
|
220
|
-
const t3Retry = await limitedQueue.dequeue();
|
|
221
|
-
expect(t3Retry).toBeDefined(); // Slot opened
|
|
222
|
-
await limitedQueue.clear();
|
|
297
|
+
it('should refund the rate limiter tokens when dequeueMany retrieves fewer tasks than requested', async () => {
|
|
298
|
+
const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`, {
|
|
299
|
+
rateLimit: 5,
|
|
300
|
+
rateInterval: 60000,
|
|
301
|
+
});
|
|
302
|
+
await q.enqueue('test', { value: '1' });
|
|
303
|
+
const tasks1 = await q.dequeueMany(5); // Consumes 5, gets 1, refunds 4.
|
|
304
|
+
expect(tasks1).toHaveLength(1);
|
|
305
|
+
await q.enqueue('test', { value: '2' });
|
|
306
|
+
const tasks2 = await q.dequeueMany(4); // Should be allowed because of refund.
|
|
307
|
+
expect(tasks2).toHaveLength(1);
|
|
308
|
+
await q.clear();
|
|
223
309
|
});
|
|
224
310
|
});
|
|
225
311
|
describe('Circuit Breaker', () => {
|
|
@@ -257,21 +343,21 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
257
343
|
await timeout(100);
|
|
258
344
|
await queue.maintenance();
|
|
259
345
|
const recovered = await queue.getTask(task.id);
|
|
260
|
-
expect(recovered?.status).toBe(TaskStatus.
|
|
346
|
+
expect(recovered?.status).toBe(TaskStatus.Retrying);
|
|
261
347
|
expect(recovered?.tries).toBe(1);
|
|
262
348
|
expect(recovered?.token).toBeNull();
|
|
263
349
|
});
|
|
264
350
|
it('should fail tasks that exceed Hard Execution Timeout via prune', async () => {
|
|
265
351
|
// Re-configure queue with very short execution timeout
|
|
266
352
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
267
|
-
const shortQueue = queueProvider.get(`prune-test-${
|
|
353
|
+
const shortQueue = queueProvider.get(`prune-test-${crypto.randomUUID()}`, { maxExecutionTime: 50 });
|
|
268
354
|
const task = await shortQueue.enqueue('foo', { foo: 'long-running' });
|
|
269
355
|
await shortQueue.dequeue();
|
|
270
356
|
await timeout(75);
|
|
271
357
|
await shortQueue.maintenance();
|
|
272
358
|
const updated = await shortQueue.getTask(task.id);
|
|
273
|
-
expect(updated?.status).toBe(TaskStatus.
|
|
274
|
-
expect(updated?.error?.
|
|
359
|
+
expect(updated?.status).toBe(TaskStatus.TimedOut);
|
|
360
|
+
expect(updated?.error?.code).toBe('MaxTimeExceeded');
|
|
275
361
|
await shortQueue.clear();
|
|
276
362
|
});
|
|
277
363
|
it('should touch a task to extend token', async () => {
|
|
@@ -294,6 +380,46 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
294
380
|
const touchResult = await queue.touch(dequeued);
|
|
295
381
|
expect(touchResult).toBeUndefined();
|
|
296
382
|
});
|
|
383
|
+
it('should correctly identify and transition zombie tasks to Orphaned after max retries', async () => {
|
|
384
|
+
const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`, { maxTries: 1 });
|
|
385
|
+
const task = await q.enqueue('test', { value: 'zombie' });
|
|
386
|
+
// Dequeue to start it
|
|
387
|
+
await q.dequeue();
|
|
388
|
+
// Manually make it a zombie
|
|
389
|
+
await database
|
|
390
|
+
.update(taskTable)
|
|
391
|
+
.set({ visibilityDeadline: currentTimestamp() - 1000 })
|
|
392
|
+
.where(eq(taskTable.id, task.id));
|
|
393
|
+
await q.maintenance();
|
|
394
|
+
const updated = await q.getTask(task.id);
|
|
395
|
+
expect(updated?.status).toBe(TaskStatus.Orphaned);
|
|
396
|
+
await q.clear();
|
|
397
|
+
});
|
|
398
|
+
it('should age priority correctly', async () => {
|
|
399
|
+
const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`, {
|
|
400
|
+
priorityAgingInterval: 0,
|
|
401
|
+
priorityAgingStep: 10,
|
|
402
|
+
});
|
|
403
|
+
const task = await q.enqueue('test', { value: 'aging' }, { priority: 100 });
|
|
404
|
+
// Manually backdate priorityAgeTimestamp
|
|
405
|
+
await database
|
|
406
|
+
.update(taskTable)
|
|
407
|
+
.set({ priorityAgeTimestamp: currentTimestamp() - 1000 })
|
|
408
|
+
.where(eq(taskTable.id, task.id));
|
|
409
|
+
await q.maintenance();
|
|
410
|
+
const updated = await q.getTask(task.id);
|
|
411
|
+
expect(updated?.priority).toBe(90);
|
|
412
|
+
await q.clear();
|
|
413
|
+
});
|
|
414
|
+
it('clear() should NOT throw foreign key violations', async () => {
|
|
415
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
416
|
+
const otherQueue = queueProvider.get('other-namespace', {});
|
|
417
|
+
const parent = await queue.enqueue('parent', {});
|
|
418
|
+
await otherQueue.enqueue('child', {}, { parentId: parent.id });
|
|
419
|
+
// Clearing the queue containing the parent should NOT throw even if other-namespace has a child pointing to it.
|
|
420
|
+
await otherQueue.clear();
|
|
421
|
+
await expect(queue.clear()).resolves.toBeUndefined();
|
|
422
|
+
});
|
|
297
423
|
});
|
|
298
424
|
describe('Batch Operations', () => {
|
|
299
425
|
it('should complete many tasks efficiently', async () => {
|
|
@@ -309,6 +435,62 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
309
435
|
expect(t1?.status).toBe(TaskStatus.Completed);
|
|
310
436
|
expect(t2?.status).toBe(TaskStatus.Completed);
|
|
311
437
|
});
|
|
438
|
+
it('should successfully completeMany tasks that have a NULL token', async () => {
|
|
439
|
+
const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
|
|
440
|
+
const t1 = await q.enqueue('test', { value: '1' });
|
|
441
|
+
const t2 = await q.enqueue('test', { value: '2' });
|
|
442
|
+
expect(t1.token).toBeNull();
|
|
443
|
+
expect(t2.token).toBeNull();
|
|
444
|
+
await q.completeMany([t1, t2]);
|
|
445
|
+
const ut1 = await q.getTask(t1.id);
|
|
446
|
+
const ut2 = await q.getTask(t2.id);
|
|
447
|
+
expect(ut1?.status).toBe(TaskStatus.Completed);
|
|
448
|
+
expect(ut2?.status).toBe(TaskStatus.Completed);
|
|
449
|
+
await q.clear();
|
|
450
|
+
});
|
|
451
|
+
it('should successfully failMany tasks that have a NULL token', async () => {
|
|
452
|
+
const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`, { maxTries: 0 });
|
|
453
|
+
const t1 = await q.enqueue('test', { value: '1' });
|
|
454
|
+
await q.failMany([t1], [new Error('fail')]);
|
|
455
|
+
const ut1 = await q.getTask(t1.id);
|
|
456
|
+
expect(ut1?.status).toBe(TaskStatus.Dead);
|
|
457
|
+
await q.clear();
|
|
458
|
+
});
|
|
459
|
+
it('should successfully touchMany tasks with missing or NULL states', async () => {
|
|
460
|
+
const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
|
|
461
|
+
const t1 = await q.enqueue('test', { value: '1' });
|
|
462
|
+
const t2 = await q.enqueue('test', { value: '2' });
|
|
463
|
+
const d1 = await q.dequeue();
|
|
464
|
+
const d2 = await q.dequeue();
|
|
465
|
+
await q.touchMany([d1, d2], {
|
|
466
|
+
progresses: [0.5, 0.8],
|
|
467
|
+
states: [{ step: 'halfway' }, undefined],
|
|
468
|
+
});
|
|
469
|
+
const ut1 = await q.getTask(t1.id);
|
|
470
|
+
const ut2 = await q.getTask(t2.id);
|
|
471
|
+
expect(ut1?.progress).toBe(0.5);
|
|
472
|
+
expect(ut1?.state).toEqual({ step: 'halfway' });
|
|
473
|
+
expect(ut2?.progress).toBe(0.8);
|
|
474
|
+
expect(ut2?.state).toBeNull();
|
|
475
|
+
await q.clear();
|
|
476
|
+
});
|
|
477
|
+
it('should reject bulk updates (completeMany/failMany) if the provided token does not match the database', async () => {
|
|
478
|
+
const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
|
|
479
|
+
const t1 = await q.enqueue('test', { value: '1' });
|
|
480
|
+
// Task is Pending, token is NULL.
|
|
481
|
+
// Try to complete with a fake token.
|
|
482
|
+
await q.completeMany([{ ...t1, token: crypto.randomUUID() }]);
|
|
483
|
+
const ut1 = await q.getTask(t1.id);
|
|
484
|
+
expect(ut1?.status).toBe(TaskStatus.Pending); // Should still be Pending
|
|
485
|
+
// Dequeue to get a real token
|
|
486
|
+
const d1 = await q.dequeue();
|
|
487
|
+
expect(d1?.token).toBeDefined();
|
|
488
|
+
// Try to complete with NULL token
|
|
489
|
+
await q.completeMany([{ ...d1, token: null }]);
|
|
490
|
+
const ut2 = await q.getTask(t1.id);
|
|
491
|
+
expect(ut2?.status).toBe(TaskStatus.Running); // Should still be Running
|
|
492
|
+
await q.clear();
|
|
493
|
+
});
|
|
312
494
|
});
|
|
313
495
|
describe('Rescheduling', () => {
|
|
314
496
|
it('should reschedule and refund tries if running', async () => {
|
|
@@ -322,6 +504,15 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
322
504
|
expect(updated?.tries).toBe(0); // Refunded
|
|
323
505
|
expect(updated?.scheduleTimestamp).toBe(inFuture);
|
|
324
506
|
});
|
|
507
|
+
it('rescheduling should NOT bypass dependency constraints (Bug 7)', async () => {
|
|
508
|
+
const dep = await queue.enqueue('dep', {});
|
|
509
|
+
const main = await queue.enqueue('main', {}, { scheduleAfter: [dep.id] });
|
|
510
|
+
expect(main.status).toBe(TaskStatus.Waiting);
|
|
511
|
+
expect(main.unresolvedScheduleDependencies).toBe(1);
|
|
512
|
+
await queue.reschedule(main.id, currentTimestamp());
|
|
513
|
+
const updatedMain = await queue.getTask(main.id);
|
|
514
|
+
expect(updatedMain?.status).toBe(TaskStatus.Waiting);
|
|
515
|
+
});
|
|
325
516
|
});
|
|
326
517
|
describe('TaskContext (Worker DX)', () => {
|
|
327
518
|
it('checkpoint() should update progress and handle token loss', async () => {
|
|
@@ -451,4 +642,49 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
451
642
|
expect(finalParent?.status).toBe(TaskStatus.Completed);
|
|
452
643
|
});
|
|
453
644
|
});
|
|
645
|
+
describe('Bugs and Edge Cases', () => {
|
|
646
|
+
it('should NOT overwrite terminal states during cancellation (Bug 3)', async () => {
|
|
647
|
+
const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
|
|
648
|
+
// 1. Enqueue parent and two children
|
|
649
|
+
const parent = await q.enqueue('parent', {});
|
|
650
|
+
const child1 = await q.enqueue('child', {}, { parentId: parent.id });
|
|
651
|
+
const child2 = await q.enqueue('child', {}, { parentId: parent.id });
|
|
652
|
+
// 2. Complete child1
|
|
653
|
+
const dequeued1 = await q.dequeue({ types: ['child'] });
|
|
654
|
+
await q.complete(dequeued1);
|
|
655
|
+
const finished1 = await q.getTask(child1.id);
|
|
656
|
+
expect(finished1?.status).toBe(TaskStatus.Completed);
|
|
657
|
+
// 3. Cancel parent tree
|
|
658
|
+
await q.cancel(parent.id);
|
|
659
|
+
// 4. Verify child1 is STILL Completed, not Cancelled
|
|
660
|
+
const updated1 = await q.getTask(child1.id);
|
|
661
|
+
expect(updated1?.status).toBe(TaskStatus.Completed);
|
|
662
|
+
// 5. Verify parent and child2 ARE Cancelled
|
|
663
|
+
const updatedParent = await q.getTask(parent.id);
|
|
664
|
+
const updated2 = await q.getTask(child2.id);
|
|
665
|
+
expect(updatedParent?.status).toBe(TaskStatus.Cancelled);
|
|
666
|
+
expect(updated2?.status).toBe(TaskStatus.Cancelled);
|
|
667
|
+
await q.clear();
|
|
668
|
+
});
|
|
669
|
+
it('should expire tasks in Waiting status (Bug 5)', async () => {
|
|
670
|
+
const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
|
|
671
|
+
// 1. Enqueue task A (will never complete)
|
|
672
|
+
const taskA = await q.enqueue('test', {});
|
|
673
|
+
// 2. Enqueue task B with scheduleAfter: [A.id] and short TTL
|
|
674
|
+
const taskB = await q.enqueue('test', {}, {
|
|
675
|
+
scheduleAfter: [taskA.id],
|
|
676
|
+
timeToLive: 100, // 100ms
|
|
677
|
+
});
|
|
678
|
+
expect(taskB.status).toBe(TaskStatus.Waiting);
|
|
679
|
+
// 3. Wait for TTL to pass
|
|
680
|
+
await timeout(200);
|
|
681
|
+
// 4. Run maintenance
|
|
682
|
+
await q.maintenance();
|
|
683
|
+
// 5. Verify task B is Expired
|
|
684
|
+
await q.waitForTasks([taskB.id]);
|
|
685
|
+
const updatedB = await q.getTask(taskB.id);
|
|
686
|
+
expect(updatedB?.status).toBe(TaskStatus.Expired);
|
|
687
|
+
await q.clear();
|
|
688
|
+
});
|
|
689
|
+
});
|
|
454
690
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { CancellationSignal } from '../../cancellation/token.js';
|
|
3
|
+
import { DeferredPromise } from '../../promise/deferred-promise.js';
|
|
4
|
+
import { TaskProcessResult, TaskQueueProvider } from '../../task-queue/index.js';
|
|
5
|
+
import { setupIntegrationTest } from '../../testing/index.js';
|
|
6
|
+
import { timeout } from '../../utils/timing.js';
|
|
7
|
+
describe('Graceful Worker Shutdown', () => {
|
|
8
|
+
let injector;
|
|
9
|
+
let queue;
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
12
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
13
|
+
queue = queueProvider.get(`shutdown-queue-${crypto.randomUUID()}`);
|
|
14
|
+
});
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await injector?.dispose();
|
|
17
|
+
});
|
|
18
|
+
it('should wait for active handler to finish on disposal', async () => {
|
|
19
|
+
const cancellationSignal = injector.resolve(CancellationSignal);
|
|
20
|
+
await queue.enqueue('shutdown-test', {});
|
|
21
|
+
let handlerFinished = false;
|
|
22
|
+
const handlerStarted = new DeferredPromise();
|
|
23
|
+
queue.process({ cancellationSignal }, async () => {
|
|
24
|
+
handlerStarted.resolve();
|
|
25
|
+
await timeout(300); // simulate work
|
|
26
|
+
handlerFinished = true;
|
|
27
|
+
return TaskProcessResult.Complete();
|
|
28
|
+
});
|
|
29
|
+
queue.notify();
|
|
30
|
+
// Wait for worker to pick it up
|
|
31
|
+
await handlerStarted;
|
|
32
|
+
// Call disposal
|
|
33
|
+
const disposePromise = injector.dispose();
|
|
34
|
+
// Check that disposal waits for the handler
|
|
35
|
+
expect(handlerFinished).toBe(false);
|
|
36
|
+
await disposePromise;
|
|
37
|
+
expect(handlerFinished).toBe(true);
|
|
38
|
+
// Note: We can't call queue.getTask here because pool is closed.
|
|
39
|
+
// But handlerFinished = true and successful disposePromise already prove it waited.
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
3
|
+
import { setupIntegrationTest } from '../../testing/index.js';
|
|
4
|
+
describe('Task Queue Transactions', () => {
|
|
5
|
+
let injector;
|
|
6
|
+
let queue;
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
9
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
10
|
+
queue = queueProvider.get(`tx-queue-${crypto.randomUUID()}`);
|
|
11
|
+
});
|
|
12
|
+
afterAll(async () => {
|
|
13
|
+
await injector?.dispose();
|
|
14
|
+
});
|
|
15
|
+
it('should respect transaction rollbacks', async () => {
|
|
16
|
+
let taskId;
|
|
17
|
+
await queue.transaction(async (tx) => {
|
|
18
|
+
const task = await queue.enqueue('tx-task', { foo: 'bar' }, { transaction: tx });
|
|
19
|
+
taskId = task.id;
|
|
20
|
+
const hasTask = await queue.has(taskId, { transaction: tx });
|
|
21
|
+
expect(hasTask).toBe(true);
|
|
22
|
+
await tx.rollback();
|
|
23
|
+
});
|
|
24
|
+
const hasTaskAfterRollback = await queue.has(taskId);
|
|
25
|
+
expect(hasTaskAfterRollback).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
it('should participate in external transaction (commit)', async () => {
|
|
28
|
+
let taskId;
|
|
29
|
+
await queue.transaction(async (tx) => {
|
|
30
|
+
const task = await queue.enqueue('tx-commit', { foo: 'bar' }, { transaction: tx });
|
|
31
|
+
taskId = task.id;
|
|
32
|
+
});
|
|
33
|
+
const hasTaskAfterCommit = await queue.has(taskId);
|
|
34
|
+
expect(hasTaskAfterCommit).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
it('should handle multiple operations in one transaction', async () => {
|
|
37
|
+
await queue.transaction(async (tx) => {
|
|
38
|
+
const t1 = await queue.enqueue('t1', {}, { transaction: tx });
|
|
39
|
+
const t2 = await queue.enqueue('t2', {}, { transaction: tx });
|
|
40
|
+
await queue.cancel(t1.id, { transaction: tx });
|
|
41
|
+
const u1 = await queue.getTask(t1.id, { transaction: tx });
|
|
42
|
+
expect(u1?.status).toBe(TaskStatus.Cancelled);
|
|
43
|
+
const u2 = await queue.getTask(t2.id, { transaction: tx });
|
|
44
|
+
expect(u2?.status).toBe(TaskStatus.Pending);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
2
2
|
import { CancellationToken } from '../../cancellation/index.js';
|
|
3
|
+
import { runInInjectionContext } from '../../injector/index.js';
|
|
4
|
+
import { injectRepository } from '../../orm/server/index.js';
|
|
3
5
|
import { TaskProcessResult, TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
6
|
+
import { PostgresTask } from '../../task-queue/postgres/index.js';
|
|
4
7
|
import { setupIntegrationTest } from '../../testing/index.js';
|
|
5
8
|
import { timeout } from '../../utils/timing.js';
|
|
6
9
|
describe('Worker & Base Class Tests', () => {
|
|
7
10
|
let injector;
|
|
8
11
|
let queue;
|
|
9
12
|
let token;
|
|
13
|
+
let otherQueueName;
|
|
10
14
|
beforeAll(async () => {
|
|
11
15
|
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
16
|
+
otherQueueName = `other-queue-${crypto.randomUUID()}`;
|
|
12
17
|
});
|
|
13
18
|
beforeEach(() => {
|
|
14
19
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
15
|
-
const queueName = `worker-queue-${
|
|
20
|
+
const queueName = `worker-queue-${crypto.randomUUID()}`;
|
|
16
21
|
queue = queueProvider.get(queueName, {
|
|
17
22
|
visibilityTimeout: 200, // Short visibility for testing lease loss
|
|
18
23
|
});
|
|
@@ -20,9 +25,14 @@ describe('Worker & Base Class Tests', () => {
|
|
|
20
25
|
});
|
|
21
26
|
afterEach(async () => {
|
|
22
27
|
token.set();
|
|
28
|
+
const repository = runInInjectionContext(injector, () => injectRepository(PostgresTask));
|
|
29
|
+
const namespace = queue.getTransactionalContextData().namespace;
|
|
30
|
+
// Clear foreign keys
|
|
31
|
+
await repository.updateManyByQuery({ namespace }, { parentId: null });
|
|
32
|
+
await repository.updateManyByQuery({ namespace: otherQueueName }, { parentId: null });
|
|
23
33
|
await queue.clear();
|
|
24
34
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
25
|
-
await queueProvider.get(
|
|
35
|
+
await queueProvider.get(otherQueueName).clear();
|
|
26
36
|
});
|
|
27
37
|
afterAll(async () => {
|
|
28
38
|
await injector?.dispose();
|
|
@@ -39,6 +49,7 @@ describe('Worker & Base Class Tests', () => {
|
|
|
39
49
|
for (let i = 0; i < 50; i++) {
|
|
40
50
|
if (processed.length == 2)
|
|
41
51
|
break;
|
|
52
|
+
queue.notify();
|
|
42
53
|
await timeout(20);
|
|
43
54
|
}
|
|
44
55
|
token.set(); // Stop worker
|
|
@@ -56,17 +67,18 @@ describe('Worker & Base Class Tests', () => {
|
|
|
56
67
|
queue.process({ cancellationSignal: token }, async () => {
|
|
57
68
|
throw new Error('worker error');
|
|
58
69
|
});
|
|
59
|
-
// Wait until task is processed (error recorded and status is
|
|
70
|
+
// Wait until task is processed (error recorded and status is Retrying)
|
|
60
71
|
for (let i = 0; i < 50; i++) {
|
|
61
72
|
const updated = await queue.getTask(task.id);
|
|
62
|
-
if (updated?.tries == 1 && updated.status == TaskStatus.
|
|
73
|
+
if (updated?.tries == 1 && updated.status == TaskStatus.Retrying) {
|
|
63
74
|
break;
|
|
64
75
|
}
|
|
76
|
+
queue.notify();
|
|
65
77
|
await timeout(20);
|
|
66
78
|
}
|
|
67
79
|
token.set();
|
|
68
80
|
const updated = await queue.getTask(task.id);
|
|
69
|
-
expect(updated?.status).toBe(TaskStatus.
|
|
81
|
+
expect(updated?.status).toBe(TaskStatus.Retrying); // Should retry
|
|
70
82
|
expect(updated?.tries).toBe(1);
|
|
71
83
|
expect(updated?.error?.message).toBe('worker error');
|
|
72
84
|
});
|
|
@@ -79,6 +91,7 @@ describe('Worker & Base Class Tests', () => {
|
|
|
79
91
|
executed = true;
|
|
80
92
|
return TaskProcessResult.Complete();
|
|
81
93
|
});
|
|
94
|
+
queue.notify();
|
|
82
95
|
await queue.waitForTasks([task.id], { timeout: 5000 });
|
|
83
96
|
token.set();
|
|
84
97
|
expect(executed).toBe(true);
|
|
@@ -99,18 +112,19 @@ describe('Worker & Base Class Tests', () => {
|
|
|
99
112
|
}
|
|
100
113
|
return TaskProcessResult.Complete();
|
|
101
114
|
});
|
|
102
|
-
// Wait until tasks are processed (error/reschedule recorded and status is Pending)
|
|
115
|
+
// Wait until tasks are processed (error/reschedule recorded and status is Retrying/Pending)
|
|
103
116
|
for (let i = 0; i < 50; i++) {
|
|
104
117
|
const uFail = await queue.getTask(tFail.id);
|
|
105
118
|
const uResched = await queue.getTask(tResched.id);
|
|
106
|
-
if (uFail?.tries == 1 && uResched?.status == TaskStatus.Pending && (uResched?.scheduleTimestamp ?? 0) > Date.now()) {
|
|
119
|
+
if (uFail?.tries == 1 && uFail.status == TaskStatus.Retrying && uResched?.status == TaskStatus.Pending && (uResched?.scheduleTimestamp ?? 0) > Date.now()) {
|
|
107
120
|
break;
|
|
108
121
|
}
|
|
122
|
+
queue.notify();
|
|
109
123
|
await timeout(20);
|
|
110
124
|
}
|
|
111
125
|
token.set();
|
|
112
126
|
const uFail = await queue.getTask(tFail.id);
|
|
113
|
-
expect(uFail?.status).toBe(TaskStatus.
|
|
127
|
+
expect(uFail?.status).toBe(TaskStatus.Retrying); // Retry
|
|
114
128
|
expect(uFail?.error?.message).toBe('explicit fail');
|
|
115
129
|
const uResched = await queue.getTask(tResched.id);
|
|
116
130
|
expect(uResched?.status).toBe(TaskStatus.Pending);
|
|
@@ -132,9 +146,9 @@ describe('Worker & Base Class Tests', () => {
|
|
|
132
146
|
const children = await context.spawnMany([{ type: 'child', data: { c: 2 } }]);
|
|
133
147
|
expect(children[0]?.parentId).toBe(task.id);
|
|
134
148
|
// Other queue spawn
|
|
135
|
-
const otherQueue = injector.resolve(TaskQueueProvider).get(
|
|
149
|
+
const otherQueue = injector.resolve(TaskQueueProvider).get(otherQueueName);
|
|
136
150
|
const otherChild = await context.spawn(otherQueue, 'other', { x: 1 });
|
|
137
|
-
expect(otherChild.namespace).toBe(
|
|
151
|
+
expect(otherChild.namespace).toBe(otherQueueName);
|
|
138
152
|
await context.spawnMany(otherQueue, [{ type: 'other', data: { x: 2 } }]);
|
|
139
153
|
executed = true;
|
|
140
154
|
return TaskProcessResult.Complete();
|
|
@@ -143,18 +157,29 @@ describe('Worker & Base Class Tests', () => {
|
|
|
143
157
|
void (async () => {
|
|
144
158
|
while (!executed) {
|
|
145
159
|
await timeout(50);
|
|
160
|
+
queue.notify();
|
|
146
161
|
}
|
|
147
162
|
// At this point parent should be WaitingChildren if children are not done
|
|
163
|
+
await queue.waitForTasks([task.id], { statuses: [TaskStatus.WaitingChildren], timeout: 2000 });
|
|
148
164
|
const midTask = await queue.getTask(task.id);
|
|
149
165
|
expect(midTask?.status).toBe(TaskStatus.WaitingChildren);
|
|
150
166
|
while (true) {
|
|
151
167
|
const dChild = await queue.dequeue({ types: ['child'] });
|
|
152
|
-
if (!dChild)
|
|
168
|
+
if (!dChild) {
|
|
169
|
+
// Check other-queue as well
|
|
170
|
+
const otherQueue = injector.resolve(TaskQueueProvider).get(otherQueueName);
|
|
171
|
+
const dOther = await otherQueue.dequeue();
|
|
172
|
+
if (dOther) {
|
|
173
|
+
await otherQueue.complete(dOther);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
153
176
|
break;
|
|
177
|
+
}
|
|
154
178
|
await queue.complete(dChild);
|
|
155
179
|
}
|
|
156
180
|
})();
|
|
157
|
-
|
|
181
|
+
queue.notify();
|
|
182
|
+
await queue.waitForTasks([task.id], { interval: 50, timeout: 2000 });
|
|
158
183
|
token.set();
|
|
159
184
|
expect(executed).toBe(true);
|
|
160
185
|
const finalTask = await queue.getTask(task.id);
|
|
@@ -162,7 +187,7 @@ describe('Worker & Base Class Tests', () => {
|
|
|
162
187
|
});
|
|
163
188
|
it('should correctly report isFinalAttempt in TaskContext', async () => {
|
|
164
189
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
165
|
-
const queueName = `final-try-test-${
|
|
190
|
+
const queueName = `final-try-test-${crypto.randomUUID()}`;
|
|
166
191
|
const testQueue = queueProvider.get(queueName, {
|
|
167
192
|
maxTries: 2,
|
|
168
193
|
retryDelayMinimum: 0,
|
|
@@ -188,4 +213,12 @@ describe('Worker & Base Class Tests', () => {
|
|
|
188
213
|
expect(finalAttemptValues).toEqual([false, true]);
|
|
189
214
|
await testQueue.clear();
|
|
190
215
|
});
|
|
216
|
+
it('should handle unsupported task result action', async () => {
|
|
217
|
+
const task = await queue.enqueue('unsupported-action', {});
|
|
218
|
+
const workerPromise = queue.processWorker(token, () => ({ payload: { action: 'magic' } }));
|
|
219
|
+
await expect(workerPromise).rejects.toThrow('Unsupported task result action');
|
|
220
|
+
const updated = await queue.getTask(task.id);
|
|
221
|
+
expect(updated?.status).toBe(TaskStatus.Retrying);
|
|
222
|
+
expect(updated?.error?.message).toContain('Unsupported task result action');
|
|
223
|
+
});
|
|
191
224
|
});
|
|
@@ -10,7 +10,7 @@ describe('Zombie Parent Deadlock', () => {
|
|
|
10
10
|
});
|
|
11
11
|
beforeEach(() => {
|
|
12
12
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
13
|
-
const queueName = `zombie-queue-${
|
|
13
|
+
const queueName = `zombie-queue-${crypto.randomUUID()}`;
|
|
14
14
|
queue = queueProvider.get(queueName, {
|
|
15
15
|
visibilityTimeout: 1000,
|
|
16
16
|
});
|
|
@@ -21,11 +21,11 @@ describe('Zombie Parent Deadlock', () => {
|
|
|
21
21
|
afterAll(async () => {
|
|
22
22
|
await injector?.dispose();
|
|
23
23
|
});
|
|
24
|
-
it('should resolve parent even if child fails (
|
|
24
|
+
it('should resolve parent even if child fails (abortOnDependencyFailure: false)', async () => {
|
|
25
25
|
const parent = await queue.enqueue('parent', {});
|
|
26
26
|
const dParent = await queue.dequeue();
|
|
27
|
-
// Spawn a child that will fail. Parent has
|
|
28
|
-
const [child] = await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id,
|
|
27
|
+
// Spawn a child that will fail. Parent has abortOnDependencyFailure: false by default.
|
|
28
|
+
const [child] = await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, abortOnDependencyFailure: false }], { returnTasks: true });
|
|
29
29
|
await queue.complete(dParent);
|
|
30
30
|
// Parent should be WaitingChildren
|
|
31
31
|
const uParent = await queue.getTask(parent.id);
|