@tstdl/base 0.93.157 → 0.93.158

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.157",
3
+ "version": "0.93.158",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -110,6 +110,7 @@ export declare class PostgresTaskQueue<Definitions extends TaskDefinitionMap = T
110
110
  failMany(tasks: Task<Definitions>[], errors: unknown[], options?: {
111
111
  transaction?: Transaction;
112
112
  }): Promise<void>;
113
+ private getFailureUpdate;
113
114
  private resolveDependencies;
114
115
  private resolveDependenciesMany;
115
116
  maintenance(options?: {
@@ -118,9 +119,7 @@ export declare class PostgresTaskQueue<Definitions extends TaskDefinitionMap = T
118
119
  private performArchival;
119
120
  private performArchivePurge;
120
121
  private processExpirations;
121
- private processZombieRetries;
122
- private processZombieExhaustions;
123
- private processHardTimeouts;
122
+ private recoverStaleTasks;
124
123
  private processPriorityAging;
125
124
  restart(id: string, options?: {
126
125
  resetState?: boolean;
@@ -56,7 +56,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
56
56
  var e = new Error(message);
57
57
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
58
58
  });
59
- import { aliasedTable, and, asc, count, eq, gt, gte, inArray, lt, lte, notExists, notInArray, or, sql, count as sqlCount, isNotNull as sqlIsNotNull, isNull as sqlIsNull } from 'drizzle-orm';
59
+ import { aliasedTable, and, asc, count, eq, gt, gte, inArray, lt, lte, not, notExists, notInArray, or, sql, count as sqlCount, isNotNull as sqlIsNotNull, isNull as sqlIsNull } from 'drizzle-orm';
60
60
  import { filter, merge, throttleTime } from 'rxjs';
61
61
  import { CancellationSignal } from '../../cancellation/index.js';
62
62
  import { CircuitBreaker, CircuitBreakerState } from '../../circuit-breaker/index.js';
@@ -837,23 +837,17 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
837
837
  }
838
838
  async fail(task, caughtError, options) {
839
839
  const error = ensureTaskError(caughtError);
840
- const isRetryable = (options?.fatal != true) && (task.tries < this.maxTries);
841
- const nextStatus = isRetryable ? TaskStatus.Retrying : TaskStatus.Dead;
842
- const delay = isRetryable
843
- ? Math.min(this.retryDelayMaximum, this.retryDelayMinimum * (this.retryDelayGrowth ** (task.tries - 1)))
844
- : 0;
845
- const nextSchedule = sql `${TRANSACTION_TIMESTAMP} + ${interval(delay, 'milliseconds')}`;
840
+ const update = this.getFailureUpdate(task.tries, error, options);
846
841
  await this.#repository.useTransaction(options?.transaction, async (tx) => {
847
842
  const [updatedRow] = await tx.pgTransaction
848
843
  .update(taskTable)
849
844
  .set({
850
- status: nextStatus,
845
+ status: update.status,
851
846
  token: null,
852
- error: serializeError(error),
847
+ error: update.error,
853
848
  visibilityDeadline: null,
854
- scheduleTimestamp: isRetryable ? nextSchedule : taskTable.scheduleTimestamp,
855
- startTimestamp: null,
856
- completeTimestamp: (nextStatus == TaskStatus.Dead) ? TRANSACTION_TIMESTAMP : null,
849
+ scheduleTimestamp: (update.status == TaskStatus.Retrying) ? update.scheduleTimestamp : taskTable.scheduleTimestamp,
850
+ completeTimestamp: update.completeTimestamp,
857
851
  })
858
852
  .where(and(eq(taskTable.id, task.id), isNull(task.token) ? sqlIsNull(taskTable.token) : eq(taskTable.token, task.token), eq(taskTable.tries, task.tries)))
859
853
  .returning();
@@ -861,7 +855,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
861
855
  return;
862
856
  }
863
857
  await this.#circuitBreaker.recordFailure();
864
- await this.resolveDependencies(task.id, nextStatus, { namespace: task.namespace, transaction: tx });
858
+ await this.resolveDependencies(task.id, update.status, { namespace: task.namespace, transaction: tx });
865
859
  });
866
860
  }
