@tstdl/base 0.93.140 → 0.93.141
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/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/postgres/module.d.ts +1 -0
- package/circuit-breaker/postgres/module.js +5 -1
- 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-flow.test.js +2 -2
- package/orm/decorators.d.ts +5 -1
- package/orm/decorators.js +1 -1
- package/orm/server/drizzle/schema-converter.js +17 -30
- package/orm/server/encryption.d.ts +0 -1
- package/orm/server/encryption.js +1 -4
- package/orm/server/index.d.ts +1 -6
- package/orm/server/index.js +1 -6
- package/orm/server/migration.d.ts +19 -0
- package/orm/server/migration.js +72 -0
- package/orm/server/repository.d.ts +1 -1
- package/orm/server/transaction.d.ts +5 -10
- package/orm/server/transaction.js +22 -26
- package/orm/server/transactional.js +3 -3
- package/orm/tests/database-migration.test.d.ts +1 -0
- package/orm/tests/database-migration.test.js +82 -0
- package/orm/tests/encryption.test.js +3 -4
- package/orm/utils.d.ts +17 -2
- package/orm/utils.js +49 -1
- package/package.json +4 -3
- 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 -9
- package/task-queue/postgres/drizzle/{0000_simple_invisible_woman.sql → 0000_wakeful_sunspot.sql} +22 -14
- package/task-queue/postgres/drizzle/meta/0000_snapshot.json +160 -82
- 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 +5 -1
- package/task-queue/postgres/schemas.d.ts +9 -6
- package/task-queue/postgres/schemas.js +4 -3
- package/task-queue/postgres/task-queue.d.ts +2 -12
- package/task-queue/postgres/task-queue.js +431 -354
- package/task-queue/postgres/task.model.d.ts +12 -5
- package/task-queue/postgres/task.model.js +51 -25
- package/task-queue/task-context.d.ts +2 -2
- package/task-queue/task-context.js +7 -7
- package/task-queue/task-queue.d.ts +36 -19
- package/task-queue/task-queue.js +18 -10
- package/task-queue/tests/cascading-cancellations.test.d.ts +1 -0
- package/task-queue/tests/cascading-cancellations.test.js +38 -0
- package/task-queue/tests/complex.test.js +44 -228
- package/task-queue/tests/coverage-branch.test.d.ts +1 -0
- package/task-queue/tests/coverage-branch.test.js +407 -0
- package/task-queue/tests/coverage-enhancement.test.d.ts +1 -0
- package/task-queue/tests/coverage-enhancement.test.js +144 -0
- package/task-queue/tests/dag-dependencies.test.d.ts +1 -0
- package/task-queue/tests/dag-dependencies.test.js +41 -0
- package/task-queue/tests/dependencies.test.js +26 -26
- package/task-queue/tests/extensive-dependencies.test.js +64 -139
- package/task-queue/tests/fan-out-spawning.test.d.ts +1 -0
- package/task-queue/tests/fan-out-spawning.test.js +53 -0
- package/task-queue/tests/idempotent-replacement.test.d.ts +1 -0
- package/task-queue/tests/idempotent-replacement.test.js +61 -0
- package/task-queue/tests/missing-idempotent-tasks.test.d.ts +1 -0
- package/task-queue/tests/missing-idempotent-tasks.test.js +38 -0
- package/task-queue/tests/queue.test.js +33 -24
- package/task-queue/tests/worker.test.js +20 -5
- 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 +54 -29
- package/text/localization.service.js +2 -2
- package/utils/file-reader.js +1 -2
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CancellationToken } from '../../cancellation/index.js';
|
|
3
|
+
import { CircuitBreaker, CircuitBreakerState } from '../../circuit-breaker/index.js';
|
|
4
|
+
import { runInInjectionContext } from '../../injector/index.js';
|
|
5
|
+
import { injectRepository } from '../../orm/server/index.js';
|
|
6
|
+
import { TaskProcessResult, TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
7
|
+
import { PostgresTask } from '../../task-queue/postgres/index.js';
|
|
8
|
+
import { setupIntegrationTest } from '../../testing/index.js';
|
|
9
|
+
import { currentTimestamp } from '../../utils/date-time.js';
|
|
10
|
+
import { timeout } from '../../utils/timing.js';
|
|
11
|
+
describe('Task Queue Branch Coverage Enhancement', () => {
|
|
12
|
+
let injector;
|
|
13
|
+
let queue;
|
|
14
|
+
let token;
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
17
|
+
});
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
20
|
+
const queueName = `branch-coverage-queue-${Date.now()}-${Math.random()}`;
|
|
21
|
+
queue = queueProvider.get(queueName, {
|
|
22
|
+
visibilityTimeout: 200,
|
|
23
|
+
});
|
|
24
|
+
token = new CancellationToken();
|
|
25
|
+
});
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
token.set();
|
|
28
|
+
await queue.clear();
|
|
29
|
+
});
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await injector?.dispose();
|
|
32
|
+
});
|
|
33
|
+
it('should handle TaskProcessResult.RescheduleTo in worker', async () => {
|
|
34
|
+
const task = await queue.enqueue('reschedule-test', {});
|
|
35
|
+
const future = currentTimestamp() + 5000;
|
|
36
|
+
queue.process({ cancellationSignal: token }, async () => {
|
|
37
|
+
return TaskProcessResult.RescheduleTo(future);
|
|
38
|
+
});
|
|
39
|
+
await timeout(500);
|
|
40
|
+
token.set();
|
|
41
|
+
const updated = await queue.getTask(task.id);
|
|
42
|
+
expect(updated?.status).toBe(TaskStatus.Pending);
|
|
43
|
+
expect(updated?.scheduleTimestamp).toBe(future);
|
|
44
|
+
});
|
|
45
|
+
it('should handle TaskProcessResult.Fail(fatal: true) in worker', async () => {
|
|
46
|
+
const task = await queue.enqueue('fail-fatal-test', {});
|
|
47
|
+
queue.process({ cancellationSignal: token }, async () => {
|
|
48
|
+
return TaskProcessResult.Fail(new Error('fatal error'), true);
|
|
49
|
+
});
|
|
50
|
+
await timeout(500);
|
|
51
|
+
token.set();
|
|
52
|
+
const updated = await queue.getTask(task.id);
|
|
53
|
+
expect(updated?.status).toBe(TaskStatus.Dead);
|
|
54
|
+
});
|
|
55
|
+
it('should handle errors in touch during background lease extension', async () => {
|
|
56
|
+
const task = await queue.enqueue('touch-error-test', {});
|
|
57
|
+
// Mock touch to throw once
|
|
58
|
+
const originalTouch = queue.touch.bind(queue);
|
|
59
|
+
let thrown = false;
|
|
60
|
+
vi.spyOn(queue, 'touch').mockImplementation(async (t, o) => {
|
|
61
|
+
if (!thrown) {
|
|
62
|
+
thrown = true;
|
|
63
|
+
throw new Error('touch error');
|
|
64
|
+
}
|
|
65
|
+
return originalTouch(t, o);
|
|
66
|
+
});
|
|
67
|
+
queue.process({ cancellationSignal: token }, async () => {
|
|
68
|
+
await timeout(300); // Wait for background touch to trigger
|
|
69
|
+
return TaskProcessResult.Complete();
|
|
70
|
+
});
|
|
71
|
+
await timeout(600);
|
|
72
|
+
token.set();
|
|
73
|
+
const updated = await queue.getTask(task.id);
|
|
74
|
+
expect(updated?.status).toBe(TaskStatus.Completed);
|
|
75
|
+
expect(thrown).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
it('should handle background lease loss during processing', async () => {
|
|
78
|
+
// visibilityTimeout is 200ms. Background touch is every 100ms.
|
|
79
|
+
const task = await queue.enqueue('lease-loss-test', {});
|
|
80
|
+
let workerFinished = false;
|
|
81
|
+
// Mock touch to return undefined (stolen/lost)
|
|
82
|
+
vi.spyOn(queue, 'touch').mockResolvedValue(undefined);
|
|
83
|
+
queue.process({ cancellationSignal: token }, async (context) => {
|
|
84
|
+
// Wait for background touch to discover lease loss
|
|
85
|
+
for (let i = 0; i < 20; i++) {
|
|
86
|
+
if (context.signal.isSet)
|
|
87
|
+
break;
|
|
88
|
+
await timeout(50);
|
|
89
|
+
}
|
|
90
|
+
workerFinished = true;
|
|
91
|
+
return TaskProcessResult.Complete();
|
|
92
|
+
});
|
|
93
|
+
await timeout(500);
|
|
94
|
+
token.set();
|
|
95
|
+
expect(workerFinished).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
it('should handle enqueueMany with empty items', async () => {
|
|
98
|
+
const result = await queue.enqueueMany([], { returnTasks: true });
|
|
99
|
+
expect(result).toEqual([]);
|
|
100
|
+
const result2 = await queue.enqueueMany([]);
|
|
101
|
+
expect(result2).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
it('should handle getTask with non-existent id', async () => {
|
|
104
|
+
const task = await queue.getTask('00000000-0000-0000-0000-000000000000');
|
|
105
|
+
expect(task).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
it('should handle dequeue with specific types', async () => {
|
|
108
|
+
await queue.enqueue('type1', { val: 1 });
|
|
109
|
+
await queue.enqueue('type2', { val: 2 });
|
|
110
|
+
const d1 = await queue.dequeue({ types: ['type2'] });
|
|
111
|
+
expect(d1?.type).toBe('type2');
|
|
112
|
+
const d2 = await queue.dequeue({ types: ['type1'] });
|
|
113
|
+
expect(d2?.type).toBe('type1');
|
|
114
|
+
});
|
|
115
|
+
it('should handle maintenance when no tasks need maintenance', async () => {
|
|
116
|
+
await expect(queue.maintenance()).resolves.toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
it('should handle multiple workers competing for tasks', async () => {
|
|
119
|
+
await queue.enqueue('compete', {});
|
|
120
|
+
// Dequeue in one worker
|
|
121
|
+
const d1 = await queue.dequeue();
|
|
122
|
+
expect(d1).toBeDefined();
|
|
123
|
+
// Try to dequeue again - should be empty because it is locked
|
|
124
|
+
const d2 = await queue.dequeue();
|
|
125
|
+
expect(d2).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
it('should handle reschedule by milliseconds', async () => {
|
|
128
|
+
const task = await queue.enqueue('reschedule-by', {});
|
|
129
|
+
const dequeued = await queue.dequeue();
|
|
130
|
+
const future = currentTimestamp() + 5000;
|
|
131
|
+
await queue.reschedule(dequeued.id, future);
|
|
132
|
+
const updated = await queue.getTask(task.id);
|
|
133
|
+
expect(updated?.scheduleTimestamp).toBe(future);
|
|
134
|
+
});
|
|
135
|
+
it('should handle touch with no tasks', async () => {
|
|
136
|
+
const result = await queue.touchMany([]);
|
|
137
|
+
expect(result).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
it('should handle touch with no task token', async () => {
|
|
140
|
+
const task = await queue.enqueue('no-token', {});
|
|
141
|
+
// Task is Pending, token is null
|
|
142
|
+
const result = await queue.touch(task);
|
|
143
|
+
expect(result).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
it('should handle complete with non-existent task', async () => {
|
|
146
|
+
const fakeTask = { id: '00000000-0000-0000-0000-000000000000', token: '00000000-0000-0000-0000-000000000000' };
|
|
147
|
+
// Should not throw
|
|
148
|
+
await queue.complete(fakeTask);
|
|
149
|
+
});
|
|
150
|
+
it('should handle dequeueMany with forceDequeue', async () => {
|
|
151
|
+
await queue.enqueue('force', {});
|
|
152
|
+
const tasks = await queue.dequeueMany(1, { forceDequeue: true });
|
|
153
|
+
expect(tasks).toHaveLength(1);
|
|
154
|
+
});
|
|
155
|
+
it('should handle dequeueMany with globalConcurrency limit', async () => {
|
|
156
|
+
const limitedQueue = injector.resolve(TaskQueueProvider).get(`limited-${Date.now()}`, { globalConcurrency: 1 });
|
|
157
|
+
await limitedQueue.enqueue('t1', {});
|
|
158
|
+
await limitedQueue.enqueue('t2', {});
|
|
159
|
+
const d1 = await limitedQueue.dequeue();
|
|
160
|
+
expect(d1).toBeDefined();
|
|
161
|
+
const d2 = await limitedQueue.dequeue();
|
|
162
|
+
expect(d2).toBeUndefined(); // Limited by globalConcurrency
|
|
163
|
+
});
|
|
164
|
+
it('should handle waitForTasks timeout', async () => {
|
|
165
|
+
const task = await queue.enqueue('wait-timeout', {});
|
|
166
|
+
await expect(queue.waitForTasks([task.id], { timeout: 100, interval: 50 })).rejects.toThrow('Timeout');
|
|
167
|
+
});
|
|
168
|
+
it('should handle cancelMany with empty ids', async () => {
|
|
169
|
+
// Should not throw
|
|
170
|
+
await queue.cancelMany([]);
|
|
171
|
+
});
|
|
172
|
+
it('should handle touch with progress and state', async () => {
|
|
173
|
+
const task = await queue.enqueue('touch-data', {});
|
|
174
|
+
const dequeued = await queue.dequeue();
|
|
175
|
+
await queue.touch(dequeued, { progress: 0.7, state: { ok: true } });
|
|
176
|
+
const updated = await queue.getTask(task.id);
|
|
177
|
+
expect(updated?.progress).toBe(0.7);
|
|
178
|
+
expect(updated?.state).toEqual({ ok: true });
|
|
179
|
+
});
|
|
180
|
+
it('should handle touchMany with multiple tasks', async () => {
|
|
181
|
+
const t1 = await queue.enqueue('tm1', {});
|
|
182
|
+
const t2 = await queue.enqueue('tm2', {});
|
|
183
|
+
const d1 = await queue.dequeue();
|
|
184
|
+
const d2 = await queue.dequeue();
|
|
185
|
+
const result = await queue.touchMany([d1, d2], { progresses: [0.1, 0.2] });
|
|
186
|
+
expect(result).toHaveLength(2);
|
|
187
|
+
});
|
|
188
|
+
it('should handle enqueue with only completeAfter (isWaiting=false in DB, but has dependencies)', async () => {
|
|
189
|
+
// line 116 in pg task-queue: const isWaiting = scheduleAfterCount > 0;
|
|
190
|
+
// status: isWaiting ? TaskStatus.Waiting : TaskStatus.Pending
|
|
191
|
+
const prereq = await queue.enqueue('p', {});
|
|
192
|
+
const dependent = await queue.enqueue('d', {}, { completeAfter: [prereq.id] });
|
|
193
|
+
expect(dependent.status).toBe(TaskStatus.Pending); // Because scheduleAfterCount is 0
|
|
194
|
+
expect(dependent.unresolvedCompleteDependencies).toBe(1);
|
|
195
|
+
});
|
|
196
|
+
it('should handle maintenance recovering zombie tasks', async () => {
|
|
197
|
+
const isolatedQueue = injector.resolve(TaskQueueProvider).get(`zombie-${Date.now()}`, { visibilityTimeout: 100 });
|
|
198
|
+
await isolatedQueue.enqueue('z', {});
|
|
199
|
+
const d1 = await isolatedQueue.dequeue();
|
|
200
|
+
expect(d1).toBeDefined();
|
|
201
|
+
// Wait for visibility timeout to expire
|
|
202
|
+
await timeout(200);
|
|
203
|
+
await isolatedQueue.maintenance();
|
|
204
|
+
const updated = await isolatedQueue.getTask(d1.id);
|
|
205
|
+
expect(updated?.status).toBe(TaskStatus.Pending);
|
|
206
|
+
expect(updated?.token).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
it('should handle getTask including archived tasks', async () => {
|
|
209
|
+
const archQueue = injector.resolve(TaskQueueProvider).get(`arch-${Date.now()}`, { retention: 0, archiveRetention: 3600000 });
|
|
210
|
+
const t = await archQueue.enqueue('t', {});
|
|
211
|
+
const d = await archQueue.dequeue();
|
|
212
|
+
await archQueue.complete(d);
|
|
213
|
+
await timeout(50);
|
|
214
|
+
await archQueue.maintenance(); // Should archive
|
|
215
|
+
const archived = await archQueue.getTask(t.id);
|
|
216
|
+
expect(archived).toBeDefined();
|
|
217
|
+
expect(archived?.id).toBe(t.id);
|
|
218
|
+
});
|
|
219
|
+
it('should handle maintenance with no work to do', async () => {
|
|
220
|
+
await queue.maintenance(); // Should not throw
|
|
221
|
+
});
|
|
222
|
+
it('should handle task TTL failure in maintenance', async () => {
|
|
223
|
+
// retention=0 to allow maintenance to process immediately
|
|
224
|
+
const ttlQueue = injector.resolve(TaskQueueProvider).get(`ttl-${Date.now()}`, { retention: 0 });
|
|
225
|
+
// Enqueue with TTL in the past
|
|
226
|
+
const task = await ttlQueue.enqueue('ttl-fail', {}, { timeToLive: currentTimestamp() - 1000 });
|
|
227
|
+
await timeout(100);
|
|
228
|
+
await ttlQueue.maintenance();
|
|
229
|
+
const updated = await ttlQueue.getTask(task.id);
|
|
230
|
+
expect(updated?.status).toBe(TaskStatus.Dead);
|
|
231
|
+
expect(updated?.error?.message).toContain('Task expired');
|
|
232
|
+
});
|
|
233
|
+
it('should handle cancelMany with multiple valid IDs', async () => {
|
|
234
|
+
const t1 = await queue.enqueue('c1', {});
|
|
235
|
+
const t2 = await queue.enqueue('c2', {});
|
|
236
|
+
await queue.cancelMany([t1.id, t2.id]);
|
|
237
|
+
const u1 = await queue.getTask(t1.id);
|
|
238
|
+
const u2 = await queue.getTask(t2.id);
|
|
239
|
+
expect(u1?.status).toBe(TaskStatus.Cancelled);
|
|
240
|
+
expect(u2?.status).toBe(TaskStatus.Cancelled);
|
|
241
|
+
});
|
|
242
|
+
it('should handle pruning long expired tasks', async () => {
|
|
243
|
+
// archiveRetention = 0
|
|
244
|
+
const pruneQueue = injector.resolve(TaskQueueProvider).get(`prune-long-${Date.now()}`, { retention: 0, archiveRetention: 0 });
|
|
245
|
+
const task = await pruneQueue.enqueue('p1', {});
|
|
246
|
+
const d1 = await pruneQueue.dequeue();
|
|
247
|
+
await pruneQueue.complete(d1);
|
|
248
|
+
await timeout(100);
|
|
249
|
+
await pruneQueue.maintenance();
|
|
250
|
+
const updated = await pruneQueue.getTask(task.id);
|
|
251
|
+
expect(updated).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
it('should handle complete with token mismatch', async () => {
|
|
254
|
+
const task = await queue.enqueue('token-mismatch', {});
|
|
255
|
+
const dequeued = await queue.dequeue();
|
|
256
|
+
// Create a fake task with wrong token
|
|
257
|
+
const fakeTask = { ...dequeued, token: '00000000-0000-0000-0000-000000000000' };
|
|
258
|
+
// Should not throw and should not complete
|
|
259
|
+
await queue.complete(fakeTask);
|
|
260
|
+
const updated = await queue.getTask(task.id);
|
|
261
|
+
expect(updated?.status).toBe(TaskStatus.Running);
|
|
262
|
+
});
|
|
263
|
+
it('should handle rescheduleManyByTags for running tasks', async () => {
|
|
264
|
+
const task = await queue.enqueue('tag-resched', {}, { tags: ['t1'] });
|
|
265
|
+
await queue.dequeue(); // Now Running
|
|
266
|
+
const future = currentTimestamp() + 10000;
|
|
267
|
+
await queue.rescheduleManyByTags('t1', future);
|
|
268
|
+
const updated = await queue.getTask(task.id);
|
|
269
|
+
expect(updated?.status).toBe(TaskStatus.Pending);
|
|
270
|
+
expect(updated?.tries).toBe(0); // Refunded
|
|
271
|
+
});
|
|
272
|
+
it('should handle dequeueMany shortfall refund', async () => {
|
|
273
|
+
// We need to trigger tasks.length < rateLimitAcquired
|
|
274
|
+
// This happens if rate limiter allows N, but only M < N are actually available/dequeuable
|
|
275
|
+
await queue.enqueue('shortfall', {});
|
|
276
|
+
// rateLimit is 5 by default in our setup.
|
|
277
|
+
// If we try to dequeue 5, but only 1 exists, it should refund 4.
|
|
278
|
+
const tasks = await queue.dequeueMany(5);
|
|
279
|
+
expect(tasks).toHaveLength(1);
|
|
280
|
+
});
|
|
281
|
+
it('should handle CircuitBreaker Half-Open state in dequeue', async () => {
|
|
282
|
+
const namespace = `cb-half-${Date.now()}`;
|
|
283
|
+
const cbQueue = injector.resolve(TaskQueueProvider).get(namespace, { circuitBreakerThreshold: 1 });
|
|
284
|
+
const cb = injector.resolve(CircuitBreaker, namespace);
|
|
285
|
+
// Mock CB to be Half-Open
|
|
286
|
+
vi.spyOn(cb, 'check').mockResolvedValue({ allowed: true, state: CircuitBreakerState.HalfOpen, isProbe: true });
|
|
287
|
+
await cbQueue.enqueue('cb', {});
|
|
288
|
+
const tasks = await cbQueue.dequeue();
|
|
289
|
+
expect(tasks).toBeDefined();
|
|
290
|
+
});
|
|
291
|
+
it('should handle CircuitBreaker Half-Open state blocking when already probing', async () => {
|
|
292
|
+
const namespace = `cb-block-${Date.now()}`;
|
|
293
|
+
const cbQueue = injector.resolve(TaskQueueProvider).get(namespace, { circuitBreakerThreshold: 1 });
|
|
294
|
+
const cb = injector.resolve(CircuitBreaker, namespace);
|
|
295
|
+
// Mock CB to be Half-Open but NOT a probe (already probing)
|
|
296
|
+
vi.spyOn(cb, 'check').mockResolvedValue({ allowed: true, state: CircuitBreakerState.HalfOpen, isProbe: false });
|
|
297
|
+
await cbQueue.enqueue('cb', {});
|
|
298
|
+
await cbQueue.enqueue('cb2', {});
|
|
299
|
+
// 1. Mock one task as already running in this namespace
|
|
300
|
+
// Actually, we can just enqueue and dequeue one normally first
|
|
301
|
+
const d1 = await cbQueue.dequeue({ forceDequeue: true });
|
|
302
|
+
expect(d1).toBeDefined();
|
|
303
|
+
// 2. Now the "normal" dequeue should see a running task and block because it's half-open and not a probe
|
|
304
|
+
const d2 = await cbQueue.dequeue();
|
|
305
|
+
expect(d2).toBeUndefined();
|
|
306
|
+
});
|
|
307
|
+
it('should handle complete when task has active children', async () => {
|
|
308
|
+
const parent = await queue.enqueue('parent', {});
|
|
309
|
+
await queue.enqueue('child', {}, { parentId: parent.id });
|
|
310
|
+
const dParent = await queue.dequeue();
|
|
311
|
+
await queue.complete(dParent);
|
|
312
|
+
const updated = await queue.getTask(parent.id);
|
|
313
|
+
expect(updated?.status).toBe(TaskStatus.WaitingChildren);
|
|
314
|
+
});
|
|
315
|
+
it('should handle maintenance recovering zombie tasks via touchMany logic in prune', async () => {
|
|
316
|
+
const pruneQueue = injector.resolve(TaskQueueProvider).get(`prune-fail-${Date.now()}`, { maxExecutionTime: 50, retention: 0 });
|
|
317
|
+
const task = await pruneQueue.enqueue('p1', {});
|
|
318
|
+
await pruneQueue.dequeue();
|
|
319
|
+
await timeout(100);
|
|
320
|
+
await pruneQueue.maintenance();
|
|
321
|
+
const updated = await pruneQueue.getTask(task.id);
|
|
322
|
+
expect(updated?.status).toBe(TaskStatus.Dead);
|
|
323
|
+
});
|
|
324
|
+
it('should handle enqueueMany with parentId and waitForCompletion false', async () => {
|
|
325
|
+
const parent = await queue.enqueue('p', {});
|
|
326
|
+
await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, waitForCompletion: false }]);
|
|
327
|
+
});
|
|
328
|
+
it('should handle failMany for running tasks', async () => {
|
|
329
|
+
const t1 = await queue.enqueue('t1', {});
|
|
330
|
+
const d1 = await queue.dequeue();
|
|
331
|
+
await queue.failMany([d1], [new Error('fail')]);
|
|
332
|
+
const updated = await queue.getTask(t1.id);
|
|
333
|
+
expect(updated?.status).toBe(TaskStatus.Pending);
|
|
334
|
+
expect(updated?.tries).toBe(1);
|
|
335
|
+
});
|
|
336
|
+
it('should handle failMany reaching max tries (Dead state)', async () => {
|
|
337
|
+
const isolatedQueue = injector.resolve(TaskQueueProvider).get(`exhaust-${Date.now()}`, { maxTries: 1 });
|
|
338
|
+
const t1 = await isolatedQueue.enqueue('t1', {});
|
|
339
|
+
const d1 = await isolatedQueue.dequeue();
|
|
340
|
+
await isolatedQueue.failMany([d1], [new Error('fatal')]);
|
|
341
|
+
const updated = await isolatedQueue.getTask(t1.id);
|
|
342
|
+
expect(updated?.status).toBe(TaskStatus.Dead);
|
|
343
|
+
});
|
|
344
|
+
it('should handle rescheduleMany with mixed statuses', async () => {
|
|
345
|
+
const t1 = await queue.enqueue('t1', {}); // Pending
|
|
346
|
+
const t2 = await queue.enqueue('t2', {});
|
|
347
|
+
await queue.dequeue(); // t2 is now Running
|
|
348
|
+
const future = currentTimestamp() + 10000;
|
|
349
|
+
await queue.rescheduleMany([t1.id, t2.id], future);
|
|
350
|
+
const u1 = await queue.getTask(t1.id);
|
|
351
|
+
const u2 = await queue.getTask(t2.id);
|
|
352
|
+
expect(u1?.status).toBe(TaskStatus.Pending);
|
|
353
|
+
expect(u1?.tries).toBe(0);
|
|
354
|
+
expect(u2?.status).toBe(TaskStatus.Pending);
|
|
355
|
+
expect(u2?.tries).toBe(0); // Refunded because it was Running
|
|
356
|
+
});
|
|
357
|
+
it('should handle restart with resetState', async () => {
|
|
358
|
+
const task = await queue.enqueue('t', { initial: 1 });
|
|
359
|
+
// Keep it Pending
|
|
360
|
+
await queue.restart(task.id, { resetState: true });
|
|
361
|
+
const updated = await queue.getTask(task.id);
|
|
362
|
+
expect(updated?.status).toBe(TaskStatus.Pending);
|
|
363
|
+
expect(updated?.state).toBeNull();
|
|
364
|
+
});
|
|
365
|
+
it('should handle clear with active tasks', async () => {
|
|
366
|
+
const t = await queue.enqueue('t', {});
|
|
367
|
+
await queue.clear();
|
|
368
|
+
expect(await queue.has(t.id)).toBe(false);
|
|
369
|
+
});
|
|
370
|
+
it('should handle countByTags with tags', async () => {
|
|
371
|
+
await queue.enqueue('t', {}, { tags: ['tag1', 'tag2'] });
|
|
372
|
+
expect(await queue.countByTags(['tag1'])).toBe(1);
|
|
373
|
+
expect(await queue.countByTags(['tag3'])).toBe(0);
|
|
374
|
+
});
|
|
375
|
+
it('should handle rescheduleManyByTags with mixed statuses', async () => {
|
|
376
|
+
const t1 = await queue.enqueue('t1', {}, { tags: ['tag-m'] });
|
|
377
|
+
const t2 = await queue.enqueue('t2', {}, { tags: ['tag-m'] });
|
|
378
|
+
await queue.dequeue(); // one is running
|
|
379
|
+
await queue.rescheduleManyByTags('tag-m', currentTimestamp() + 5000);
|
|
380
|
+
const u1 = await queue.getTask(t1.id);
|
|
381
|
+
const u2 = await queue.getTask(t2.id);
|
|
382
|
+
expect(u1?.tries).toBe(0);
|
|
383
|
+
expect(u2?.tries).toBe(0);
|
|
384
|
+
});
|
|
385
|
+
it('should handle touchMany with mismatched status/token', async () => {
|
|
386
|
+
const t1 = await queue.enqueue('t1', {});
|
|
387
|
+
const d1 = await queue.dequeue();
|
|
388
|
+
// Wrong token
|
|
389
|
+
const result = await queue.touchMany([{ ...d1, token: '00000000-0000-0000-0000-000000000000' }]);
|
|
390
|
+
expect(result).toHaveLength(0);
|
|
391
|
+
});
|
|
392
|
+
it('should handle large batch maintenance (> 1000 tasks)', async () => {
|
|
393
|
+
const namespace = `aging-batch-${Date.now()}`;
|
|
394
|
+
const agingQueue = injector.resolve(TaskQueueProvider).get(namespace, { priorityAgingInterval: 60000, priorityAgingStep: 1 });
|
|
395
|
+
const items = Array.from({ length: 1001 }, (_, i) => ({ type: 'batch', data: { i } }));
|
|
396
|
+
await agingQueue.enqueueMany(items);
|
|
397
|
+
// Manually push timestamps into the past to qualify for aging without waiting
|
|
398
|
+
const repository = runInInjectionContext(injector, () => injectRepository(PostgresTask));
|
|
399
|
+
const twoMinutesAgo = currentTimestamp() - 120000;
|
|
400
|
+
await repository.updateManyByQuery({ namespace }, { priorityAgeTimestamp: twoMinutesAgo });
|
|
401
|
+
await agingQueue.maintenance();
|
|
402
|
+
});
|
|
403
|
+
it('should handle maintenance with empty archive and active tables', async () => {
|
|
404
|
+
const emptyQueue = injector.resolve(TaskQueueProvider).get(`empty-${Date.now()}`, { retention: 0, archiveRetention: 0 });
|
|
405
|
+
await emptyQueue.maintenance();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { CancellationToken } from '../../cancellation/index.js';
|
|
4
|
+
import { runInInjectionContext } from '../../injector/index.js';
|
|
5
|
+
import { Logger } from '../../logger/index.js';
|
|
6
|
+
import { RANDOM_UUID_V4 } from '../../orm/index.js';
|
|
7
|
+
import { injectRepository } from '../../orm/server/index.js';
|
|
8
|
+
import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
9
|
+
import { task as taskTable } from '../../task-queue/postgres/schemas.js';
|
|
10
|
+
import { PostgresTask } from '../../task-queue/postgres/task.model.js';
|
|
11
|
+
import { TaskContext } from '../../task-queue/task-context.js';
|
|
12
|
+
import { setupIntegrationTest } from '../../testing/index.js';
|
|
13
|
+
import { timeout } from '../../utils/timing.js';
|
|
14
|
+
describe('Task Queue Coverage Enhancement', () => {
|
|
15
|
+
let injector;
|
|
16
|
+
let queueProvider;
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
19
|
+
queueProvider = injector.resolve(TaskQueueProvider);
|
|
20
|
+
});
|
|
21
|
+
afterAll(async () => {
|
|
22
|
+
await injector?.dispose();
|
|
23
|
+
});
|
|
24
|
+
it('should exercise TaskContext getters and methods', async () => {
|
|
25
|
+
const queue = queueProvider.get(`context-test-${Date.now()}-${Math.random()}`);
|
|
26
|
+
const task = await queue.enqueue('test', { foo: 'bar' }, { tags: ['tag1'], priority: 500 });
|
|
27
|
+
const signal = new CancellationToken();
|
|
28
|
+
const logger = injector.resolve(Logger);
|
|
29
|
+
const context = new TaskContext(queue, task, signal, logger);
|
|
30
|
+
expect(context.id).toBe(task.id);
|
|
31
|
+
expect(context.type).toBe('test');
|
|
32
|
+
expect(context.parentId).toBeNull();
|
|
33
|
+
expect(context.tags).toContain('tag1');
|
|
34
|
+
expect(context.unresolvedScheduleDependencies).toBe(0);
|
|
35
|
+
expect(context.unresolvedCompleteDependencies).toBe(0);
|
|
36
|
+
expect(context.data).toEqual({ foo: 'bar' });
|
|
37
|
+
expect(context.state).toBeNull();
|
|
38
|
+
expect(context.attempt).toBe(0);
|
|
39
|
+
expect(context.triesLeft).toBe(queue.maxTries);
|
|
40
|
+
expect(context.isFinalAttempt).toBe(false);
|
|
41
|
+
expect(context.signal).toBe(signal);
|
|
42
|
+
expect(context.logger).toBeDefined();
|
|
43
|
+
// spawn for other queue
|
|
44
|
+
const otherQueue = queueProvider.get(`other-queue-${Date.now()}-${Math.random()}`);
|
|
45
|
+
const spawned = await context.spawn(otherQueue, 'spawned', { x: 1 });
|
|
46
|
+
expect(spawned.type).toBe('spawned');
|
|
47
|
+
const spawnedMany = await context.spawnMany(otherQueue, [{ type: 'm1', data: {} }, { type: 'm2', data: {} }]);
|
|
48
|
+
expect(spawnedMany).toHaveLength(2);
|
|
49
|
+
// checkpoint
|
|
50
|
+
await context.checkpoint({ progress: 0.5 });
|
|
51
|
+
// reschedule with delay
|
|
52
|
+
await context.reschedule({ delay: 10000 });
|
|
53
|
+
const uTask = await queue.getTask(task.id);
|
|
54
|
+
expect(uTask?.status).toBe(TaskStatus.Pending);
|
|
55
|
+
});
|
|
56
|
+
it('should handle checkpoint lease loss', async () => {
|
|
57
|
+
const queue = queueProvider.get(`checkpoint-loss-${Date.now()}-${Math.random()}`);
|
|
58
|
+
const task = await queue.enqueue('t', {});
|
|
59
|
+
const dTask = await queue.dequeue();
|
|
60
|
+
// Manually break the token in DB
|
|
61
|
+
const repository = runInInjectionContext(injector, () => injectRepository(PostgresTask));
|
|
62
|
+
await repository.useTransaction(undefined, async (tx) => {
|
|
63
|
+
await tx.pgTransaction.update(taskTable).set({ token: RANDOM_UUID_V4 }).where(eq(taskTable.id, task.id));
|
|
64
|
+
});
|
|
65
|
+
const signal = new CancellationToken();
|
|
66
|
+
const logger = injector.resolve(Logger);
|
|
67
|
+
const context = new TaskContext(queue, dTask, signal, logger);
|
|
68
|
+
await context.checkpoint({ progress: 0.9 });
|
|
69
|
+
expect(signal.isSet).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('should exercise various TaskQueue methods', async () => {
|
|
72
|
+
const queue = queueProvider.get(`methods-test-${Date.now()}-${Math.random()}`);
|
|
73
|
+
// has
|
|
74
|
+
expect(await queue.has('00000000-0000-0000-0000-000000000000')).toBe(false);
|
|
75
|
+
const t = await queue.enqueue('t', {});
|
|
76
|
+
expect(await queue.has(t.id)).toBe(true);
|
|
77
|
+
// countByTags
|
|
78
|
+
expect(await queue.countByTags([])).toBe(0);
|
|
79
|
+
expect(await queue.countByTags(['tag1'])).toBe(0);
|
|
80
|
+
await queue.enqueue('t2', {}, { tags: ['tag1'] });
|
|
81
|
+
expect(await queue.countByTags(['tag1'])).toBe(1);
|
|
82
|
+
// getManyByTags
|
|
83
|
+
expect(await queue.getManyByTags([])).toEqual([]);
|
|
84
|
+
const tagged = await queue.getManyByTags(['tag1']);
|
|
85
|
+
expect(tagged).toHaveLength(1);
|
|
86
|
+
// clear
|
|
87
|
+
await queue.clear();
|
|
88
|
+
expect(await queue.countByTags(['tag1'])).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
it('should load task from archive in getTask', async () => {
|
|
91
|
+
const archiveQueue = queueProvider.get(`archive-test-${Date.now()}-${Math.random()}`, { retention: 0, archiveRetention: 3600000 });
|
|
92
|
+
const task = await archiveQueue.enqueue('t', {});
|
|
93
|
+
const dTask = await archiveQueue.dequeue();
|
|
94
|
+
await archiveQueue.complete(dTask);
|
|
95
|
+
await timeout(10);
|
|
96
|
+
await archiveQueue.maintenance();
|
|
97
|
+
const archivedTask = await archiveQueue.getTask(task.id);
|
|
98
|
+
expect(archivedTask).toBeDefined();
|
|
99
|
+
expect(archivedTask?.id).toBe(task.id);
|
|
100
|
+
expect(archivedTask?.status).toBe(TaskStatus.Completed);
|
|
101
|
+
});
|
|
102
|
+
it('should exercise touchMany and failMany', async () => {
|
|
103
|
+
const isolatedQueue = queueProvider.get(`touch-fail-queue-${Date.now()}-${Math.random()}`);
|
|
104
|
+
const t1 = await isolatedQueue.enqueue('t1', {});
|
|
105
|
+
const t2 = await isolatedQueue.enqueue('t2', {});
|
|
106
|
+
const dTasks = await isolatedQueue.dequeueMany(2);
|
|
107
|
+
expect(dTasks).toHaveLength(2);
|
|
108
|
+
const touched = await isolatedQueue.touchMany(dTasks, { progresses: [0.5, 0.8] });
|
|
109
|
+
expect(touched).toHaveLength(2);
|
|
110
|
+
expect(touched).toContain(t1.id);
|
|
111
|
+
expect(touched).toContain(t2.id);
|
|
112
|
+
await isolatedQueue.failMany(dTasks, [new Error('e1'), new Error('e2')]);
|
|
113
|
+
const u1 = await isolatedQueue.getTask(t1.id);
|
|
114
|
+
const u2 = await isolatedQueue.getTask(t2.id);
|
|
115
|
+
expect(u1?.status).toBe(TaskStatus.Pending);
|
|
116
|
+
expect(u2?.status).toBe(TaskStatus.Pending);
|
|
117
|
+
expect(u1?.tries).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
it('should handle idempotency collisions in enqueueMany', async () => {
|
|
120
|
+
const queue = queueProvider.get(`idempotency-test-${Date.now()}-${Math.random()}`);
|
|
121
|
+
const key = `idempotent-${Date.now()}-${Math.random()}`;
|
|
122
|
+
await queue.enqueue('t1', { val: 1 }, { idempotencyKey: key });
|
|
123
|
+
// Enqueue again with same key, returnTasks: true
|
|
124
|
+
const tasks = await queue.enqueueMany([
|
|
125
|
+
{ type: 't1', data: { val: 2 }, idempotencyKey: key },
|
|
126
|
+
{ type: 't2', data: { val: 3 } }
|
|
127
|
+
], { returnTasks: true });
|
|
128
|
+
expect(tasks).toHaveLength(2);
|
|
129
|
+
const rt1 = tasks.find((t) => t.idempotencyKey == key);
|
|
130
|
+
const rt2 = tasks.find((t) => t.idempotencyKey == null);
|
|
131
|
+
expect(rt1?.data).toEqual({ val: 1 }); // Original data preserved
|
|
132
|
+
expect(rt2?.data).toEqual({ val: 3 }); // New task created
|
|
133
|
+
});
|
|
134
|
+
it('should purge archive in maintenance', async () => {
|
|
135
|
+
const purgeQueue = queueProvider.get(`purge-test-${Date.now()}-${Math.random()}`, { retention: 0, archiveRetention: 0 });
|
|
136
|
+
const task = await purgeQueue.enqueue('t', {});
|
|
137
|
+
const dTask = await purgeQueue.dequeue();
|
|
138
|
+
await purgeQueue.complete(dTask);
|
|
139
|
+
await timeout(10);
|
|
140
|
+
await purgeQueue.maintenance(); // Should archive AND purge
|
|
141
|
+
const purgedTask = await purgeQueue.getTask(task.id);
|
|
142
|
+
expect(purgedTask).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
3
|
+
import { setupIntegrationTest } from '../../testing/index.js';
|
|
4
|
+
describe('DAG Dependencies', () => {
|
|
5
|
+
let injector;
|
|
6
|
+
let queue;
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
|
|
9
|
+
});
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
12
|
+
const queueName = `dag-queue-${Date.now()}-${Math.random()}`;
|
|
13
|
+
queue = queueProvider.get(queueName, {
|
|
14
|
+
visibilityTimeout: 1000,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await queue.clear();
|
|
19
|
+
});
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
await injector?.dispose();
|
|
22
|
+
});
|
|
23
|
+
it('should schedule a task only after dependency reaches required status (scheduleAfter)', async () => {
|
|
24
|
+
const prereq = await queue.enqueue('prereq', { val: 1 });
|
|
25
|
+
const dependent = await queue.enqueue('dependent', { foo: 'bar' }, {
|
|
26
|
+
scheduleAfter: [prereq.id],
|
|
27
|
+
});
|
|
28
|
+
expect(dependent.status).toBe(TaskStatus.Waiting);
|
|
29
|
+
const dequeued = await queue.dequeue({ types: ['prereq'] });
|
|
30
|
+
expect(dequeued?.id).toBe(prereq.id);
|
|
31
|
+
await queue.complete(dequeued);
|
|
32
|
+
// Dependent should transition to Pending.
|
|
33
|
+
// Let's dequeue and complete it to make it reaches a finalized state for waitForTasks
|
|
34
|
+
const dDependent = await queue.dequeue({ types: ['dependent'] });
|
|
35
|
+
expect(dDependent?.id).toBe(dependent.id);
|
|
36
|
+
await queue.complete(dDependent);
|
|
37
|
+
await queue.waitForTasks([dependent.id]);
|
|
38
|
+
const updatedDependent = await queue.getTask(dependent.id);
|
|
39
|
+
expect(updatedDependent?.status).toBe(TaskStatus.Completed);
|
|
40
|
+
});
|
|
41
|
+
});
|