@tstdl/base 0.93.154 → 0.93.155

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.
@@ -4,7 +4,7 @@
4
4
  * simplifying common SQL operations like generating UUIDs, working with intervals,
5
5
  * and aggregating data.
6
6
  */
7
- import { Column, SQL, type AnyColumn, type SQLChunk, type SQLWrapper } from 'drizzle-orm';
7
+ import { Column, type AnyColumn, type SQL, type SQLChunk, type SQLWrapper } from 'drizzle-orm';
8
8
  import type { GetSelectTableSelection, SelectResultField, TableLike } from 'drizzle-orm/query-builders/select.types';
9
9
  import type { EnumerationObject, EnumerationValue, Record } from '../../types/types.js';
10
10
  import { type PgEnumFromEnumeration } from '../enums.js';
@@ -125,7 +125,7 @@ export declare function autoAlias<T>(column: AnyColumn<{
125
125
  * @param unit - The unit of the interval (e.g., 'day', 'hour').
126
126
  * @returns A Drizzle SQL object representing the interval.
127
127
  */
128
- export declare function interval(value: number, unit: IntervalUnit): SQL;
128
+ export declare function interval(value: number | SQL<number>, unit: IntervalUnit): SQL;
129
129
  /**
130
130
  * Creates a PostgreSQL `array_agg` aggregate function call.
131
131
  * Aggregates values from a column into a PostgreSQL array.
@@ -183,6 +183,7 @@ export declare function greatest<T extends (Column | SQL | SQL.Aliased | number)
183
183
  [P in keyof T]: T[P] extends number ? Exclude<T[P], number> | SQL<number> : T[P];
184
184
  }[number]>>;
185
185
  export declare function greatest<T>(...values: T[]): SQL<SelectResultField<T>>;
186
+ export declare function power(base: number | SQLChunk, exponent: number | SQLChunk): SQL<number>;
186
187
  export declare function unnest<T>(array: SQL<readonly T[]> | SQL.Aliased<readonly T[]> | Column): SQL<T>;
187
188
  /**
188
189
  * Creates a PostgreSQL array contains operator expression (@>).
package/orm/sqls/sqls.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * simplifying common SQL operations like generating UUIDs, working with intervals,
5
5
  * and aggregating data.
6
6
  */
7
- import { and, Column, eq, isSQLWrapper, sql, SQL, isNotNull as sqlIsNotNull, isNull as sqlIsNull, Table } from 'drizzle-orm';
7
+ import { and, Column, eq, isSQLWrapper, sql, isNotNull as sqlIsNotNull, isNull as sqlIsNull, Table } from 'drizzle-orm';
8
8
  import { match, P } from 'ts-pattern';
9
9
  import { distinct, toArray } from '../../utils/array/array.js';
10
10
  import { objectEntries, objectValues } from '../../utils/object/object.js';
@@ -146,7 +146,7 @@ export function autoAlias(column) {
146
146
  * @returns A Drizzle SQL object representing the interval.
147
147
  */
148
148
  export function interval(value, unit) {
149
- return sql `(${value} ||' ${sql.raw(unit)}')::interval`;
149
+ return sql `(${value} || ' ${sql.raw(unit)}')::interval`;
150
150
  }
151
151
  /**
152
152
  * Creates a PostgreSQL `array_agg` aggregate function call.
@@ -224,6 +224,9 @@ export function greatest(...values) {
224
224
  const sqlValues = values.map((value) => isNumber(value) ? sql.raw(String(value)) : value);
225
225
  return sql `greatest(${sql.join(sqlValues, sql.raw(', '))})`;
226
226
  }
227
+ export function power(base, exponent) {
228
+ return sql `power(${base}, ${exponent})`;
229
+ }
227
230
  export function unnest(array) {
228
231
  return sql `unnest(${array})`;
229
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.154",
3
+ "version": "0.93.155",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -152,8 +152,8 @@
152
152
  "type-fest": "^5.4"
153
153
  },
154
154
  "peerDependencies": {
155
- "@aws-sdk/client-s3": "^3.1001",
156
- "@aws-sdk/s3-request-presigner": "^3.1001",
155
+ "@aws-sdk/client-s3": "^3.1002",
156
+ "@aws-sdk/s3-request-presigner": "^3.1002",
157
157
  "@genkit-ai/google-genai": "^1.29",
158
158
  "@google-cloud/storage": "^7.19",
159
159
  "@toon-format/toon": "^2.1.0",
@@ -168,7 +168,7 @@
168
168
  "handlebars": "^4.7",
169
169
  "mjml": "^4.18",
170
170
  "nodemailer": "^8.0",
171
- "pg": "^8.19",
171
+ "pg": "^8.20",
172
172
  "playwright": "^1.58",
173
173
  "preact": "^10.28",
174
174
  "preact-render-to-string": "^6.6",
@@ -60,15 +60,14 @@ import { aliasedTable, and, asc, count, eq, gt, gte, inArray, lt, lte, notExists
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';
63
- import { serializeError, TimeoutError } from '../../errors/index.js';
63
+ import { NotFoundError, serializeError, TimeoutError } from '../../errors/index.js';
64
64
  import { afterResolve, inject, provide, Singleton } from '../../injector/index.js';
65
65
  import { Logger } from '../../logger/index.js';
66
66
  import { MessageBus } from '../../message-bus/index.js';
67
- import { arrayOverlaps, caseWhen, coalesce, enumValue, getEntityIds, greatest, interval, jsonbBuildObject, RANDOM_UUID_V4, TRANSACTION_TIMESTAMP } from '../../orm/index.js';
68
- import { Database, DatabaseConfig, injectRepository } from '../../orm/server/index.js';
67
+ import { arrayOverlaps, caseWhen, coalesce, enumValue, greatest, interval, jsonbBuildObject, least, power, RANDOM_UUID_V4, TRANSACTION_TIMESTAMP } from '../../orm/index.js';
68
+ import { DatabaseConfig, injectRepository } from '../../orm/server/index.js';
69
69
  import { RateLimiter } from '../../rate-limit/index.js';
70
70
  import { distinct, toArray } from '../../utils/array/array.js';
71
- import { currentTimestamp } from '../../utils/date-time.js';
72
71
  import { Timer } from '../../utils/timer.js';
73
72
  import { cancelableTimeout } from '../../utils/timing.js';
74
73
  import { isArray, isDefined, isNotNull, isNull, isNumber, isString, isUndefined } from '../../utils/type-guards.js';
@@ -76,10 +75,9 @@ import { millisecondsPerMinute, millisecondsPerSecond } from '../../utils/units.
76
75
  import { defaultQueueConfig, queueableOrWaitableStatuses, queueableStatuses, TaskDependencyType, TaskQueue, TaskStatus, terminalStatuses } from '../task-queue.js';
77
76
  import { ensureTaskError } from '../task.error.js';
78
77
  import { PostgresTaskQueueModuleConfig } from './module.js';
79
- import { taskArchive as taskArchiveTable, taskDependency as taskDependencyTable, taskDependencyType, taskStatus, task as taskTable } from './schemas.js';
78
+ import { taskArchive as taskArchiveTable, taskDependency as taskDependencyTable, taskStatus, task as taskTable } from './schemas.js';
80
79
  import { PostgresTask, PostgresTaskArchive } from './task.model.js';
81
80
  let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
82
- #database = inject(Database);
83
81
  #repository = injectRepository(PostgresTask);
84
82
  #archiveRepository = injectRepository(PostgresTaskArchive);
85
83
  #config = this.config;
@@ -399,13 +397,17 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
399
397
  .set({
400
398
  unresolvedScheduleDependencies: sql `${taskTable.unresolvedScheduleDependencies} + ${updates.scheduleIncrement}`,
401
399
  unresolvedCompleteDependencies: sql `${taskTable.unresolvedCompleteDependencies} + ${updates.completeIncrement}`,
402
- status: caseWhen(and(eq(taskTable.status, TaskStatus.Pending), gt(sql `${taskTable.unresolvedScheduleDependencies} + ${updates.scheduleIncrement}`, 0)), enumValue(TaskStatus, taskStatus, TaskStatus.Waiting)).else(taskTable.status),
400
+ status: caseWhen(and(eq(taskTable.status, TaskStatus.Pending), gt(sql `${taskTable.unresolvedScheduleDependencies} + ${updates.scheduleIncrement}`, 0)), TaskStatus.Waiting).else(taskTable.status),
403
401
  })
404
402
  .from(updates)
405
403
  .where(eq(taskTable.id, updates.taskId))
406
404
  .returning({ id: taskTable.id, status: taskTable.status, namespace: taskTable.namespace });
405
+ const notifiedNamespaces = new Set();
407
406
  for (const row of updatedRows) {
408
- this.notify(row.namespace);
407
+ if (!notifiedNamespaces.has(row.namespace)) {
408
+ this.notify(row.namespace);
409
+ notifiedNamespaces.add(row.namespace);
410
+ }
409
411
  }
410
412
  }
411
413
  async has(id, options) {
@@ -535,23 +537,29 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
535
537
  await this.cancelMany([id], options);
536
538
  }
537
539
  async cancelMany(ids, options) {
540
+ if (ids.length == 0) {
541
+ return;
542
+ }
538
543
  await this.#repository.useTransaction(options?.transaction, async (tx) => {
539
- const tree = await this.getTree(ids, { transaction: tx });
540
- const treeIds = tree.map((task) => task.id);
541
- if (treeIds.length == 0) {
542
- return;
543
- }
544
- const cancelledRows = await tx.pgTransaction
545
- .update(taskTable)
546
- .set({
547
- status: TaskStatus.Cancelled,
548
- token: null,
549
- completeTimestamp: TRANSACTION_TIMESTAMP,
550
- })
551
- .where(and(inArray(taskTable.id, treeIds), notInArray(taskTable.status, terminalStatuses)))
552
- .returning({ id: taskTable.id, namespace: taskTable.namespace });
553
- if (cancelledRows.length > 0) {
554
- await this.resolveDependenciesMany(cancelledRows.map((row) => ({ id: row.id, status: TaskStatus.Cancelled, namespace: row.namespace })), { transaction: tx });
544
+ const cancelledRows = await tx.pgTransaction.execute(sql `
545
+ WITH RECURSIVE task_tree AS (
546
+ SELECT id FROM ${taskTable} WHERE ${inArray(taskTable.id, ids)}
547
+ UNION ALL
548
+ SELECT child.id FROM ${taskTable} child JOIN task_tree parent ON child.parent_id = parent.id
549
+ )
550
+ UPDATE ${taskTable}
551
+ SET
552
+ status = ${enumValue(TaskStatus, taskStatus, TaskStatus.Cancelled)},
553
+ token = NULL,
554
+ complete_timestamp = ${TRANSACTION_TIMESTAMP}
555
+ FROM task_tree
556
+ WHERE
557
+ ${taskTable.id} = task_tree.id
558
+ AND ${taskTable.status} NOT IN (${sql.join(terminalStatuses.map((s) => enumValue(TaskStatus, taskStatus, s)), sql `, `)})
559
+ RETURNING ${taskTable.id} as id, ${taskTable.namespace} as namespace
560
+ `);
561
+ if (cancelledRows.rows.length > 0) {
562
+ await this.resolveDependenciesMany(cancelledRows.rows.map((row) => ({ id: row.id, status: TaskStatus.Cancelled, namespace: row.namespace })), { transaction: tx });
555
563
  }
556
564
  });
557
565
  }
@@ -705,42 +713,29 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
705
713
  if (isNull(task.token)) {
706
714
  return undefined;
707
715
  }
708
- return await this.#repository.useTransaction(options?.transaction, async (tx) => {
709
- const update = {
710
- visibilityDeadline: sql `${TRANSACTION_TIMESTAMP} + ${interval(this.visibilityTimeout, 'milliseconds')}`,
711
- };
712
- if (isDefined(options?.progress)) {
713
- update.progress = options.progress;
714
- }
715
- if (isDefined(options?.state)) {
716
- update.state = options.state;
717
- }
718
- const [updatedRow] = await tx.pgTransaction
719
- .update(taskTable)
720
- .set(update)
721
- .where(and(eq(taskTable.id, task.id), eq(taskTable.status, TaskStatus.Running), isNull(task.token) ? sqlIsNull(taskTable.token) : eq(taskTable.token, task.token), gt(taskTable.startTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.maxExecutionTime, 'milliseconds')}`)))
722
- .returning();
723
- if (isDefined(updatedRow)) {
724
- return await this.#repository.mapToEntity(updatedRow);
725
- }
726
- const [existingRow] = await tx.pgTransaction
727
- .select({ startTimestamp: taskTable.startTimestamp })
728
- .from(taskTable)
729
- .where(and(eq(taskTable.id, task.id), eq(taskTable.status, TaskStatus.Running), isNull(task.token) ? sqlIsNull(taskTable.token) : eq(taskTable.token, task.token)));
730
- if (isDefined(existingRow) && isNotNull(existingRow.startTimestamp) && (currentTimestamp() - existingRow.startTimestamp) > this.maxExecutionTime) {
731
- await tx.pgTransaction
732
- .update(taskTable)
733
- .set({
734
- status: TaskStatus.TimedOut,
735
- completeTimestamp: TRANSACTION_TIMESTAMP,
736
- error: { code: 'MaxTimeExceeded', message: 'Hard Execution Timeout' },
737
- })
738
- .where(eq(taskTable.id, task.id));
739
- await this.resolveDependenciesMany([{ id: task.id, status: TaskStatus.TimedOut, namespace: task.namespace }], { transaction: tx });
740
- this.notify();
741
- }
716
+ const session = options?.transaction?.pgTransaction ?? this.#repository.session;
717
+ const exceededMaxExecutionTime = lt(taskTable.startTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.maxExecutionTime, 'milliseconds')}`);
718
+ const [updatedRow] = await session
719
+ .update(taskTable)
720
+ .set({
721
+ status: caseWhen(exceededMaxExecutionTime, enumValue(TaskStatus, taskStatus, TaskStatus.TimedOut)).else(taskTable.status),
722
+ visibilityDeadline: caseWhen(exceededMaxExecutionTime, null).else(sql `${TRANSACTION_TIMESTAMP} + ${interval(this.visibilityTimeout, 'milliseconds')}`),
723
+ completeTimestamp: caseWhen(exceededMaxExecutionTime, TRANSACTION_TIMESTAMP).else(taskTable.completeTimestamp),
724
+ error: caseWhen(exceededMaxExecutionTime, jsonbBuildObject({ code: 'MaxTimeExceeded', message: 'Hard Execution Timeout' })).else(taskTable.error),
725
+ progress: caseWhen(exceededMaxExecutionTime, taskTable.progress).else(isDefined(options?.progress) ? options.progress : taskTable.progress),
726
+ state: caseWhen(exceededMaxExecutionTime, taskTable.state).else(isDefined(options?.state) ? options.state : taskTable.state),
727
+ })
728
+ .where(and(eq(taskTable.id, task.id), eq(taskTable.status, TaskStatus.Running), isNull(task.token) ? sqlIsNull(taskTable.token) : eq(taskTable.token, task.token)))
729
+ .returning();
730
+ if (isUndefined(updatedRow)) {
742
731
  return undefined;
743
- });
732
+ }
733
+ if (updatedRow.status == TaskStatus.TimedOut) {
734
+ await this.resolveDependencies(task.id, TaskStatus.TimedOut, { namespace: task.namespace, transaction: options?.transaction });
735
+ this.notify();
736
+ return undefined;
737
+ }
738
+ return await this.#repository.mapToEntity(updatedRow);
744
739
  }
745
740
  async touchMany(tasks, options) {
746
741
  if (tasks.length == 0) {
@@ -778,34 +773,24 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
778
773
  }
779
774
  async complete(task, options) {
780
775
  await this.#repository.useTransaction(options?.transaction, async (tx) => {
781
- const [freshTask] = await tx.pgTransaction
782
- .select({ unresolvedCompleteDependencies: taskTable.unresolvedCompleteDependencies })
783
- .from(taskTable)
784
- .where(eq(taskTable.id, task.id))
785
- .for('update');
786
- if (isUndefined(freshTask)) {
787
- return;
788
- }
789
- const hasActiveChildren = freshTask.unresolvedCompleteDependencies > 0;
790
- const nextStatus = hasActiveChildren ? TaskStatus.WaitingChildren : TaskStatus.Completed;
791
776
  const [updatedTask] = await tx.pgTransaction.update(taskTable)
792
777
  .set({
793
- status: nextStatus,
778
+ status: caseWhen(gt(taskTable.unresolvedCompleteDependencies, 0), enumValue(TaskStatus, taskStatus, TaskStatus.WaitingChildren)).else(enumValue(TaskStatus, taskStatus, TaskStatus.Completed)),
794
779
  token: null,
795
780
  result: options?.result,
796
- progress: hasActiveChildren ? task.progress : 1,
797
- completeTimestamp: (nextStatus == TaskStatus.Completed) ? TRANSACTION_TIMESTAMP : null,
781
+ progress: caseWhen(gt(taskTable.unresolvedCompleteDependencies, 0), task.progress).else(sql.raw('1')),
782
+ completeTimestamp: caseWhen(gt(taskTable.unresolvedCompleteDependencies, 0), null).else(TRANSACTION_TIMESTAMP),
798
783
  visibilityDeadline: null,
799
784
  })
800
785
  .where(and(eq(taskTable.id, task.id), isNull(task.token) ? sqlIsNull(taskTable.token) : eq(taskTable.token, task.token)))
801
- .returning({ id: taskTable.id });
786
+ .returning({ id: taskTable.id, status: taskTable.status });
802
787
  if (isUndefined(updatedTask)) {
803
788
  return;
804
789
  }
805
- if (nextStatus == TaskStatus.Completed) {
790
+ if (updatedTask.status == TaskStatus.Completed) {
806
791
  await this.#circuitBreaker.recordSuccess();
807
792
  }
808
- await this.resolveDependencies(task.id, nextStatus, { namespace: task.namespace, transaction: tx });
793
+ await this.resolveDependencies(task.id, updatedTask.status, { namespace: task.namespace, transaction: tx });
809
794
  });
810
795
  }
811
796
  async completeMany(tasks, options) {
@@ -855,9 +840,9 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
855
840
  const isRetryable = (options?.fatal != true) && (task.tries < this.maxTries);
856
841
  const nextStatus = isRetryable ? TaskStatus.Retrying : TaskStatus.Dead;
857
842
  const delay = isRetryable
858
- ? Math.min(this.retryDelayMaximum, this.retryDelayMinimum * (this.retryDelayGrowth ** task.tries))
843
+ ? Math.min(this.retryDelayMaximum, this.retryDelayMinimum * (this.retryDelayGrowth ** (task.tries - 1)))
859
844
  : 0;
860
- const nextSchedule = currentTimestamp() + delay;
845
+ const nextSchedule = sql `${TRANSACTION_TIMESTAMP} + ${interval(delay, 'milliseconds')}`;
861
846
  await this.#repository.useTransaction(options?.transaction, async (tx) => {
862
847
  const [updatedRow] = await tx.pgTransaction
863
848
  .update(taskTable)
@@ -866,7 +851,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
866
851
  token: null,
867
852
  error: serializeError(error),
868
853
  visibilityDeadline: null,
869
- scheduleTimestamp: nextSchedule,
854
+ scheduleTimestamp: isRetryable ? nextSchedule : taskTable.scheduleTimestamp,
870
855
  startTimestamp: null,
871
856
  completeTimestamp: (nextStatus == TaskStatus.Dead) ? TRANSACTION_TIMESTAMP : null,
872
857
  })
@@ -889,31 +874,31 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
889
874
  const isRetryable = (task.tries < this.maxTries);
890
875
  const nextStatus = isRetryable ? TaskStatus.Retrying : TaskStatus.Dead;
891
876
  const delay = isRetryable
892
- ? Math.min(this.retryDelayMaximum, this.retryDelayMinimum * (this.retryDelayGrowth ** task.tries))
877
+ ? Math.min(this.retryDelayMaximum, this.retryDelayMinimum * (this.retryDelayGrowth ** (task.tries - 1)))
893
878
  : 0;
894
- const nextSchedule = new Date(currentTimestamp() + delay);
895
- const completeTimestamp = (nextStatus == TaskStatus.Dead) ? new Date() : null;
896
- return sql `(${task.id}::uuid, ${task.token}::uuid, ${task.tries}::int, ${nextStatus}::text, ${serializeError(error)}::jsonb, ${nextSchedule}::timestamptz, ${completeTimestamp}::timestamptz)`;
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)`;
897
882
  });
898
883
  const updates = tx.pgTransaction.$with('updates').as((qb) => qb
899
884
  .select({
900
- updateId: sql `(id)::uuid`.as('update_id'),
901
- updateToken: sql `(token)::uuid`.as('update_token'),
902
- updateTries: sql `(tries)::int`.as('update_tries'),
903
- updateStatus: sql `(status)::text`.as('update_status'),
904
- updateError: sql `(error)::jsonb`.as('update_error'),
905
- updateSchedule: sql `(schedule_timestamp)::timestamptz`.as('update_schedule'),
906
- updateComplete: sql `(complete_timestamp)::timestamptz`.as('update_complete'),
885
+ updateId: sql `(id)`.as('update_id'),
886
+ updateToken: sql `(token)`.as('update_token'),
887
+ updateTries: sql `(tries)`.as('update_tries'),
888
+ updateStatus: sql `(status)`.as('update_status'),
889
+ updateError: sql `(error)`.as('update_error'),
890
+ updateSchedule: sql `(schedule_timestamp)`.as('update_schedule'),
891
+ updateComplete: sql `(complete_timestamp)`.as('update_complete'),
907
892
  })
908
893
  .from(sql `(VALUES ${sql.join(rows, sql `, `)}) AS t(id, token, tries, status, error, schedule_timestamp, complete_timestamp)`));
909
894
  const updated = tx.pgTransaction.$with('updated').as(() => tx.pgTransaction
910
895
  .update(taskTable)
911
896
  .set({
912
- status: sql `${updates.updateStatus}::${taskStatus}`,
897
+ status: sql `${updates.updateStatus}`,
913
898
  token: null,
914
899
  error: sql `${updates.updateError}`,
915
900
  visibilityDeadline: null,
916
- scheduleTimestamp: sql `${updates.updateSchedule}`,
901
+ scheduleTimestamp: caseWhen(eq(updates.updateStatus, TaskStatus.Retrying), updates.updateSchedule).else(taskTable.scheduleTimestamp),
917
902
  startTimestamp: null,
918
903
  completeTimestamp: sql `${updates.updateComplete}`,
919
904
  })
@@ -934,41 +919,41 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
934
919
  await this.resolveDependenciesMany([{ id, status, namespace: options?.namespace }], options);
935
920
  }
936
921
  async resolveDependenciesMany(tasks, options) {
937
- if (tasks.length == 0) {
922
+ const tasksToResolve = tasks.filter((t) => terminalStatuses.includes(t.status));
923
+ if (tasksToResolve.length == 0) {
938
924
  return;
939
925
  }
940
- const taskStatusMap = new Map(tasks.map((t) => [t.id, t.status]));
941
926
  const notifiedNamespaces = new Set();
942
927
  await this.#repository.useTransaction(options?.transaction, async (tx) => {
943
- const taskIds = tasks.map((t) => t.id);
944
- const dependents = await tx.pgTransaction
928
+ const taskValues = tasksToResolve.map((t) => sql `(${t.id}::uuid, ${t.status}::${taskStatus})`);
929
+ // 1. CTE: Load the incoming terminal tasks into a memory table
930
+ const resolvedTasks = tx.pgTransaction.$with('resolved_tasks').as((qb) => qb
945
931
  .select({
946
- taskId: taskDependencyTable.taskId,
947
- dependencyTaskId: taskDependencyTable.dependencyTaskId,
948
- type: taskDependencyTable.type,
949
- requiredStatuses: taskDependencyTable.requiredStatuses,
932
+ resolvedId: sql `(id)`.as('resolved_id'),
933
+ resolvedStatus: sql `(status)`.as('resolved_status'),
950
934
  })
951
- .from(taskDependencyTable)
952
- .where(inArray(taskDependencyTable.dependencyTaskId, taskIds));
953
- if (dependents.length == 0) {
954
- return;
955
- }
956
- const resolvedEdges = [];
957
- const abortOnDependencyFailureTaskIds = new Set();
958
- for (const dep of dependents) {
959
- const status = taskStatusMap.get(dep.dependencyTaskId);
960
- const isMatched = dep.requiredStatuses.includes(status);
961
- const isTerminal = terminalStatuses.includes(status);
962
- if (isMatched || isTerminal) {
963
- resolvedEdges.push(dep);
964
- if (!isMatched) {
965
- abortOnDependencyFailureTaskIds.add(dep.taskId);
966
- }
967
- }
968
- }
935
+ .from(sql `(VALUES ${sql.join(taskValues, sql `, `)}) AS t(id, status)`));
936
+ // 2. CTE: Atomically delete all edges pointing to these terminal tasks and return them
937
+ const deletedEdges = tx.pgTransaction.$with('deleted_edges').as(() => tx.pgTransaction
938
+ .delete(taskDependencyTable)
939
+ .where(inArray(taskDependencyTable.dependencyTaskId, tx.pgTransaction.select({ id: resolvedTasks.resolvedId }).from(resolvedTasks)))
940
+ .returning());
941
+ // 3. Execute: Join deleted edges with their resolving status to determine if they matched the required status
942
+ const resolvedEdges = await tx.pgTransaction
943
+ .with(resolvedTasks, deletedEdges)
944
+ .select({
945
+ taskId: deletedEdges.taskId,
946
+ dependencyTaskId: deletedEdges.dependencyTaskId,
947
+ type: deletedEdges.type,
948
+ isMatched: sql `${resolvedTasks.resolvedStatus} = ANY(${deletedEdges.requiredStatuses})`.as('is_matched'),
949
+ })
950
+ .from(deletedEdges)
951
+ .innerJoin(resolvedTasks, eq(deletedEdges.dependencyTaskId, resolvedTasks.resolvedId));
969
952
  if (resolvedEdges.length == 0) {
970
953
  return;
971
954
  }
955
+ // Extract skipped dependencies (terminal status but not a matched status)
956
+ const abortOnDependencyFailureTaskIds = distinct(resolvedEdges.filter((d) => !d.isMatched).map((d) => d.taskId));
972
957
  const sortedResolvedEdges = resolvedEdges.toSorted((a, b) => {
973
958
  const idCompare = a.taskId.localeCompare(b.taskId);
974
959
  if (idCompare != 0) {
@@ -980,18 +965,10 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
980
965
  }
981
966
  return a.type.localeCompare(b.type);
982
967
  });
983
- const edgeValues = sortedResolvedEdges.map((e) => sql `(${e.taskId}::uuid, ${e.dependencyTaskId}::uuid, ${e.type}::text)`);
984
- await tx.pgTransaction.execute(sql `
985
- DELETE FROM ${taskDependencyTable}
986
- WHERE (task_id, dependency_task_id, type) IN (
987
- SELECT t.task_id, t.dependency_task_id, t.type::${taskDependencyType}
988
- FROM (VALUES ${sql.join(edgeValues, sql `, `)}) AS t(task_id, dependency_task_id, type)
989
- )
990
- `);
991
968
  const terminalTasks = [];
992
969
  const skippedTaskIds = new Set();
993
- if (abortOnDependencyFailureTaskIds.size > 0) {
994
- const sortedAbortIds = [...abortOnDependencyFailureTaskIds].toSorted();
970
+ if (abortOnDependencyFailureTaskIds.length > 0) {
971
+ const sortedAbortIds = abortOnDependencyFailureTaskIds.toSorted();
995
972
  const dependentTasks = await tx.pgTransaction
996
973
  .select({ id: taskTable.id, namespace: taskTable.namespace, abortOnDependencyFailure: taskTable.abortOnDependencyFailure, status: taskTable.status })
997
974
  .from(taskTable)
@@ -1054,7 +1031,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1054
1031
  .set({
1055
1032
  unresolvedScheduleDependencies: greatest(0, sql `${taskTable.unresolvedScheduleDependencies} - ${updates.scheduleDecrement}`),
1056
1033
  unresolvedCompleteDependencies: greatest(0, sql `${taskTable.unresolvedCompleteDependencies} - ${updates.completeDecrement}`),
1057
- status: caseWhen(and(eq(taskTable.status, TaskStatus.Waiting), eq(greatest(0, sql `${taskTable.unresolvedScheduleDependencies} - ${updates.scheduleDecrement}`), 0)), enumValue(TaskStatus, taskStatus, TaskStatus.Pending)).else(caseWhen(and(eq(taskTable.status, TaskStatus.WaitingChildren), eq(greatest(0, sql `${taskTable.unresolvedCompleteDependencies} - ${updates.completeDecrement}`), 0)), enumValue(TaskStatus, taskStatus, TaskStatus.Completed)).else(taskTable.status)),
1034
+ status: caseWhen(and(eq(taskTable.status, TaskStatus.Waiting), eq(greatest(0, sql `${taskTable.unresolvedScheduleDependencies} - ${updates.scheduleDecrement}`), 0)), TaskStatus.Pending).else(caseWhen(and(eq(taskTable.status, TaskStatus.WaitingChildren), eq(greatest(0, sql `${taskTable.unresolvedCompleteDependencies} - ${updates.completeDecrement}`), 0)), TaskStatus.Completed).else(taskTable.status)),
1058
1035
  progress: caseWhen(and(eq(taskTable.status, TaskStatus.WaitingChildren), eq(greatest(0, sql `${taskTable.unresolvedCompleteDependencies} - ${updates.completeDecrement}`), 0)), 1).else(taskTable.progress),
1059
1036
  completeTimestamp: caseWhen(and(eq(taskTable.status, TaskStatus.WaitingChildren), eq(greatest(0, sql `${taskTable.unresolvedCompleteDependencies} - ${updates.completeDecrement}`), 0)), TRANSACTION_TIMESTAMP).else(taskTable.completeTimestamp),
1060
1037
  token: caseWhen(and(eq(taskTable.status, TaskStatus.WaitingChildren), eq(greatest(0, sql `${taskTable.unresolvedCompleteDependencies} - ${updates.completeDecrement}`), 0)), null).else(taskTable.token),
@@ -1086,47 +1063,50 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1086
1063
  }
1087
1064
  }
1088
1065
  async maintenance(options) {
1089
- await Promise.allSettled([
1090
- this.processExpirations(options),
1091
- this.processZombieRetries(options),
1092
- this.processZombieExhaustions(options),
1093
- this.processHardTimeouts(options),
1094
- this.processPriorityAging(options),
1095
- ]);
1066
+ await this.processExpirations(options);
1067
+ await this.processZombieRetries(options);
1068
+ await this.processZombieExhaustions(options);
1069
+ await this.processHardTimeouts(options);
1070
+ await this.processPriorityAging(options);
1096
1071
  await this.performArchival(options);
1097
1072
  await this.performArchivePurge(options);
1098
1073
  }
1099
1074
  async performArchival(options) {
1075
+ const session = options?.transaction?.pgTransaction ?? this.#repository.session;
1100
1076
  while (true) {
1101
- const archivedCount = await this.#repository.useTransaction(options?.transaction, async (tx) => {
1102
- const childTaskTable = aliasedTable(taskTable, 'childTask');
1103
- const rowsToArchive = await tx.pgTransaction
1104
- .select()
1105
- .from(taskTable)
1106
- .where(and(eq(taskTable.namespace, this.#namespace), inArray(taskTable.status, terminalStatuses), lte(taskTable.completeTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.retention, 'milliseconds')}`), notExists(tx.pgTransaction
1107
- .select({ id: childTaskTable.id })
1108
- .from(childTaskTable)
1109
- .where(eq(childTaskTable.parentId, taskTable.id))), notExists(tx.pgTransaction
1110
- .select({ taskId: taskDependencyTable.taskId })
1111
- .from(taskDependencyTable)
1112
- .where(eq(taskDependencyTable.dependencyTaskId, taskTable.id)))))
1113
- .limit(1000)
1114
- .for('update', { skipLocked: true });
1115
- if (rowsToArchive.length > 0) {
1116
- const rowsToArchiveIds = getEntityIds(rowsToArchive);
1117
- await tx.pgTransaction.insert(taskArchiveTable).values(rowsToArchive);
1118
- await tx.pgTransaction.delete(taskTable).where(inArray(taskTable.id, rowsToArchiveIds));
1119
- }
1120
- return rowsToArchive.length;
1121
- });
1122
- if (archivedCount < 1000) {
1077
+ const childTaskTable = aliasedTable(taskTable, 'childTask');
1078
+ const selection = session.$with('selection').as((qb) => qb
1079
+ .select({ id: taskTable.id })
1080
+ .from(taskTable)
1081
+ .where(and(eq(taskTable.namespace, this.#namespace), inArray(taskTable.status, terminalStatuses), lte(taskTable.completeTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.retention, 'milliseconds')}`), notExists(session
1082
+ .select({ id: childTaskTable.id })
1083
+ .from(childTaskTable)
1084
+ .where(eq(childTaskTable.parentId, taskTable.id))), notExists(session
1085
+ .select({ taskId: taskDependencyTable.taskId })
1086
+ .from(taskDependencyTable)
1087
+ .where(eq(taskDependencyTable.dependencyTaskId, taskTable.id)))))
1088
+ .limit(1000)
1089
+ .for('update', { skipLocked: true }));
1090
+ const deleted = session.$with('deleted').as(() => session
1091
+ .delete(taskTable)
1092
+ .where(inArray(taskTable.id, session.select().from(selection)))
1093
+ .returning());
1094
+ const inserted = session.$with('inserted').as(() => session
1095
+ .insert(taskArchiveTable)
1096
+ .select(session.select().from(deleted))
1097
+ .returning({ id: taskArchiveTable.id }));
1098
+ const [result] = await session
1099
+ .with(selection, deleted, inserted)
1100
+ .select({ count: count() })
1101
+ .from(inserted);
1102
+ if ((result?.count ?? 0) < 1000) {
1123
1103
  break;
1124
1104
  }
1125
1105
  }
1126
1106
  }
1127
1107
  async performArchivePurge(options) {
1128
- const session = options?.transaction?.pgTransaction ?? this.#database;
1129
- const selection = session.$with('archive_purge_selection').as((qb) => qb
1108
+ const session = options?.transaction?.pgTransaction ?? this.#repository.session;
1109
+ const selection = session.$with('selection').as((qb) => qb
1130
1110
  .select({ id: taskArchiveTable.id })
1131
1111
  .from(taskArchiveTable)
1132
1112
  .where(and(eq(taskArchiveTable.namespace, this.#namespace), lte(taskArchiveTable.completeTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.archiveRetention, 'milliseconds')}`)))
@@ -1143,7 +1123,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1143
1123
  }
1144
1124
  }
1145
1125
  async processExpirations(options) {
1146
- const expiredSelection = this.#database.$with('expired_selection').as((qb) => qb
1126
+ const expiredSelection = this.#repository.session.$with('expired_selection').as((qb) => qb
1147
1127
  .select({ id: taskTable.id })
1148
1128
  .from(taskTable)
1149
1129
  .where(and(eq(taskTable.namespace, this.#namespace), inArray(taskTable.status, queueableOrWaitableStatuses), lt(taskTable.timeToLive, TRANSACTION_TIMESTAMP)))
@@ -1173,7 +1153,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1173
1153
  }
1174
1154
  }
1175
1155
  async processZombieRetries(options) {
1176
- const session = options?.transaction?.pgTransaction ?? this.#database;
1156
+ const session = options?.transaction?.pgTransaction ?? this.#repository.session;
1177
1157
  const zombieRetrySelection = session.$with('zombie_retry_selection').as((qb) => qb
1178
1158
  .select({ id: taskTable.id })
1179
1159
  .from(taskTable)
@@ -1189,7 +1169,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1189
1169
  token: null,
1190
1170
  visibilityDeadline: null,
1191
1171
  startTimestamp: null,
1192
- scheduleTimestamp: sql `${TRANSACTION_TIMESTAMP} + ${interval(this.retryDelayMinimum, 'milliseconds')}`, // Simple backoff for zombies
1172
+ scheduleTimestamp: sql `${TRANSACTION_TIMESTAMP} + ${interval(least(this.retryDelayMaximum, sql `${this.retryDelayMinimum} * ${power(this.retryDelayGrowth, sql `${taskTable.tries} - 1`)}`), 'milliseconds')}`,
1193
1173
  error: jsonbBuildObject({ code: 'VisibilityTimeout', message: 'Worker Lost', lastError: taskTable.error }),
1194
1174
  })
1195
1175
  .where(inArray(taskTable.id, session.select().from(zombieRetrySelection)))
@@ -1200,7 +1180,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1200
1180
  }
1201
1181
  }
1202
1182
  async processZombieExhaustions(options) {
1203
- const zombieExhaustionSelection = this.#database.$with('zombie_exhaustion_selection').as((qb) => qb
1183
+ const zombieExhaustionSelection = this.#repository.session.$with('selection').as((qb) => qb
1204
1184
  .select({ id: taskTable.id })
1205
1185
  .from(taskTable)
1206
1186
  .where(and(eq(taskTable.namespace, this.#namespace), eq(taskTable.status, TaskStatus.Running), lt(taskTable.visibilityDeadline, TRANSACTION_TIMESTAMP), gte(taskTable.tries, this.maxTries)))
@@ -1231,7 +1211,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1231
1211
  }
1232
1212
  }
1233
1213
  async processHardTimeouts(options) {
1234
- const timeoutSelection = this.#database.$with('timeout_selection').as((qb) => qb
1214
+ const timeoutSelection = this.#repository.session.$with('selection').as((qb) => qb
1235
1215
  .select({ id: taskTable.id })
1236
1216
  .from(taskTable)
1237
1217
  .where(and(eq(taskTable.namespace, this.#namespace), eq(taskTable.status, TaskStatus.Running), lt(taskTable.startTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.maxExecutionTime, 'milliseconds')}`)))
@@ -1262,7 +1242,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1262
1242
  }
1263
1243
  }
1264
1244
  async processPriorityAging(options) {
1265
- const session = options?.transaction?.pgTransaction ?? this.#database;
1245
+ const session = options?.transaction?.pgTransaction ?? this.#repository.session;
1266
1246
  const agingSelection = session.$with('aging_selection').as((qb) => qb
1267
1247
  .select({ id: taskTable.id })
1268
1248
  .from(taskTable)
@@ -1285,8 +1265,8 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1285
1265
  }
1286
1266
  }
1287
1267
  async restart(id, options) {
1288
- const repository = this.#repository.withOptionalTransaction(options?.transaction);
1289
- await repository.session
1268
+ const session = options?.transaction?.pgTransaction ?? this.#repository.session;
1269
+ const [updatedTask] = await session
1290
1270
  .update(taskTable)
1291
1271
  .set({
1292
1272
  status: TaskStatus.Pending,
@@ -1301,7 +1281,12 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
1301
1281
  priorityAgeTimestamp: TRANSACTION_TIMESTAMP,
1302
1282
  state: (options?.resetState == true) ? null : undefined,
1303
1283
  })
1304
- .where(and(eq(taskTable.id, id), or(inArray(taskTable.status, queueableStatuses), inArray(taskTable.status, terminalStatuses), lt(taskTable.visibilityDeadline, TRANSACTION_TIMESTAMP))));
1284
+ .where(and(eq(taskTable.id, id), or(inArray(taskTable.status, queueableStatuses), inArray(taskTable.status, terminalStatuses), lt(taskTable.visibilityDeadline, TRANSACTION_TIMESTAMP))))
1285
+ .returning();
1286
+ if (isUndefined(updatedTask)) {
1287
+ throw new NotFoundError('Task not found or not in a restartable state.');
1288
+ }
1289
+ this.notify(updatedTask.namespace);
1305
1290
  }
1306
1291
  notify(namespace = this.#namespace) {
1307
1292
  this.#messageBus.publishAndForget(namespace);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,127 @@
1
+ import { and, eq, sql } from 'drizzle-orm';
2
+ import { beforeAll, describe, expect, vi } from 'vitest';
3
+ import { inject } from '../../injector/index.js';
4
+ import { TRANSACTION_TIMESTAMP } from '../../orm/index.js';
5
+ import { injectRepository } from '../../orm/server/index.js';
6
+ import { setupIntegrationTest, testInInjector } from '../../testing/index.js';
7
+ import { timeout } from '../../utils/timing.js';
8
+ import { taskArchive as taskArchiveTable, taskDependency as taskDependencyTable, task as taskTable } from '../postgres/schemas.js';
9
+ import { PostgresTaskQueue } from '../postgres/task-queue.js';
10
+ import { PostgresTask, PostgresTaskArchive } from '../postgres/task.model.js';
11
+ import { TaskDependencyType, TaskStatus } from '../task-queue.js';
12
+ describe('Task Queue Optimization Edge Cases', () => {
13
+ let context;
14
+ beforeAll(async () => {
15
+ context = await setupIntegrationTest({ modules: { taskQueue: true } });
16
+ });
17
+ testInInjector('should notify unique namespaces exactly once in incrementCounters', () => context.injector, async () => {
18
+ const q1 = inject(PostgresTaskQueue, 'ns-1');
19
+ const q2 = inject(PostgresTaskQueue, 'ns-2');
20
+ const notifySpy = vi.spyOn(PostgresTaskQueue.prototype, 'notify');
21
+ const [t1] = await q1.enqueueMany([{ type: 't1', data: {} }], { returnTasks: true });
22
+ const [t2] = await q2.enqueueMany([{ type: 't2', data: {} }], { returnTasks: true });
23
+ notifySpy.mockClear();
24
+ await q1.incrementCounters([
25
+ { taskId: t1.id, dependencyTaskId: 'some-dep', type: TaskDependencyType.Schedule },
26
+ { taskId: t2.id, dependencyTaskId: 'some-dep', type: TaskDependencyType.Schedule },
27
+ { taskId: t1.id, dependencyTaskId: 'other-dep', type: TaskDependencyType.Schedule },
28
+ ]);
29
+ const notifiedNamespaces = notifySpy.mock.calls.map(call => call[0]);
30
+ expect(notifiedNamespaces).toContain('ns-1');
31
+ expect(notifiedNamespaces).toContain('ns-2');
32
+ expect(notifiedNamespaces.filter(n => n == 'ns-1').length).toBe(1);
33
+ expect(notifiedNamespaces.filter(n => n == 'ns-2').length).toBe(1);
34
+ });
35
+ testInInjector('should resolve edge and abort on unmatched terminal status in resolveDependenciesMany', () => context.injector, async () => {
36
+ const queue = inject(PostgresTaskQueue, 'test-namespace');
37
+ const repository = injectRepository(PostgresTask);
38
+ const [parent] = await queue.enqueueMany([{ type: 'parent', data: {}, abortOnDependencyFailure: true }], { returnTasks: true });
39
+ const [child] = await queue.enqueueMany([{ type: 'child', data: {} }], { returnTasks: true });
40
+ await repository.session.insert(taskDependencyTable).values({
41
+ taskId: parent.id,
42
+ dependencyTaskId: child.id,
43
+ type: TaskDependencyType.Schedule,
44
+ requiredStatuses: [TaskStatus.Completed],
45
+ });
46
+ await repository.session.update(taskTable).set({ unresolvedScheduleDependencies: 1, status: TaskStatus.Waiting }).where(eq(taskTable.id, parent.id));
47
+ await queue.resolveDependenciesMany([{ id: child.id, status: TaskStatus.Dead, namespace: 'test-namespace' }]);
48
+ const updatedParent = await queue.getTask(parent.id);
49
+ expect(updatedParent.status).toBe(TaskStatus.Skipped);
50
+ });
51
+ testInInjector('should handle hard timeout during touch', () => context.injector, async () => {
52
+ // Configure with small maxExecutionTime
53
+ const queue = inject(PostgresTaskQueue, { namespace: 'timeout-test', maxExecutionTime: 10 });
54
+ const [task] = await queue.enqueueMany([{ type: 'test', data: {} }], { returnTasks: true });
55
+ const [runningTask] = await queue.dequeueMany(1);
56
+ await timeout(50);
57
+ const result = await queue.touch(runningTask);
58
+ expect(result).toBeUndefined();
59
+ const updated = await queue.getTask(runningTask.id);
60
+ expect(updated.status).toBe(TaskStatus.TimedOut);
61
+ });
62
+ testInInjector('should handle non-existent tasks in complete and fail', () => context.injector, async () => {
63
+ const queue = inject(PostgresTaskQueue, 'missing-test');
64
+ const fakeTask = { id: crypto.randomUUID(), token: crypto.randomUUID(), tries: 0 };
65
+ await expect(queue.complete(fakeTask)).resolves.toBeUndefined();
66
+ await expect(queue.fail(fakeTask, new Error('fail'))).resolves.toBeUndefined();
67
+ });
68
+ testInInjector('should handle terminal tasks with no dependents in resolveDependenciesMany', () => context.injector, async () => {
69
+ const queue = inject(PostgresTaskQueue, 'no-deps-test');
70
+ const [task] = await queue.enqueueMany([{ type: 'test', data: {} }], { returnTasks: true });
71
+ await queue.resolveDependenciesMany([{ id: task.id, status: TaskStatus.Completed }]);
72
+ });
73
+ testInInjector('should handle archival and purge in maintenance', () => context.injector, async () => {
74
+ const namespace = `archival-test-${crypto.randomUUID()}`;
75
+ // Configure with small retention
76
+ const queue = inject(PostgresTaskQueue, { namespace, retention: 1000, archiveRetention: 1000 });
77
+ const repository = injectRepository(PostgresTask);
78
+ const archiveRepository = injectRepository(PostgresTaskArchive);
79
+ const [task] = await queue.enqueueMany([{ type: 'test', data: {} }], { returnTasks: true });
80
+ await repository.session.update(taskTable)
81
+ .set({
82
+ status: TaskStatus.Completed,
83
+ completeTimestamp: sql `${TRANSACTION_TIMESTAMP} - interval '2 seconds'`,
84
+ })
85
+ .where(and(eq(taskTable.id, task.id), eq(taskTable.namespace, namespace)));
86
+ await queue.performArchival();
87
+ const archived = await archiveRepository.load(task.id);
88
+ expect(archived).toBeDefined();
89
+ await repository.session.update(taskArchiveTable)
90
+ .set({ completeTimestamp: sql `${TRANSACTION_TIMESTAMP} - interval '2 seconds'` })
91
+ .where(and(eq(taskArchiveTable.id, task.id), eq(taskArchiveTable.namespace, namespace)));
92
+ await queue.performArchivePurge();
93
+ const purged = await archiveRepository.load(task.id).catch(() => undefined);
94
+ expect(purged).toBeUndefined();
95
+ });
96
+ testInInjector('should notify on restart', () => context.injector, async () => {
97
+ const namespace = `restart-test-${crypto.randomUUID()}`;
98
+ const queue = inject(PostgresTaskQueue, namespace);
99
+ const notifySpy = vi.spyOn(PostgresTaskQueue.prototype, 'notify');
100
+ const [task] = await queue.enqueueMany([{ type: 'test', data: {} }], { returnTasks: true });
101
+ await queue.cancelMany([task.id]);
102
+ notifySpy.mockClear();
103
+ await queue.restart(task.id);
104
+ expect(notifySpy).toHaveBeenCalledWith(namespace);
105
+ });
106
+ testInInjector('should use exponential backoff for zombies', () => context.injector, async () => {
107
+ const namespace = `zombie-backoff-${crypto.randomUUID()}`;
108
+ // Configure with standard growth
109
+ const queue = inject(PostgresTaskQueue, { namespace, retryDelayMinimum: 1000, retryDelayGrowth: 2 });
110
+ const repository = injectRepository(PostgresTask);
111
+ const [task] = await queue.enqueueMany([{ type: 'test', data: {} }], { returnTasks: true });
112
+ await queue.dequeueMany(1);
113
+ await repository.session.update(taskTable)
114
+ .set({
115
+ status: TaskStatus.Running,
116
+ visibilityDeadline: sql `${TRANSACTION_TIMESTAMP} - interval '1 minute'`,
117
+ tries: 2,
118
+ })
119
+ .where(and(eq(taskTable.id, task.id), eq(taskTable.namespace, namespace)));
120
+ await queue.processZombieRetries();
121
+ const updated = await queue.getTask(task.id);
122
+ expect(updated.status).toBe(TaskStatus.Retrying);
123
+ const delay = updated.scheduleTimestamp - Date.now();
124
+ expect(delay).toBeGreaterThan(1000);
125
+ expect(delay).toBeLessThan(3000);
126
+ });
127
+ });
package/test5.js CHANGED
@@ -3,12 +3,18 @@ import { Application } from './application/application.js';
3
3
  import { provideModule, provideSignalHandler } from './application/index.js';
4
4
  import { PrettyPrintLogFormatter } from './logger/index.js';
5
5
  import { provideConsoleLogTransport } from './logger/transports/console.js';
6
+ import { TaskQueue } from './task-queue/task-queue.js';
7
+ import { setupIntegrationTest } from './testing/integration-setup.js';
8
+ import { createArray } from './utils/array/array.js';
9
+ import { timedBenchmarkAsync } from './utils/benchmark.js';
6
10
  async function main(_cancellationSignal) {
7
- const arr = [1, 2, 3, 4, 5];
8
- for (const item of arr) {
9
- console.log(item);
10
- arr.push(arr.at(-1) + 1);
11
- }
11
+ const { injector } = await setupIntegrationTest({ modules: { taskQueue: true } });
12
+ const queue1 = injector.resolve(TaskQueue, 'namespace-1');
13
+ const batch = createArray(1000, (i) => ({ type: 'test', data: { index: i } }));
14
+ const enqueueResult = await timedBenchmarkAsync(1000, async () => {
15
+ await queue1.enqueueMany(batch);
16
+ });
17
+ console.log(enqueueResult.operationsPerMillisecond * batch.length * 1000, 'items/s');
12
18
  }
13
19
  Application.run('Test', [
14
20
  provideConsoleLogTransport(PrettyPrintLogFormatter),