867
861
  async failMany(tasks, errors, options) {
@@ -871,14 +865,8 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
871
865
  await this.#repository.useTransaction(options?.transaction, async (tx) => {
872
866
  const rows = tasks.map((task, index) => {
873
867
  const error = ensureTaskError(errors[index]);
874
- const isRetryable = (task.tries < this.maxTries);
875
- const nextStatus = isRetryable ? TaskStatus.Retrying : TaskStatus.Dead;
876
- const delay = isRetryable
877
- ? Math.min(this.retryDelayMaximum, this.retryDelayMinimum * (this.retryDelayGrowth ** (task.tries - 1)))
878
- : 0;
879
- const nextSchedule = sql `(${TRANSACTION_TIMESTAMP} + ${interval(delay, 'milliseconds')})`;
880
- const completeTimestamp = (nextStatus == TaskStatus.Dead) ? TRANSACTION_TIMESTAMP : null;
881
- return sql `(${task.id}::uuid, ${task.token}::uuid, ${task.tries}::int, ${nextStatus}::${taskStatus}, ${serializeError(error)}::jsonb, ${nextSchedule}::timestamptz, ${completeTimestamp}::timestamptz)`;
868
+ const update = this.getFailureUpdate(task.tries, error);
869
+ return sql `(${task.id}::uuid, ${task.token}::uuid, ${task.tries}::int, ${update.status}::${taskStatus}, ${update.error}::jsonb, ${update.scheduleTimestamp}::timestamptz, ${update.completeTimestamp}::timestamptz)`;
882
870
  });
883
871
  const updates = tx.pgTransaction.$with('updates').as((qb) => qb
884
872
  .select({
@@ -899,7 +887,6 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
899
887
  error: sql `${updates.updateError}`,
900
888
  visibilityDeadline: null,
901
889
  scheduleTimestamp: caseWhen(eq(updates.updateStatus, TaskStatus.Retrying), updates.updateSchedule).else(taskTable.scheduleTimestamp),
902
- startTimestamp: null,
903
890
  completeTimestamp: sql `${updates.updateComplete}`,
904
891
  })
905
892
  .from(updates)
@@ -915,6 +902,16 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
915
902
  }
916
903
  });
917
904
  }
905
+ getFailureUpdate(tries, error, options) {
906
+ const isRetryable = (options?.fatal != true) && (tries < this.maxTries);
907
+ const status = isRetryable ? TaskStatus.Retrying : TaskStatus.Dead;
908
+ const delay = isRetryable
909
+ ? Math.min(this.retryDelayMaximum, this.retryDelayMinimum * (this.retryDelayGrowth ** (tries - 1)))
910
+ : 0;
911
+ const scheduleTimestamp = sql `(${TRANSACTION_TIMESTAMP} + ${interval(delay, 'milliseconds')})`;
912
+ const completeTimestamp = (status == TaskStatus.Dead) ? TRANSACTION_TIMESTAMP : null;
913
+ return { status, error: serializeError(error), scheduleTimestamp, completeTimestamp };
914
+ }
918
915
  async resolveDependencies(id, status, options) {
919
916
  await this.resolveDependenciesMany([{ id, status, namespace: options?.namespace }], options);
920
917
  }
@@ -1063,10 +1060,8 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1063
1060
  }
1064
1061
  }
1065
1062
  async maintenance(options) {
1063
+ await this.recoverStaleTasks(options);
1066
1064
  await this.processExpirations(options);
1067
- await this.processZombieRetries(options);
1068
- await this.processZombieExhaustions(options);
1069
- await this.processHardTimeouts(options);
1070
1065
  await this.processPriorityAging(options);
1071
1066
  await this.performArchival(options);
1072
1067
  await this.performArchivePurge(options);
@@ -1152,91 +1147,35 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1152
1147
  }
1153
1148
  }
1154
1149
  }
