@tstdl/base 0.93.141 → 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/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_great_gwen_stacy.sql +84 -0
- package/task-queue/postgres/drizzle/meta/0000_snapshot.json +151 -68
- 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 +593 -372
- package/task-queue/postgres/task.model.d.ts +9 -5
- package/task-queue/postgres/task.model.js +26 -26
- package/task-queue/task-context.d.ts +10 -5
- package/task-queue/task-context.js +5 -3
- package/task-queue/task-queue.d.ts +339 -35
- package/task-queue/task-queue.js +135 -31
- package/task-queue/tests/coverage-branch.test.js +45 -57
- package/task-queue/tests/coverage-enhancement.test.js +123 -117
- package/task-queue/tests/{extensive-dependencies.test.js → dag.test.js} +61 -32
- package/task-queue/tests/dependencies.test.js +139 -21
- package/task-queue/tests/enqueue-batch.test.js +125 -0
- package/task-queue/tests/fan-out-spawning.test.js +43 -2
- 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 +1 -1
- 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
package/task-queue/task-queue.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
|
|
1
2
|
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
2
3
|
if (value !== null && value !== void 0) {
|
|
3
4
|
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
@@ -55,30 +56,53 @@ import { inject, injectArgument, Injector } from '../injector/index.js';
|
|
|
55
56
|
import { Logger } from '../logger/logger.js';
|
|
56
57
|
import { Transactional } from '../orm/server/transactional.js';
|
|
57
58
|
import { currentTimestamp } from '../utils/date-time.js';
|
|
58
|
-
import {
|
|
59
|
-
import { isDefined, isString, isUndefined } from '../utils/type-guards.js';
|
|
59
|
+
import { isDefined, isError, isString } from '../utils/type-guards.js';
|
|
60
60
|
import { millisecondsPerDay, millisecondsPerMinute, millisecondsPerSecond } from '../utils/units.js';
|
|
61
61
|
import { TaskQueueEnqueueBatch } from './enqueue-batch.js';
|
|
62
62
|
import { TaskContext } from './task-context.js';
|
|
63
|
+
/**
|
|
64
|
+
* Represents the result of processing a task.
|
|
65
|
+
* @template Result The type of the result data.
|
|
66
|
+
*/
|
|
63
67
|
export class TaskProcessResult {
|
|
64
68
|
payload;
|
|
65
69
|
constructor(payload) {
|
|
66
70
|
this.payload = payload;
|
|
67
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Creates a successful process result.
|
|
74
|
+
* @param result The optional result data.
|
|
75
|
+
*/
|
|
68
76
|
static Complete(result) {
|
|
69
77
|
return new TaskProcessResult({ action: 'complete', result });
|
|
70
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Creates a failed process result.
|
|
81
|
+
* @param error The error that occurred.
|
|
82
|
+
* @param fatal Whether the error is fatal and the task should not be retried.
|
|
83
|
+
*/
|
|
71
84
|
static Fail(error, fatal = false) {
|
|
72
85
|
return new TaskProcessResult({ action: 'fail', error, fatal });
|
|
73
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Creates a result that reschedules the task to a specific timestamp.
|
|
89
|
+
* @param timestamp The timestamp to reschedule to.
|
|
90
|
+
*/
|
|
74
91
|
static RescheduleTo(timestamp) {
|
|
75
92
|
return new TaskProcessResult({ action: 'reschedule', timestamp });
|
|
76
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Creates a result that reschedules the task by a specific number of milliseconds.
|
|
96
|
+
* @param milliseconds The number of milliseconds to reschedule by.
|
|
97
|
+
*/
|
|
77
98
|
static RescheduleBy(milliseconds) {
|
|
78
99
|
const timestamp = currentTimestamp() + milliseconds;
|
|
79
100
|
return this.RescheduleTo(timestamp);
|
|
80
101
|
}
|
|
81
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Represents the status of a task in the queue.
|
|
105
|
+
*/
|
|
82
106
|
export const TaskStatus = defineEnum('TaskStatus', {
|
|
83
107
|
/**
|
|
84
108
|
* The task is ready to be processed and is waiting for a worker.
|
|
@@ -112,18 +136,51 @@ export const TaskStatus = defineEnum('TaskStatus', {
|
|
|
112
136
|
* The task has been manually paused and will not be dequeued until resumed.
|
|
113
137
|
*/
|
|
114
138
|
Paused: 'paused',
|
|
139
|
+
/**
|
|
140
|
+
* The task has failed but has remaining attempts and is waiting for its next scheduled attempt.
|
|
141
|
+
*/
|
|
142
|
+
Retrying: 'retrying',
|
|
143
|
+
/**
|
|
144
|
+
* The task was forcefully terminated because its execution time exceeded maxExecutionTime.
|
|
145
|
+
*/
|
|
146
|
+
TimedOut: 'timed-out',
|
|
147
|
+
/**
|
|
148
|
+
* The task expired in the queue before it could be picked up by a worker.
|
|
149
|
+
*/
|
|
150
|
+
Expired: 'expired',
|
|
151
|
+
/**
|
|
152
|
+
* The task was never attempted because one of its prerequisites failed or was cancelled.
|
|
153
|
+
*/
|
|
154
|
+
Skipped: 'skipped',
|
|
155
|
+
/**
|
|
156
|
+
* The task was abandoned by a worker and has exhausted all retry attempts.
|
|
157
|
+
*/
|
|
158
|
+
Orphaned: 'orphaned',
|
|
115
159
|
});
|
|
160
|
+
/**
|
|
161
|
+
* Represents the type of dependency between tasks.
|
|
162
|
+
*/
|
|
116
163
|
export const TaskDependencyType = defineEnum('TaskDependencyType', {
|
|
164
|
+
/**
|
|
165
|
+
* The task should only be scheduled after the dependency has reached a specific status.
|
|
166
|
+
*/
|
|
117
167
|
Schedule: 'schedule',
|
|
168
|
+
/**
|
|
169
|
+
* The task is only considered complete after the dependency has reached a specific status.
|
|
170
|
+
*/
|
|
118
171
|
Complete: 'complete',
|
|
172
|
+
/**
|
|
173
|
+
* The task is only considered complete after the child task has reached a specific status.
|
|
174
|
+
*/
|
|
175
|
+
Child: 'child',
|
|
119
176
|
});
|
|
177
|
+
/** Default priority for tasks. */
|
|
120
178
|
export const defaultTaskPriority = 1000;
|
|
121
179
|
export const defaultQueueConfig = {
|
|
122
180
|
visibilityTimeout: millisecondsPerMinute * 5,
|
|
123
181
|
maxExecutionTime: millisecondsPerMinute * 60,
|
|
124
182
|
maxTries: 3,
|
|
125
183
|
retention: 30 * millisecondsPerDay,
|
|
126
|
-
globalConcurrency: null,
|
|
127
184
|
circuitBreakerThreshold: 5,
|
|
128
185
|
circuitBreakerResetTimeout: 30 * millisecondsPerSecond,
|
|
129
186
|
retryDelayMinimum: 5 * millisecondsPerSecond,
|
|
@@ -137,15 +194,30 @@ export const defaultQueueConfig = {
|
|
|
137
194
|
rateInterval: 1000,
|
|
138
195
|
idempotencyWindow: millisecondsPerMinute * 60,
|
|
139
196
|
};
|
|
197
|
+
/**
|
|
198
|
+
* Abstract base class for task queues.
|
|
199
|
+
* @template Definitions The type map of task definitions.
|
|
200
|
+
*/
|
|
140
201
|
export class TaskQueue extends Transactional {
|
|
202
|
+
#activeTasks = new Map();
|
|
203
|
+
#heartbeatLoopRunning = false;
|
|
141
204
|
injector = inject(Injector);
|
|
142
205
|
config = this.transactionalContextData ?? (() => { const arg = injectArgument(this); return isString(arg) ? { namespace: arg } : arg; })();
|
|
143
206
|
logger = inject(Logger, TaskQueue.name).with({ namespace: this.config.namespace });
|
|
207
|
+
get namespace() {
|
|
208
|
+
return this.config.namespace;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Starts a new batch operation for enqueuing multiple tasks.
|
|
212
|
+
* @returns A TaskQueueEnqueueBatch instance.
|
|
213
|
+
*/
|
|
144
214
|
batch() {
|
|
145
215
|
return new TaskQueueEnqueueBatch(this);
|
|
146
216
|
}
|
|
147
217
|
/**
|
|
148
|
-
* Starts processing tasks with the provided worker function
|
|
218
|
+
* Starts processing tasks with the provided worker function.
|
|
219
|
+
* @param options Concurrency, cancellation signal, task types, and forceDequeue flag.
|
|
220
|
+
* @param handler The worker function to process tasks.
|
|
149
221
|
*/
|
|
150
222
|
process({ concurrency = 1, cancellationSignal, types, forceDequeue }, handler) {
|
|
151
223
|
const promises = [];
|
|
@@ -159,7 +231,10 @@ export class TaskQueue extends Transactional {
|
|
|
159
231
|
return this.config;
|
|
160
232
|
}
|
|
161
233
|
/**
|
|
162
|
-
*
|
|
234
|
+
* Internal method to process tasks using a worker function.
|
|
235
|
+
* @param cancellationSignal A signal to stop processing tasks.
|
|
236
|
+
* @param handler The worker function.
|
|
237
|
+
* @param options Dequeue options.
|
|
163
238
|
*/
|
|
164
239
|
async processWorker(cancellationSignal, handler, options) {
|
|
165
240
|
for await (const task of this.getConsumer(cancellationSignal, options)) {
|
|
@@ -167,54 +242,50 @@ export class TaskQueue extends Transactional {
|
|
|
167
242
|
try {
|
|
168
243
|
const taskToken = __addDisposableResource(env_1, cancellationSignal.fork(), false);
|
|
169
244
|
const context = new TaskContext(this, task, taskToken, this.logger.with({ type: task.type }));
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
context.logger.warn(`Task lost lease. Aborting.`);
|
|
182
|
-
isTaskActive = false;
|
|
183
|
-
taskToken.set();
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
catch (error) {
|
|
187
|
-
context.logger.error('Error touching task', error);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
})();
|
|
245
|
+
const isTaskActiveObj = { value: true };
|
|
246
|
+
this.#activeTasks.set(task.id, {
|
|
247
|
+
task: task,
|
|
248
|
+
taskToken,
|
|
249
|
+
logger: context.logger,
|
|
250
|
+
isTaskActive: isTaskActiveObj,
|
|
251
|
+
});
|
|
252
|
+
if (!this.#heartbeatLoopRunning) {
|
|
253
|
+
void this.#runHeartbeatLoop();
|
|
254
|
+
}
|
|
255
|
+
context.logger.verbose('Processing task');
|
|
191
256
|
try {
|
|
192
257
|
if (taskToken.isSet) {
|
|
193
258
|
throw new Error('Task cancelled before start');
|
|
194
259
|
}
|
|
195
260
|
const result = await handler(context);
|
|
196
|
-
if (isDefined(result) &&
|
|
261
|
+
if (isDefined(result) && isTaskActiveObj.value) {
|
|
197
262
|
switch (result.payload.action) {
|
|
198
263
|
case 'complete':
|
|
199
|
-
context.logger.verbose(
|
|
264
|
+
context.logger.verbose('Completing task');
|
|
200
265
|
await this.complete(task, { result: result.payload.result });
|
|
201
266
|
break;
|
|
202
267
|
case 'fail':
|
|
203
|
-
context.logger.verbose(
|
|
268
|
+
context.logger.verbose('Failing task');
|
|
204
269
|
await this.fail(task, result.payload.error, { fatal: result.payload.fatal });
|
|
205
270
|
break;
|
|
206
271
|
case 'reschedule':
|
|
207
|
-
context.logger.verbose(
|
|
272
|
+
context.logger.verbose('Rescheduling task');
|
|
208
273
|
await this.reschedule(task.id, result.payload.timestamp);
|
|
209
274
|
break;
|
|
210
275
|
default:
|
|
211
|
-
throw new Error(
|
|
276
|
+
throw new Error('Unsupported task result action.');
|
|
212
277
|
}
|
|
213
278
|
}
|
|
214
279
|
}
|
|
215
280
|
catch (error) {
|
|
216
281
|
context.logger.error('Error processing task', error);
|
|
217
282
|
await this.fail(task, error);
|
|
283
|
+
if (isError(error) && ((error.message === 'Task cancelled before start') || (error.message == 'Unsupported task result action.'))) {
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
this.#activeTasks.delete(task.id);
|
|
218
289
|
}
|
|
219
290
|
}
|
|
220
291
|
catch (e_1) {
|
|
@@ -226,4 +297,37 @@ export class TaskQueue extends Transactional {
|
|
|
226
297
|
}
|
|
227
298
|
}
|
|
228
299
|
}
|
|
300
|
+
async #runHeartbeatLoop() {
|
|
301
|
+
if (this.#heartbeatLoopRunning) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.#heartbeatLoopRunning = true;
|
|
305
|
+
try {
|
|
306
|
+
while (this.#activeTasks.size > 0) {
|
|
307
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(this.visibilityTimeout / 2, 5000)));
|
|
308
|
+
if (this.#activeTasks.size === 0) {
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
const entries = Array.from(this.#activeTasks.values());
|
|
312
|
+
const tasks = entries.map((e) => e.task);
|
|
313
|
+
try {
|
|
314
|
+
const touchedIds = await this.touchMany(tasks);
|
|
315
|
+
const touchedSet = new Set(touchedIds);
|
|
316
|
+
for (const entry of entries) {
|
|
317
|
+
if (entry.taskToken.isUnset && !touchedSet.has(entry.task.id)) {
|
|
318
|
+
entry.logger.warn(`Task lost lease. Aborting.`);
|
|
319
|
+
entry.isTaskActive.value = false;
|
|
320
|
+
entry.taskToken.set();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
this.logger.error('Error touching tasks', error);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
this.#heartbeatLoopRunning = false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
229
333
|
}
|
|
@@ -17,7 +17,7 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
17
17
|
});
|
|
18
18
|
beforeEach(() => {
|
|
19
19
|
const queueProvider = injector.resolve(TaskQueueProvider);
|
|
20
|
-
const queueName = `branch-coverage-queue-${
|
|
20
|
+
const queueName = `branch-coverage-queue-${crypto.randomUUID()}`;
|
|
21
21
|
queue = queueProvider.get(queueName, {
|
|
22
22
|
visibilityTimeout: 200,
|
|
23
23
|
});
|
|
@@ -33,9 +33,7 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
33
33
|
it('should handle TaskProcessResult.RescheduleTo in worker', async () => {
|
|
34
34
|
const task = await queue.enqueue('reschedule-test', {});
|
|
35
35
|
const future = currentTimestamp() + 5000;
|
|
36
|
-
queue.process({ cancellationSignal: token },
|
|
37
|
-
return TaskProcessResult.RescheduleTo(future);
|
|
38
|
-
});
|
|
36
|
+
queue.process({ cancellationSignal: token }, () => TaskProcessResult.RescheduleTo(future));
|
|
39
37
|
await timeout(500);
|
|
40
38
|
token.set();
|
|
41
39
|
const updated = await queue.getTask(task.id);
|
|
@@ -44,9 +42,7 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
44
42
|
});
|
|
45
43
|
it('should handle TaskProcessResult.Fail(fatal: true) in worker', async () => {
|
|
46
44
|
const task = await queue.enqueue('fail-fatal-test', {});
|
|
47
|
-
queue.process({ cancellationSignal: token },
|
|
48
|
-
return TaskProcessResult.Fail(new Error('fatal error'), true);
|
|
49
|
-
});
|
|
45
|
+
queue.process({ cancellationSignal: token }, () => TaskProcessResult.Fail(new Error('fatal error'), true));
|
|
50
46
|
await timeout(500);
|
|
51
47
|
token.set();
|
|
52
48
|
const updated = await queue.getTask(task.id);
|
|
@@ -54,15 +50,15 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
54
50
|
});
|
|
55
51
|
it('should handle errors in touch during background lease extension', async () => {
|
|
56
52
|
const task = await queue.enqueue('touch-error-test', {});
|
|
57
|
-
// Mock
|
|
58
|
-
const
|
|
53
|
+
// Mock touchMany to throw once
|
|
54
|
+
const originalTouchMany = queue.touchMany.bind(queue);
|
|
59
55
|
let thrown = false;
|
|
60
|
-
vi.spyOn(queue, '
|
|
56
|
+
vi.spyOn(queue, 'touchMany').mockImplementation(async (t, o) => {
|
|
61
57
|
if (!thrown) {
|
|
62
58
|
thrown = true;
|
|
63
59
|
throw new Error('touch error');
|
|
64
60
|
}
|
|
65
|
-
return
|
|
61
|
+
return originalTouchMany(t, o);
|
|
66
62
|
});
|
|
67
63
|
queue.process({ cancellationSignal: token }, async () => {
|
|
68
64
|
await timeout(300); // Wait for background touch to trigger
|
|
@@ -78,8 +74,8 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
78
74
|
// visibilityTimeout is 200ms. Background touch is every 100ms.
|
|
79
75
|
const task = await queue.enqueue('lease-loss-test', {});
|
|
80
76
|
let workerFinished = false;
|
|
81
|
-
// Mock
|
|
82
|
-
vi.spyOn(queue, '
|
|
77
|
+
// Mock touchMany to return empty array (stolen/lost)
|
|
78
|
+
vi.spyOn(queue, 'touchMany').mockResolvedValue([]);
|
|
83
79
|
queue.process({ cancellationSignal: token }, async (context) => {
|
|
84
80
|
// Wait for background touch to discover lease loss
|
|
85
81
|
for (let i = 0; i < 20; i++) {
|
|
@@ -113,7 +109,10 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
113
109
|
expect(d2?.type).toBe('type1');
|
|
114
110
|
});
|
|
115
111
|
it('should handle maintenance when no tasks need maintenance', async () => {
|
|
116
|
-
await
|
|
112
|
+
await queue.enqueue('t1', {});
|
|
113
|
+
await queue.maintenance();
|
|
114
|
+
const count = await queue.count({ status: TaskStatus.Pending });
|
|
115
|
+
expect(count).toBe(1);
|
|
117
116
|
});
|
|
118
117
|
it('should handle multiple workers competing for tasks', async () => {
|
|
119
118
|
await queue.enqueue('compete', {});
|
|
@@ -143,36 +142,30 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
143
142
|
expect(result).toBeUndefined();
|
|
144
143
|
});
|
|
145
144
|
it('should handle complete with non-existent task', async () => {
|
|
146
|
-
const fakeTask = { id:
|
|
147
|
-
// Should not throw
|
|
145
|
+
const fakeTask = { id: crypto.randomUUID(), token: crypto.randomUUID() };
|
|
146
|
+
// Should not throw and should not affect existing tasks
|
|
147
|
+
await queue.enqueue('t1', {});
|
|
148
148
|
await queue.complete(fakeTask);
|
|
149
|
+
const count = await queue.count();
|
|
150
|
+
expect(count).toBe(1);
|
|
149
151
|
});
|
|
150
152
|
it('should handle dequeueMany with forceDequeue', async () => {
|
|
151
153
|
await queue.enqueue('force', {});
|
|
152
154
|
const tasks = await queue.dequeueMany(1, { forceDequeue: true });
|
|
153
155
|
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');
|
|
156
|
+
expect(tasks[0]?.status).toBe(TaskStatus.Running);
|
|
167
157
|
});
|
|
168
158
|
it('should handle cancelMany with empty ids', async () => {
|
|
169
|
-
|
|
159
|
+
await queue.enqueue('t1', {});
|
|
170
160
|
await queue.cancelMany([]);
|
|
161
|
+
const count = await queue.count({ status: TaskStatus.Pending });
|
|
162
|
+
expect(count).toBe(1);
|
|
171
163
|
});
|
|
172
164
|
it('should handle touch with progress and state', async () => {
|
|
173
165
|
const task = await queue.enqueue('touch-data', {});
|
|
174
166
|
const dequeued = await queue.dequeue();
|
|
175
|
-
await queue.touch(dequeued, { progress: 0.7, state: { ok: true } });
|
|
167
|
+
const result = await queue.touch(dequeued, { progress: 0.7, state: { ok: true } });
|
|
168
|
+
expect(result).toBeDefined();
|
|
176
169
|
const updated = await queue.getTask(task.id);
|
|
177
170
|
expect(updated?.progress).toBe(0.7);
|
|
178
171
|
expect(updated?.state).toEqual({ ok: true });
|
|
@@ -193,20 +186,8 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
193
186
|
expect(dependent.status).toBe(TaskStatus.Pending); // Because scheduleAfterCount is 0
|
|
194
187
|
expect(dependent.unresolvedCompleteDependencies).toBe(1);
|
|
195
188
|
});
|
|
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
189
|
it('should handle getTask including archived tasks', async () => {
|
|
209
|
-
const archQueue = injector.resolve(TaskQueueProvider).get(`arch-${
|
|
190
|
+
const archQueue = injector.resolve(TaskQueueProvider).get(`arch-${crypto.randomUUID()}`, { retention: 0, archiveRetention: 3600000 });
|
|
210
191
|
const t = await archQueue.enqueue('t', {});
|
|
211
192
|
const d = await archQueue.dequeue();
|
|
212
193
|
await archQueue.complete(d);
|
|
@@ -217,17 +198,20 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
217
198
|
expect(archived?.id).toBe(t.id);
|
|
218
199
|
});
|
|
219
200
|
it('should handle maintenance with no work to do', async () => {
|
|
220
|
-
await queue.
|
|
201
|
+
await queue.enqueue('t1', {});
|
|
202
|
+
await queue.maintenance();
|
|
203
|
+
const count = await queue.count({ status: TaskStatus.Pending });
|
|
204
|
+
expect(count).toBe(1);
|
|
221
205
|
});
|
|
222
206
|
it('should handle task TTL failure in maintenance', async () => {
|
|
223
207
|
// retention=0 to allow maintenance to process immediately
|
|
224
|
-
const ttlQueue = injector.resolve(TaskQueueProvider).get(`ttl-${
|
|
208
|
+
const ttlQueue = injector.resolve(TaskQueueProvider).get(`ttl-${crypto.randomUUID()}`, { retention: 0 });
|
|
225
209
|
// Enqueue with TTL in the past
|
|
226
210
|
const task = await ttlQueue.enqueue('ttl-fail', {}, { timeToLive: currentTimestamp() - 1000 });
|
|
227
211
|
await timeout(100);
|
|
228
212
|
await ttlQueue.maintenance();
|
|
229
213
|
const updated = await ttlQueue.getTask(task.id);
|
|
230
|
-
expect(updated?.status).toBe(TaskStatus.
|
|
214
|
+
expect(updated?.status).toBe(TaskStatus.Expired);
|
|
231
215
|
expect(updated?.error?.message).toContain('Task expired');
|
|
232
216
|
});
|
|
233
217
|
it('should handle cancelMany with multiple valid IDs', async () => {
|
|
@@ -241,7 +225,7 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
241
225
|
});
|
|
242
226
|
it('should handle pruning long expired tasks', async () => {
|
|
243
227
|
// archiveRetention = 0
|
|
244
|
-
const pruneQueue = injector.resolve(TaskQueueProvider).get(`prune-long-${
|
|
228
|
+
const pruneQueue = injector.resolve(TaskQueueProvider).get(`prune-long-${crypto.randomUUID()}`, { retention: 0, archiveRetention: 0 });
|
|
245
229
|
const task = await pruneQueue.enqueue('p1', {});
|
|
246
230
|
const d1 = await pruneQueue.dequeue();
|
|
247
231
|
await pruneQueue.complete(d1);
|
|
@@ -279,7 +263,7 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
279
263
|
expect(tasks).toHaveLength(1);
|
|
280
264
|
});
|
|
281
265
|
it('should handle CircuitBreaker Half-Open state in dequeue', async () => {
|
|
282
|
-
const namespace = `cb-half-${
|
|
266
|
+
const namespace = `cb-half-${crypto.randomUUID()}`;
|
|
283
267
|
const cbQueue = injector.resolve(TaskQueueProvider).get(namespace, { circuitBreakerThreshold: 1 });
|
|
284
268
|
const cb = injector.resolve(CircuitBreaker, namespace);
|
|
285
269
|
// Mock CB to be Half-Open
|
|
@@ -289,7 +273,7 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
289
273
|
expect(tasks).toBeDefined();
|
|
290
274
|
});
|
|
291
275
|
it('should handle CircuitBreaker Half-Open state blocking when already probing', async () => {
|
|
292
|
-
const namespace = `cb-block-${
|
|
276
|
+
const namespace = `cb-block-${crypto.randomUUID()}`;
|
|
293
277
|
const cbQueue = injector.resolve(TaskQueueProvider).get(namespace, { circuitBreakerThreshold: 1 });
|
|
294
278
|
const cb = injector.resolve(CircuitBreaker, namespace);
|
|
295
279
|
// Mock CB to be Half-Open but NOT a probe (already probing)
|
|
@@ -313,28 +297,32 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
313
297
|
expect(updated?.status).toBe(TaskStatus.WaitingChildren);
|
|
314
298
|
});
|
|
315
299
|
it('should handle maintenance recovering zombie tasks via touchMany logic in prune', async () => {
|
|
316
|
-
const pruneQueue = injector.resolve(TaskQueueProvider).get(`prune-fail-${
|
|
300
|
+
const pruneQueue = injector.resolve(TaskQueueProvider).get(`prune-fail-${crypto.randomUUID()}`, { maxExecutionTime: 50, retention: 0 });
|
|
317
301
|
const task = await pruneQueue.enqueue('p1', {});
|
|
318
302
|
await pruneQueue.dequeue();
|
|
319
303
|
await timeout(100);
|
|
320
304
|
await pruneQueue.maintenance();
|
|
321
305
|
const updated = await pruneQueue.getTask(task.id);
|
|
322
|
-
expect(updated?.status).toBe(TaskStatus.
|
|
306
|
+
expect(updated?.status).toBe(TaskStatus.TimedOut);
|
|
323
307
|
});
|
|
324
308
|
it('should handle enqueueMany with parentId and waitForCompletion false', async () => {
|
|
325
309
|
const parent = await queue.enqueue('p', {});
|
|
326
|
-
await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, waitForCompletion: false }]);
|
|
310
|
+
const tasks = await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, waitForCompletion: false }], { returnTasks: true });
|
|
311
|
+
expect(tasks).toHaveLength(1);
|
|
312
|
+
expect(tasks[0]?.parentId).toBe(parent.id);
|
|
313
|
+
const updatedParent = await queue.getTask(parent.id);
|
|
314
|
+
expect(updatedParent?.unresolvedCompleteDependencies).toBe(0);
|
|
327
315
|
});
|
|
328
316
|
it('should handle failMany for running tasks', async () => {
|
|
329
317
|
const t1 = await queue.enqueue('t1', {});
|
|
330
318
|
const d1 = await queue.dequeue();
|
|
331
319
|
await queue.failMany([d1], [new Error('fail')]);
|
|
332
320
|
const updated = await queue.getTask(t1.id);
|
|
333
|
-
expect(updated?.status).toBe(TaskStatus.
|
|
321
|
+
expect(updated?.status).toBe(TaskStatus.Retrying);
|
|
334
322
|
expect(updated?.tries).toBe(1);
|
|
335
323
|
});
|
|
336
324
|
it('should handle failMany reaching max tries (Dead state)', async () => {
|
|
337
|
-
const isolatedQueue = injector.resolve(TaskQueueProvider).get(`exhaust-${
|
|
325
|
+
const isolatedQueue = injector.resolve(TaskQueueProvider).get(`exhaust-${crypto.randomUUID()}`, { maxTries: 1 });
|
|
338
326
|
const t1 = await isolatedQueue.enqueue('t1', {});
|
|
339
327
|
const d1 = await isolatedQueue.dequeue();
|
|
340
328
|
await isolatedQueue.failMany([d1], [new Error('fatal')]);
|
|
@@ -390,7 +378,7 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
390
378
|
expect(result).toHaveLength(0);
|
|
391
379
|
});
|
|
392
380
|
it('should handle large batch maintenance (> 1000 tasks)', async () => {
|
|
393
|
-
const namespace = `aging-batch-${
|
|
381
|
+
const namespace = `aging-batch-${crypto.randomUUID()}`;
|
|
394
382
|
const agingQueue = injector.resolve(TaskQueueProvider).get(namespace, { priorityAgingInterval: 60000, priorityAgingStep: 1 });
|
|
395
383
|
const items = Array.from({ length: 1001 }, (_, i) => ({ type: 'batch', data: { i } }));
|
|
396
384
|
await agingQueue.enqueueMany(items);
|
|
@@ -401,7 +389,7 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
401
389
|
await agingQueue.maintenance();
|
|
402
390
|
});
|
|
403
391
|
it('should handle maintenance with empty archive and active tables', async () => {
|
|
404
|
-
const emptyQueue = injector.resolve(TaskQueueProvider).get(`empty-${
|
|
392
|
+
const emptyQueue = injector.resolve(TaskQueueProvider).get(`empty-${crypto.randomUUID()}`, { retention: 0, archiveRetention: 0 });
|
|
405
393
|
await emptyQueue.maintenance();
|
|
406
394
|
});
|
|
407
395
|
});
|