@tstdl/base 0.93.140 → 0.93.142
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/application/application.d.ts +1 -1
- package/application/application.js +1 -1
- package/application/providers.d.ts +20 -2
- package/application/providers.js +34 -7
- package/audit/module.d.ts +5 -0
- package/audit/module.js +9 -1
- package/authentication/client/authentication.service.d.ts +1 -0
- package/authentication/client/authentication.service.js +3 -2
- package/authentication/server/module.d.ts +5 -0
- package/authentication/server/module.js +9 -1
- package/authentication/tests/authentication.api-controller.test.js +1 -1
- package/authentication/tests/authentication.api-request-token.provider.test.js +1 -1
- package/authentication/tests/authentication.client-service.test.js +1 -1
- 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/postgres/module.d.ts +1 -0
- package/circuit-breaker/postgres/module.js +5 -1
- package/circuit-breaker/tests/circuit-breaker.test.js +20 -0
- package/document-management/server/configure.js +5 -1
- package/document-management/server/module.d.ts +1 -1
- package/document-management/server/module.js +1 -1
- package/document-management/server/services/document-management-ancillary.service.js +1 -1
- package/document-management/tests/ai-config-hierarchy.test.js +0 -5
- package/document-management/tests/document-management-ai-overrides.test.js +0 -1
- package/document-management/tests/document-validation-ai-overrides.test.js +0 -1
- package/examples/document-management/main.d.ts +1 -0
- package/examples/document-management/main.js +14 -11
- package/key-value-store/postgres/module.d.ts +1 -0
- package/key-value-store/postgres/module.js +5 -1
- package/lock/postgres/module.d.ts +1 -0
- package/lock/postgres/module.js +5 -1
- package/mail/module.d.ts +5 -1
- package/mail/module.js +11 -6
- package/module/modules/web-server.module.js +2 -3
- package/notification/server/module.d.ts +1 -0
- package/notification/server/module.js +5 -1
- package/notification/tests/notification-api.test.js +5 -1
- package/notification/tests/notification-flow.test.js +8 -5
- package/orm/decorators.d.ts +22 -5
- package/orm/decorators.js +10 -1
- 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 +85 -56
- package/orm/server/encryption.d.ts +0 -1
- package/orm/server/encryption.js +1 -4
- package/orm/server/extension.d.ts +14 -0
- package/orm/server/extension.js +27 -0
- package/orm/server/index.d.ts +3 -6
- package/orm/server/index.js +3 -6
- package/orm/server/migration.d.ts +18 -0
- package/orm/server/migration.js +58 -0
- package/orm/server/repository.d.ts +2 -1
- package/orm/server/repository.js +19 -9
- package/orm/server/transaction.d.ts +6 -10
- package/orm/server/transaction.js +25 -26
- package/orm/server/transactional.js +3 -3
- package/orm/tests/database-extension.test.js +63 -0
- package/orm/tests/database-migration.test.js +83 -0
- package/orm/tests/encryption.test.js +3 -4
- 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/orm/utils.d.ts +17 -2
- package/orm/utils.js +49 -1
- package/package.json +5 -4
- package/rate-limit/postgres/module.d.ts +1 -0
- package/rate-limit/postgres/module.js +5 -1
- package/reflection/decorator-data.js +11 -12
- package/task-queue/README.md +2 -10
- package/task-queue/postgres/drizzle/0000_great_gwen_stacy.sql +84 -0
- package/task-queue/postgres/drizzle/meta/0000_snapshot.json +250 -89
- package/task-queue/postgres/drizzle/meta/_journal.json +2 -2
- package/task-queue/postgres/module.d.ts +1 -0
- package/task-queue/postgres/module.js +6 -1
- package/task-queue/postgres/schemas.d.ts +15 -6
- package/task-queue/postgres/schemas.js +4 -3
- package/task-queue/postgres/task-queue.d.ts +18 -15
- package/task-queue/postgres/task-queue.js +797 -499
- package/task-queue/postgres/task.model.d.ts +20 -9
- package/task-queue/postgres/task.model.js +65 -39
- package/task-queue/task-context.d.ts +12 -7
- package/task-queue/task-context.js +8 -6
- package/task-queue/task-queue.d.ts +364 -43
- package/task-queue/task-queue.js +153 -41
- package/task-queue/tests/coverage-branch.test.d.ts +1 -0
- package/task-queue/tests/coverage-branch.test.js +395 -0
- package/task-queue/tests/coverage-enhancement.test.d.ts +1 -0
- package/task-queue/tests/coverage-enhancement.test.js +150 -0
- package/task-queue/tests/dag.test.d.ts +1 -0
- package/task-queue/tests/dag.test.js +188 -0
- package/task-queue/tests/dependencies.test.js +165 -47
- package/task-queue/tests/enqueue-batch.test.d.ts +1 -0
- package/task-queue/tests/enqueue-batch.test.js +125 -0
- package/task-queue/tests/fan-out-spawning.test.d.ts +1 -0
- package/task-queue/tests/fan-out-spawning.test.js +94 -0
- package/task-queue/tests/idempotent-replacement.test.d.ts +1 -0
- package/task-queue/tests/idempotent-replacement.test.js +114 -0
- package/task-queue/tests/missing-idempotent-tasks.test.d.ts +1 -0
- package/task-queue/tests/missing-idempotent-tasks.test.js +39 -0
- package/task-queue/tests/queue.test.js +294 -49
- package/task-queue/tests/shutdown.test.d.ts +1 -0
- 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 +63 -15
- package/task-queue/tests/zombie-parent.test.d.ts +1 -0
- package/task-queue/tests/zombie-parent.test.js +45 -0
- package/task-queue/tests/zombie-recovery.test.d.ts +1 -0
- package/task-queue/tests/zombie-recovery.test.js +51 -0
- package/test5.js +5 -5
- package/testing/integration-setup.d.ts +4 -4
- package/testing/integration-setup.js +56 -29
- package/text/localization.service.js +2 -2
- package/utils/file-reader.js +1 -2
- package/utils/timing.d.ts +2 -2
- package/task-queue/postgres/drizzle/0000_simple_invisible_woman.sql +0 -74
- package/task-queue/tests/complex.test.js +0 -306
- package/task-queue/tests/extensive-dependencies.test.js +0 -234
- /package/{task-queue/tests/complex.test.d.ts → orm/tests/database-extension.test.d.ts} +0 -0
- /package/{task-queue/tests/extensive-dependencies.test.d.ts → orm/tests/database-migration.test.d.ts} +0 -0
|
@@ -1,14 +1,17 @@
|
|
|
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';
|
|
7
10
|
import { timeout } from '../../utils/timing.js';
|
|
11
|
+
import { isDefined } from '../../utils/type-guards.js';
|
|
8
12
|
describe('Queue Integration Tests', () => {
|
|
9
13
|
let injector;
|
|
10
14
|
let queue;
|
|
11
|
-
const queueName = `test-queue-${Date.now()}`;
|
|
12
15
|
// Helper to verify state in DB
|
|
13
16
|
async function assertTaskStatus(id, state, message) {
|
|
14
17
|
const task = await queue.getTask(id);
|
|
@@ -16,10 +19,10 @@ describe('Queue Integration Tests', () => {
|
|
|
16
19
|
}
|
|
17
20
|
beforeAll(async () => {
|
|
18
21
|
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// messing with `injectArgument` manually in the test body.
|
|
22
|
+
});
|
|
23
|
+
beforeEach(async () => {
|
|
22
24
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
25
|
+
const queueName = `test-queue-${crypto.randomUUID()}`;
|
|
23
26
|
queue = queueProvider.get(queueName, {
|
|
24
27
|
visibilityTimeout: 5000,
|
|
25
28
|
retryDelayMinimum: 100, // Fast retries for testing
|
|
@@ -32,7 +35,9 @@ describe('Queue Integration Tests', () => {
|
|
|
32
35
|
});
|
|
33
36
|
afterAll(async () => {
|
|
34
37
|
try {
|
|
35
|
-
|
|
38
|
+
if (isDefined(queue)) {
|
|
39
|
+
await queue.clear();
|
|
40
|
+
}
|
|
36
41
|
await injector.dispose();
|
|
37
42
|
}
|
|
38
43
|
catch (error) {
|
|
@@ -68,7 +73,7 @@ describe('Queue Integration Tests', () => {
|
|
|
68
73
|
await queue.complete(second);
|
|
69
74
|
});
|
|
70
75
|
it('Deduplication (Idempotency Keys)', async () => {
|
|
71
|
-
const key = `unique-${
|
|
76
|
+
const key = `unique-${crypto.randomUUID()}`;
|
|
72
77
|
// 1. Initial Insert
|
|
73
78
|
const t1 = await queue.enqueue('test', { value: 'original' }, { idempotencyKey: key });
|
|
74
79
|
// 2. Default Strategy (replace: false): Should return existing task, ignore new data
|
|
@@ -76,9 +81,9 @@ describe('Queue Integration Tests', () => {
|
|
|
76
81
|
expect(t2.id, 'Same ID if not replaced').toBe(t1.id);
|
|
77
82
|
const check1 = await queue.getTask(t1.id);
|
|
78
83
|
expect((check1?.data)['value']).toBe('original');
|
|
79
|
-
// 3. Replace Strategy: Should replace existing task with new data
|
|
84
|
+
// 3. Replace Strategy: Should replace existing task with new data. ID stays the same to avoid foreign key violations.
|
|
80
85
|
const t3 = await queue.enqueueMany([{ type: 'test', data: { value: 'updated' }, idempotencyKey: key }], { replace: true, returnTasks: true });
|
|
81
|
-
expect(t3[0].id, '
|
|
86
|
+
expect(t3[0].id, 'Same ID if replaced').toBe(t1.id);
|
|
82
87
|
// New task should have new data
|
|
83
88
|
const checkNew = await queue.getTask(t3[0].id);
|
|
84
89
|
expect((checkNew?.data)['value']).toBe('updated');
|
|
@@ -90,6 +95,8 @@ describe('Queue Integration Tests', () => {
|
|
|
90
95
|
const attempt1 = await queue.dequeue();
|
|
91
96
|
expect(attempt1?.id).toBe(task.id);
|
|
92
97
|
await queue.fail(attempt1, { message: 'oops' });
|
|
98
|
+
// Verify Retrying status
|
|
99
|
+
await assertTaskStatus(task.id, TaskStatus.Retrying, 'Task enters Retrying status after failure');
|
|
93
100
|
// Force reschedule to now to bypass retryDelay
|
|
94
101
|
await queue.reschedule(task.id, currentTimestamp());
|
|
95
102
|
// Try 2
|
|
@@ -129,12 +136,14 @@ describe('Queue Integration Tests', () => {
|
|
|
129
136
|
describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
130
137
|
let injector;
|
|
131
138
|
let queue;
|
|
139
|
+
let database;
|
|
132
140
|
beforeAll(async () => {
|
|
133
141
|
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
142
|
+
database = injector.resolve(Database);
|
|
134
143
|
});
|
|
135
144
|
beforeEach(() => {
|
|
136
145
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
137
|
-
const queueName = `pg-test-queue-${
|
|
146
|
+
const queueName = `pg-test-queue-${crypto.randomUUID()}`;
|
|
138
147
|
queue = queueProvider.get(queueName, {
|
|
139
148
|
visibilityTimeout: 50, // Short timeout for testing
|
|
140
149
|
retryDelayMinimum: 50,
|
|
@@ -172,11 +181,96 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
172
181
|
const dequeued = await queue.dequeue();
|
|
173
182
|
await queue.fail(dequeued, new Error('temp failure'));
|
|
174
183
|
const updated = await queue.getTask(task.id);
|
|
175
|
-
expect(updated?.status).toBe(TaskStatus.
|
|
184
|
+
expect(updated?.status).toBe(TaskStatus.Retrying);
|
|
176
185
|
expect(updated?.tries).toBe(1);
|
|
177
186
|
expect(updated?.error).toBeDefined();
|
|
178
187
|
});
|
|
179
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, waitForCompletion: 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
|
+
waitForCompletion: 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 waitForCompletion: 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, waitForCompletion: true });
|
|
270
|
+
const updatedParent = await queue.getTask(parent.id);
|
|
271
|
+
expect(updatedParent?.unresolvedCompleteDependencies).toBe(1);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
180
274
|
describe('Priority and Scheduling', () => {
|
|
181
275
|
it('should dequeue tasks in priority order (lower number first)', async () => {
|
|
182
276
|
await queue.enqueue('foo', { foo: 'low' }, { priority: 2000 });
|
|
@@ -200,24 +294,18 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
200
294
|
});
|
|
201
295
|
});
|
|
202
296
|
describe('Concurrency Control', () => {
|
|
203
|
-
it('should
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
expect(t2).toBeDefined();
|
|
216
|
-
expect(t3).toBeUndefined(); // Limit reached
|
|
217
|
-
await limitedQueue.complete(t1);
|
|
218
|
-
const t3Retry = await limitedQueue.dequeue();
|
|
219
|
-
expect(t3Retry).toBeDefined(); // Slot opened
|
|
220
|
-
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();
|
|
221
309
|
});
|
|
222
310
|
});
|
|
223
311
|
describe('Circuit Breaker', () => {
|
|
@@ -255,21 +343,21 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
255
343
|
await timeout(100);
|
|
256
344
|
await queue.maintenance();
|
|
257
345
|
const recovered = await queue.getTask(task.id);
|
|
258
|
-
expect(recovered?.status).toBe(TaskStatus.
|
|
346
|
+
expect(recovered?.status).toBe(TaskStatus.Retrying);
|
|
259
347
|
expect(recovered?.tries).toBe(1);
|
|
260
348
|
expect(recovered?.token).toBeNull();
|
|
261
349
|
});
|
|
262
350
|
it('should fail tasks that exceed Hard Execution Timeout via prune', async () => {
|
|
263
351
|
// Re-configure queue with very short execution timeout
|
|
264
352
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
265
|
-
const shortQueue = queueProvider.get(`prune-test-${
|
|
353
|
+
const shortQueue = queueProvider.get(`prune-test-${crypto.randomUUID()}`, { maxExecutionTime: 50 });
|
|
266
354
|
const task = await shortQueue.enqueue('foo', { foo: 'long-running' });
|
|
267
355
|
await shortQueue.dequeue();
|
|
268
356
|
await timeout(75);
|
|
269
357
|
await shortQueue.maintenance();
|
|
270
358
|
const updated = await shortQueue.getTask(task.id);
|
|
271
|
-
expect(updated?.status).toBe(TaskStatus.
|
|
272
|
-
expect(updated?.error?.
|
|
359
|
+
expect(updated?.status).toBe(TaskStatus.TimedOut);
|
|
360
|
+
expect(updated?.error?.code).toBe('MaxTimeExceeded');
|
|
273
361
|
await shortQueue.clear();
|
|
274
362
|
});
|
|
275
363
|
it('should touch a task to extend token', async () => {
|
|
@@ -278,7 +366,7 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
278
366
|
const initialLock = dequeued.visibilityDeadline;
|
|
279
367
|
await timeout(20);
|
|
280
368
|
const touched = await queue.touch(dequeued);
|
|
281
|
-
expect(touched
|
|
369
|
+
expect(touched.visibilityDeadline > initialLock).toBe(true);
|
|
282
370
|
});
|
|
283
371
|
it('should prevent touching if token is lost (stolen by another worker)', async () => {
|
|
284
372
|
await queue.enqueue('foo', { foo: 'work' });
|
|
@@ -292,6 +380,46 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
292
380
|
const touchResult = await queue.touch(dequeued);
|
|
293
381
|
expect(touchResult).toBeUndefined();
|
|
294
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
|
+
});
|
|
295
423
|
});
|
|
296
424
|
describe('Batch Operations', () => {
|
|
297
425
|
it('should complete many tasks efficiently', async () => {
|
|
@@ -307,6 +435,62 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
307
435
|
expect(t1?.status).toBe(TaskStatus.Completed);
|
|
308
436
|
expect(t2?.status).toBe(TaskStatus.Completed);
|
|
309
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
|
+
});
|
|
310
494
|
});
|
|
311
495
|
describe('Rescheduling', () => {
|
|
312
496
|
it('should reschedule and refund tries if running', async () => {
|
|
@@ -320,6 +504,15 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
320
504
|
expect(updated?.tries).toBe(0); // Refunded
|
|
321
505
|
expect(updated?.scheduleTimestamp).toBe(inFuture);
|
|
322
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
|
+
});
|
|
323
516
|
});
|
|
324
517
|
describe('TaskContext (Worker DX)', () => {
|
|
325
518
|
it('checkpoint() should update progress and handle token loss', async () => {
|
|
@@ -419,27 +612,79 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
419
612
|
expect(result.cancelled).toBe(false);
|
|
420
613
|
});
|
|
421
614
|
it('should wait for parent task to reach finalized state after child completion', async () => {
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
|
|
615
|
+
const parent = await queue.enqueue('parent', { value: 'parent' });
|
|
616
|
+
const dParent = await queue.dequeue({ types: ['parent'] });
|
|
617
|
+
expect(dParent).toBeDefined();
|
|
425
618
|
// Spawn a child
|
|
426
|
-
await queue.enqueueMany([{ type: '
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
619
|
+
const [child] = await queue.enqueueMany([{ type: 'child', data: { value: 'child' }, parentId: parent.id, completeAfter: [] }], { returnTasks: true });
|
|
620
|
+
expect(child).toBeDefined();
|
|
621
|
+
// Re-enqueuing with dependency:
|
|
622
|
+
const parentWithDep = await queue.enqueue('parent-dep', { value: 'parent' }, { completeAfter: [child.id] });
|
|
623
|
+
let dParent2;
|
|
624
|
+
for (let i = 0; i < 10; i++) {
|
|
625
|
+
dParent2 = await queue.dequeue({ types: ['parent-dep'] });
|
|
626
|
+
if (dParent2)
|
|
627
|
+
break;
|
|
628
|
+
await timeout(50);
|
|
629
|
+
}
|
|
630
|
+
expect(dParent2?.id).toBe(parentWithDep.id);
|
|
631
|
+
// Complete parent (it will move to Waiting because of completeAfter)
|
|
632
|
+
await queue.complete(dParent2);
|
|
633
|
+
const checkParent = await queue.getTask(parentWithDep.id);
|
|
634
|
+
expect(checkParent?.status).toBe(TaskStatus.WaitingChildren);
|
|
431
635
|
void (async () => {
|
|
432
636
|
await timeout(100);
|
|
433
|
-
const dChild = await queue.dequeue();
|
|
434
|
-
|
|
435
|
-
await queue.complete(dChild);
|
|
436
|
-
}
|
|
437
|
-
// Manual fan-in processing since we are in a test environment and might want immediate result
|
|
438
|
-
await queue.processPendingFanIn();
|
|
637
|
+
const dChild = await queue.dequeue({ types: ['child'] });
|
|
638
|
+
await queue.complete(dChild);
|
|
439
639
|
})();
|
|
440
|
-
await queue.waitForTasks([
|
|
441
|
-
const finalParent = await queue.getTask(
|
|
640
|
+
await queue.waitForTasks([parentWithDep.id], { timeout: 2000 });
|
|
641
|
+
const finalParent = await queue.getTask(parentWithDep.id);
|
|
442
642
|
expect(finalParent?.status).toBe(TaskStatus.Completed);
|
|
443
643
|
});
|
|
444
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
|
+
});
|
|
445
690
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|