1155
- async processZombieRetries(options) {
1150
+ async recoverStaleTasks(options) {
1156
1151
  const session = options?.transaction?.pgTransaction ?? this.#repository.session;
1157
- const zombieRetrySelection = session.$with('zombie_retry_selection').as((qb) => qb
1158
- .select({ id: taskTable.id })
1159
- .from(taskTable)
1160
- .where(and(eq(taskTable.namespace, this.#namespace), eq(taskTable.status, TaskStatus.Running), lt(taskTable.visibilityDeadline, TRANSACTION_TIMESTAMP), lt(taskTable.tries, this.maxTries)))
1161
- .limit(1000)
1162
- .for('update', { skipLocked: true }));
1163
- while (true) {
1164
- const zombieRetryRows = await session
1165
- .with(zombieRetrySelection)
1166
- .update(taskTable)
1167
- .set({
1168
- status: TaskStatus.Retrying,
1169
- token: null,
1170
- visibilityDeadline: null,
1171
- startTimestamp: null,
1172
- scheduleTimestamp: sql `${TRANSACTION_TIMESTAMP} + ${interval(least(this.retryDelayMaximum, sql `${this.retryDelayMinimum} * ${power(this.retryDelayGrowth, sql `${taskTable.tries} - 1`)}`), 'milliseconds')}`,
1173
- error: jsonbBuildObject({ code: 'VisibilityTimeout', message: 'Worker Lost', lastError: taskTable.error }),
1174
- })
1175
- .where(inArray(taskTable.id, session.select().from(zombieRetrySelection)))
1176
- .returning({ id: taskTable.id });
1177
- if (zombieRetryRows.length < 1000) {
1178
- break;
1179
- }
1180
- }
1181
- }
1182
- async processZombieExhaustions(options) {
1183
- const zombieExhaustionSelection = this.#repository.session.$with('selection').as((qb) => qb
1184
- .select({ id: taskTable.id })
1185
- .from(taskTable)
1186
- .where(and(eq(taskTable.namespace, this.#namespace), eq(taskTable.status, TaskStatus.Running), lt(taskTable.visibilityDeadline, TRANSACTION_TIMESTAMP), gte(taskTable.tries, this.maxTries)))
1187
- .limit(1000)
1188
- .for('update', { skipLocked: true }));
1189
- while (true) {
1190
- const updatedCount = await this.#repository.useTransaction(options?.transaction, async (tx) => {
1191
- const exhaustionRows = await tx.pgTransaction
1192
- .with(zombieExhaustionSelection)
1193
- .update(taskTable)
1194
- .set({
1195
- status: TaskStatus.Orphaned,
1196
- token: null,
1197
- visibilityDeadline: null,
1198
- completeTimestamp: TRANSACTION_TIMESTAMP,
1199
- error: jsonbBuildObject({ code: 'ZombieExhausted', message: 'Exceeded max retries after repeated crashes', lastError: taskTable.error }),
1200
- })
1201
- .where(inArray(taskTable.id, tx.pgTransaction.select().from(zombieExhaustionSelection)))
1202
- .returning({ id: taskTable.id });
1203
- if (exhaustionRows.length > 0) {
1204
- await this.resolveDependenciesMany(exhaustionRows.map((r) => ({ id: r.id, status: TaskStatus.Orphaned, namespace: this.#namespace })), { transaction: tx });
1205
- }
1206
- return exhaustionRows.length;
1207
- });
1208
- if (updatedCount < 1000) {
1209
- break;
1210
- }
1211
- }
1212
- }
1213
- async processHardTimeouts(options) {
1214
- const timeoutSelection = this.#repository.session.$with('selection').as((qb) => qb
1152
+ const selection = session.$with('selection').as((qb) => qb
1215
1153
  .select({ id: taskTable.id })
1216
1154
  .from(taskTable)
1217
- .where(and(eq(taskTable.namespace, this.#namespace), eq(taskTable.status, TaskStatus.Running), lt(taskTable.startTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.maxExecutionTime, 'milliseconds')}`)))
1155
+ .where(and(eq(taskTable.namespace, this.#namespace), eq(taskTable.status, TaskStatus.Running), or(lt(taskTable.visibilityDeadline, TRANSACTION_TIMESTAMP), lt(taskTable.startTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.maxExecutionTime, 'milliseconds')}`))))
1218
1156
  .limit(1000)
1219
1157
  .for('update', { skipLocked: true }));
1220
1158
  while (true) {
1221
- const updatedCount = await this.#repository.useTransaction(options?.transaction, async (tx) => {
1222
- const timeoutRows = await tx.pgTransaction
1223
- .with(timeoutSelection)
1159
+ const rows = await this.#repository.useTransaction(options?.transaction, async (tx) => {
1160
+ const updatedRows = await tx.pgTransaction
1161
+ .with(selection)
1224
1162
  .update(taskTable)
1225
1163
  .set({
1226
- status: TaskStatus.TimedOut,
1164
+ status: caseWhen(lt(taskTable.startTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.maxExecutionTime, 'milliseconds')}`), enumValue(TaskStatus, taskStatus, TaskStatus.TimedOut)).else(caseWhen(lt(taskTable.tries, this.maxTries), enumValue(TaskStatus, taskStatus, TaskStatus.Retrying)).else(enumValue(TaskStatus, taskStatus, TaskStatus.Orphaned))),
1227
1165
  token: null,
1228
1166
  visibilityDeadline: null,
1229
- completeTimestamp: TRANSACTION_TIMESTAMP,
1230
- error: jsonbBuildObject({ code: 'MaxTimeExceeded', message: sql `'Hard Execution Timeout: Task ran longer than ' || ${this.maxExecutionTime} || 'ms'`, lastError: taskTable.error }),
1167
+ completeTimestamp: caseWhen(or(lt(taskTable.startTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.maxExecutionTime, 'milliseconds')}`), gte(taskTable.tries, this.maxTries)), TRANSACTION_TIMESTAMP).else(null),
1168
+ scheduleTimestamp: caseWhen(and(lt(taskTable.visibilityDeadline, TRANSACTION_TIMESTAMP), lt(taskTable.tries, this.maxTries), not(lt(taskTable.startTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.maxExecutionTime, 'milliseconds')}`))), sql `${TRANSACTION_TIMESTAMP} + ${interval(least(this.retryDelayMaximum, sql `${this.retryDelayMinimum} * ${power(this.retryDelayGrowth, sql `${taskTable.tries} - 1`)}`), 'milliseconds')}`).else(taskTable.scheduleTimestamp),
1169
+ error: caseWhen(lt(taskTable.startTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.maxExecutionTime, 'milliseconds')}`), jsonbBuildObject({ code: 'MaxTimeExceeded', message: sql `'Hard Execution Timeout: Task ran longer than ' || ${this.maxExecutionTime} || 'ms'`, lastError: taskTable.error })).else(caseWhen(lt(taskTable.tries, this.maxTries), jsonbBuildObject({ code: 'VisibilityTimeout', message: 'Worker Lost', lastError: taskTable.error })).else(jsonbBuildObject({ code: 'ZombieExhausted', message: 'Exceeded max retries after repeated crashes', lastError: taskTable.error }))),
1231
1170
  })
1232
- .where(inArray(taskTable.id, tx.pgTransaction.select().from(timeoutSelection)))
1233
- .returning({ id: taskTable.id });
1234
- if (timeoutRows.length > 0) {
1235
- await this.resolveDependenciesMany(timeoutRows.map((r) => ({ id: r.id, status: TaskStatus.TimedOut, namespace: this.#namespace })), { transaction: tx });
1171
+ .where(inArray(taskTable.id, tx.pgTransaction.select().from(selection)))
1172
+ .returning({ id: taskTable.id, status: taskTable.status });
1173
+ if (updatedRows.length > 0) {
1174
+ await this.resolveDependenciesMany(updatedRows.map((r) => ({ id: r.id, status: r.status, namespace: this.#namespace })), { transaction: tx });
1236
1175
  }
1237
- return timeoutRows.length;
1176
+ return updatedRows;
1238
1177
  });
1239
- if (updatedCount < 1000) {
1178
+ if (rows.length < 1000) {
1240
1179
  break;
1241
1180
  }
1242
1181
  }
@@ -1345,12 +1284,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1345
1284
  async highFrequencyMaintenanceLoop() {
1346
1285
  while (this.#cancellationSignal.isUnset) {
1347
1286
  try {
1348
- const results = await Promise.allSettled([
1349
- this.processZombieRetries(),
1350
- this.processZombieExhaustions(),
1351
- this.processHardTimeouts(),
1352
- ]);
1353
- this.logPromiseAllSettledErrors(results, 'high frequency maintenance loop');
1287
+ await this.recoverStaleTasks();
1354
1288
  }
1355
1289
  catch (error) {
1356
1290
  this.#logger.error('Error during high frequency maintenance loop', error);
@@ -117,7 +117,7 @@ describe('Task Queue Optimization Edge Cases', () => {
117
117
  tries: 2,
118
118
  })
119
119
  .where(and(eq(taskTable.id, task.id), eq(taskTable.namespace, namespace)));
120
- await queue.processZombieRetries();
120
+ await queue.recoverStaleTasks();
121
121
  const updated = await queue.getTask(task.id);
122
122
  expect(updated.status).toBe(TaskStatus.Retrying);
123
123
  const delay = updated.scheduleTimestamp - Date.now();
@@ -185,6 +185,42 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
185
185
  expect(updated?.tries).toBe(1);
186
186
  expect(updated?.error).toBeDefined();
187
187
  });
188
+ it('should NOT clear startTimestamp when transitioning to terminal or retry states', async () => {
189
+ // Re-create queue with high circuit breaker threshold for this test
190
+ const q = injector.resolve(TaskQueueProvider).get(`start-timestamp-${crypto.randomUUID()}`, {
191
+ circuitBreakerThreshold: 10,
192
+ });
193
+ const task = await q.enqueue('foo', { foo: 'bar' });
194
+ // 1. Dequeue to start it (sets startTimestamp)
195
+ const dequeued = await q.dequeue();
196
+ expect(dequeued?.startTimestamp).not.toBeNull();
197
+ const startTimestamp = dequeued.startTimestamp;
198
+ // 2. Fail it (transitions to Retrying)
199
+ await q.fail(dequeued, new Error('temp failure'));
200
+ const retryingTask = await q.getTask(task.id);
201
+ expect(retryingTask?.status).toBe(TaskStatus.Retrying);
202
+ expect(retryingTask?.startTimestamp).toBe(startTimestamp);
203
+ // 3. Dequeue again (updates startTimestamp)
204
+ await q.reschedule(task.id, currentTimestamp());
205
+ const dequeued2 = await q.dequeue();
206
+ expect(dequeued2?.startTimestamp).not.toBe(startTimestamp);
207
+ const startTimestamp2 = dequeued2.startTimestamp;
208
+ // 4. Fail fatally (transitions to Dead)
209
+ await q.fail(dequeued2, new Error('fatal failure'), { fatal: true });
210
+ const deadTask = await q.getTask(task.id);
211
+ expect(deadTask?.status).toBe(TaskStatus.Dead);
212
+ expect(deadTask?.startTimestamp).toBe(startTimestamp2);
213
+ // 5. Success (transitions to Completed)
214
+ const task2 = await q.enqueue('foo', { foo: 'bar2' });
215
+ const dequeued3 = await q.dequeue();
216
+ expect(dequeued3).toBeDefined();
217
+ const startTimestamp3 = dequeued3.startTimestamp;
218
+ await q.complete(dequeued3);
219
+ const completedTask = await q.getTask(task2.id);
220
+ expect(completedTask?.status).toBe(TaskStatus.Completed);
221
+ expect(completedTask?.startTimestamp).toBe(startTimestamp3);
222
+ await q.clear();
223
+ });
188
224
  });
189
225
  describe('Hierarchy and Cross-Namespace', () => {
190
226
  it('should correctly increment parent unresolved dependencies when a child is spawned in a different namespace', async () => {
@@ -336,9 +372,11 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
336
372
  });
337
373
  });
338
374
  describe('Timeouts and Maintenance (Pruning)', () => {
339
- it('should recover "Zombie" tasks (crashed workers)', async () => {
375
+ it('should recover "Zombie" tasks (crashed workers) and preserve startTimestamp', async () => {
340
376
  const task = await queue.enqueue('foo', { foo: 'zombie' });
341
- await queue.dequeue(); // Task is now Running with a token
377
+ const dequeued = await queue.dequeue(); // Task is now Running with a token
378
+ expect(dequeued?.startTimestamp).not.toBeNull();
379
+ const startTimestamp = dequeued.startTimestamp;
342
380
  // processTimeout is 50ms. Wait for it to expire.
343
381
  await timeout(100);
344
382
  await queue.maintenance();
@@ -346,6 +384,7 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
346
384
  expect(recovered?.status).toBe(TaskStatus.Retrying);
347
385
  expect(recovered?.tries).toBe(1);
348
386
  expect(recovered?.token).toBeNull();
387
+ expect(recovered?.startTimestamp).toBe(startTimestamp);
349
388
  });
350
389
  it('should fail tasks that exceed Hard Execution Timeout via prune', async () => {
351
390
  // Re-configure queue with very short execution timeout