@tstdl/base 0.93.76 → 0.93.78

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.
Files changed (103) hide show
  1. package/ai/prompts/instructions-formatter.d.ts +68 -5
  2. package/ai/prompts/instructions-formatter.js +11 -3
  3. package/authentication/client/http-client.middleware.js +2 -2
  4. package/authentication/models/authentication-credentials.model.d.ts +2 -2
  5. package/authentication/models/authentication-credentials.model.js +5 -3
  6. package/authentication/models/authentication-session.model.d.ts +2 -2
  7. package/authentication/models/authentication-session.model.js +5 -3
  8. package/authentication/models/index.d.ts +4 -0
  9. package/authentication/models/index.js +4 -0
  10. package/authentication/models/service-account.model.d.ts +7 -0
  11. package/authentication/models/service-account.model.js +31 -0
  12. package/authentication/models/subject.model.d.ts +16 -0
  13. package/authentication/models/subject.model.js +59 -0
  14. package/authentication/models/system-account.model.d.ts +5 -0
  15. package/authentication/models/system-account.model.js +25 -0
  16. package/authentication/models/user.model.d.ts +15 -0
  17. package/authentication/models/user.model.js +47 -0
  18. package/authentication/server/drizzle/0001_condemned_pretty_boy.sql +70 -0
  19. package/authentication/server/drizzle/meta/0001_snapshot.json +651 -0
  20. package/authentication/server/drizzle/meta/_journal.json +7 -0
  21. package/authentication/server/index.d.ts +1 -0
  22. package/authentication/server/index.js +1 -0
  23. package/authentication/server/schemas.d.ts +16 -1
  24. package/authentication/server/schemas.js +7 -1
  25. package/authentication/server/subject.service.d.ts +6 -0
  26. package/authentication/server/subject.service.js +44 -0
  27. package/circuit-breaker/circuit-breaker.d.ts +32 -0
  28. package/circuit-breaker/circuit-breaker.js +9 -0
  29. package/circuit-breaker/index.d.ts +2 -0
  30. package/circuit-breaker/index.js +2 -0
  31. package/circuit-breaker/postgres/circuit-breaker.d.ts +7 -0
  32. package/circuit-breaker/postgres/circuit-breaker.js +78 -0
  33. package/circuit-breaker/postgres/drizzle/0000_hard_shocker.sql +9 -0
  34. package/circuit-breaker/postgres/drizzle/meta/0000_snapshot.json +82 -0
  35. package/circuit-breaker/postgres/drizzle/meta/_journal.json +13 -0
  36. package/circuit-breaker/postgres/drizzle.config.d.ts +2 -0
  37. package/circuit-breaker/postgres/drizzle.config.js +11 -0
  38. package/circuit-breaker/postgres/index.d.ts +5 -0
  39. package/circuit-breaker/postgres/index.js +5 -0
  40. package/circuit-breaker/postgres/model.d.ts +9 -0
  41. package/circuit-breaker/postgres/model.js +40 -0
  42. package/circuit-breaker/postgres/module.d.ts +6 -0
  43. package/circuit-breaker/postgres/module.js +25 -0
  44. package/circuit-breaker/postgres/provider.d.ts +6 -0
  45. package/circuit-breaker/postgres/provider.js +21 -0
  46. package/circuit-breaker/postgres/schemas.d.ts +8 -0
  47. package/circuit-breaker/postgres/schemas.js +6 -0
  48. package/circuit-breaker/provider.d.ts +4 -0
  49. package/circuit-breaker/provider.js +2 -0
  50. package/circuit-breaker/tests/circuit-breaker.test.js +113 -0
  51. package/document-management/models/document.model.d.ts +0 -1
  52. package/document-management/models/document.model.js +0 -5
  53. package/document-management/server/api/document-management.api.js +1 -2
  54. package/document-management/server/drizzle/0002_round_warbird.sql +1 -0
  55. package/document-management/server/drizzle/meta/0002_snapshot.json +2722 -0
  56. package/document-management/server/drizzle/meta/_journal.json +7 -0
  57. package/document-management/server/services/document-collection.service.js +3 -3
  58. package/document-management/server/services/document-management-ancillary.service.d.ts +1 -1
  59. package/document-management/server/services/document-management.service.js +1 -1
  60. package/document-management/server/services/document-workflow.service.js +5 -5
  61. package/document-management/server/services/document.service.d.ts +0 -2
  62. package/document-management/server/services/document.service.js +1 -2
  63. package/document-management/service-models/enriched/enriched-document.view.d.ts +1 -1
  64. package/examples/document-management/main.d.ts +1 -1
  65. package/examples/document-management/main.js +1 -1
  66. package/logger/transports/console.d.ts +1 -1
  67. package/logger/transports/console.js +4 -1
  68. package/message-bus/message-bus-base.js +1 -1
  69. package/package.json +8 -5
  70. package/queue/enqueue-batch.d.ts +11 -11
  71. package/queue/enqueue-batch.js +2 -3
  72. package/queue/index.d.ts +1 -0
  73. package/queue/index.js +1 -0
  74. package/queue/postgres/drizzle/0003_tricky_venom.sql +30 -0
  75. package/queue/postgres/drizzle/meta/0003_snapshot.json +288 -0
  76. package/queue/postgres/drizzle/meta/_journal.json +7 -0
  77. package/queue/postgres/drizzle.config.js +2 -2
  78. package/queue/postgres/index.d.ts +1 -1
  79. package/queue/postgres/index.js +1 -1
  80. package/queue/postgres/module.d.ts +1 -1
  81. package/queue/postgres/module.js +1 -1
  82. package/queue/postgres/queue.d.ts +52 -23
  83. package/queue/postgres/queue.js +582 -64
  84. package/queue/postgres/queue.provider.d.ts +1 -1
  85. package/queue/postgres/schemas.d.ts +13 -2
  86. package/queue/postgres/schemas.js +4 -2
  87. package/queue/postgres/task.model.d.ts +24 -0
  88. package/queue/postgres/task.model.js +115 -0
  89. package/queue/provider.d.ts +1 -1
  90. package/queue/queue.d.ts +158 -37
  91. package/queue/queue.js +97 -19
  92. package/queue/task-context.d.ts +38 -0
  93. package/queue/task-context.js +102 -0
  94. package/queue/tests/queue.test.d.ts +1 -0
  95. package/queue/tests/queue.test.js +623 -0
  96. package/test4.d.ts +1 -1
  97. package/test4.js +1 -1
  98. package/utils/format-error.d.ts +17 -20
  99. package/utils/format-error.js +105 -47
  100. package/queue/postgres/job.model.d.ts +0 -12
  101. package/queue/postgres/job.model.js +0 -53
  102. package/test6.js +0 -33
  103. /package/{test6.d.ts → circuit-breaker/tests/circuit-breaker.test.d.ts} +0 -0
@@ -4,67 +4,106 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { and, asc, eq, inArray, isNull as isSqlNull, lt, lte, or, sql } from 'drizzle-orm';
7
+ import { and, asc, eq, gt, inArray, lt, lte, or, sql, isNull as sqlIsNull } from 'drizzle-orm';
8
8
  import { merge } from 'rxjs';
9
9
  import { CancellationSignal } from '../../cancellation/index.js';
10
+ import { CircuitBreaker, CircuitBreakerState } from '../../circuit-breaker/index.js';
11
+ import { Enumerable } from '../../enumerable/enumerable.js';
10
12
  import { inject, injectArgument, provide, Singleton } from '../../injector/index.js';
11
13
  import { MessageBus } from '../../message-bus/index.js';
12
- import { interval, RANDOM_UUID_V4, TRANSACTION_TIMESTAMP } from '../../orm/index.js';
14
+ import { coalesce, interval, RANDOM_UUID_V4, TRANSACTION_TIMESTAMP } from '../../orm/index.js';
13
15
  import { DatabaseConfig, injectRepository } from '../../orm/server/index.js';
16
+ import { toArray } from '../../utils/array/array.js';
17
+ import { currentTimestamp } from '../../utils/date-time.js';
18
+ import { serializeError } from '../../utils/format-error.js';
14
19
  import { cancelableTimeout } from '../../utils/timing.js';
15
- import { isDefined, isString, isUndefined } from '../../utils/type-guards.js';
20
+ import { isDefined, isNotNull, isNull, isString, isUndefined } from '../../utils/type-guards.js';
16
21
  import { millisecondsPerSecond } from '../../utils/units.js';
17
- import { defaultQueueConfig, Queue, UniqueTagStrategy } from '../queue.js';
18
- import { PostgresJob } from './job.model.js';
22
+ import { defaultQueueConfig, Queue, TaskState, UniqueTagStrategy } from '../queue.js';
19
23
  import { PostgresQueueModuleConfig } from './module.js';
20
- import { job } from './schemas.js';
24
+ import { task as taskTable } from './schemas.js';
25
+ import { PostgresTask } from './task.model.js';
21
26
  let PostgresQueue = class PostgresQueue extends Queue {
22
- #repository = injectRepository(PostgresJob);
27
+ #repository = injectRepository(PostgresTask);
23
28
  #config = injectArgument(this);
24
29
  #queueName = isString(this.#config) ? this.#config : this.#config.name;
25
30
  #messageBus = inject((MessageBus), `PostgresQueue:${this.#queueName}`);
26
- #keepOldUpdate = { id: sql `${job.id}` };
31
+ #circuitBreaker = inject(CircuitBreaker, {
32
+ key: this.#queueName,
33
+ threshold: (isString(this.#config) ? undefined : this.#config.circuitBreakerThreshold) ?? defaultQueueConfig.circuitBreakerThreshold,
34
+ resetTimeout: (isString(this.#config) ? undefined : this.#config.circuitBreakerResetTimeout) ?? defaultQueueConfig.circuitBreakerResetTimeout,
35
+ });
27
36
  processTimeout = (isString(this.#config) ? undefined : this.#config.processTimeout) ?? defaultQueueConfig.processTimeout;
37
+ executionTimeout = (isString(this.#config) ? undefined : this.#config.executionTimeout) ?? defaultQueueConfig.executionTimeout;
28
38
  maxTries = (isString(this.#config) ? undefined : this.#config.maxTries) ?? defaultQueueConfig.maxTries;
39
+ retryDelayMinimum = (isString(this.#config) ? undefined : this.#config.retryDelayMinimum) ?? defaultQueueConfig.retryDelayMinimum;
40
+ retryDelayMaximum = (isString(this.#config) ? undefined : this.#config.retryDelayMaximum) ?? defaultQueueConfig.retryDelayMaximum;
41
+ retryDelayGrowth = (isString(this.#config) ? undefined : this.#config.retryDelayGrowth) ?? defaultQueueConfig.retryDelayGrowth;
42
+ retentionPeriod = (isString(this.#config) ? undefined : this.#config.retentionPeriod) ?? defaultQueueConfig.retentionPeriod;
43
+ globalConcurrency = (isString(this.#config) ? undefined : this.#config.globalConcurrency) ?? defaultQueueConfig.globalConcurrency;
44
+ // -- Updates --
45
+ #keepOldUpdate = { id: sql `${taskTable.id}` };
29
46
  #takeNewUpdate = {
30
47
  id: RANDOM_UUID_V4,
31
48
  queue: this.#queueName,
32
49
  priority: sql `excluded.priority`,
33
50
  tag: sql `excluded.tag`,
51
+ status: TaskState.Pending,
52
+ lease: null,
34
53
  tries: 0,
54
+ progress: 0,
35
55
  enqueueTimestamp: TRANSACTION_TIMESTAMP,
36
- lastDequeueTimestamp: null,
56
+ scheduleTimestamp: sql `excluded.schedule_timestamp`,
57
+ startTimestamp: null,
58
+ expirationTimestamp: sql `excluded.expiration_timestamp`,
59
+ lockExpirationTimestamp: null,
37
60
  data: sql `excluded.data`,
38
- };
39
- #dequeueQuery = and(eq(job.queue, this.#queueName), lt(job.tries, this.maxTries), or(isSqlNull(job.lastDequeueTimestamp), lte(sql `${job.lastDequeueTimestamp} + ${interval(this.processTimeout, 'milliseconds')}`, TRANSACTION_TIMESTAMP)));
40
- #dequeueUpdate = {
41
- tries: sql `${job.tries} + 1`,
42
- lastDequeueTimestamp: TRANSACTION_TIMESTAMP,
61
+ state: null,
62
+ error: null,
63
+ result: null,
43
64
  };
44
65
  async enqueue(data, options) {
45
- const jobs = await this.enqueueMany([{ data, tag: options?.tag, priority: options?.priority }], { uniqueTag: options?.uniqueTag, returnJobs: true });
46
- return jobs[0];
66
+ const tasks = await this.enqueueMany([{ data, ...options }], { uniqueTag: options?.uniqueTag, returnTasks: true, transaction: options?.transaction });
67
+ return tasks[0];
47
68
  }
48
69
  async enqueueMany(items, options) {
70
+ if (items.length == 0) {
71
+ return (options?.returnTasks == true) ? [] : undefined;
72
+ }
49
73
  const newEntities = items.map((item) => ({
50
74
  queue: this.#queueName,
75
+ status: TaskState.Pending,
76
+ lease: null,
51
77
  priority: item.priority ?? 1000,
52
78
  tag: item.tag ?? null,
79
+ parentId: item.parentId ?? null,
53
80
  tries: 0,
81
+ progress: 0,
54
82
  enqueueTimestamp: TRANSACTION_TIMESTAMP,
55
- lastDequeueTimestamp: null,
83
+ scheduleTimestamp: item.scheduleTimestamp ?? TRANSACTION_TIMESTAMP,
84
+ startTimestamp: null,
85
+ expirationTimestamp: item.expirationTimestamp ?? null,
86
+ lockExpirationTimestamp: null,
87
+ completeTimestamp: null,
56
88
  data: item.data,
89
+ state: null,
90
+ result: null,
91
+ error: null,
57
92
  }));
58
93
  const update = (options?.uniqueTag == UniqueTagStrategy.TakeNew)
59
94
  ? this.#takeNewUpdate
60
95
  : (options?.uniqueTag == UniqueTagStrategy.KeepOld)
61
96
  ? this.#keepOldUpdate
62
97
  : undefined;
63
- const jobs = isUndefined(update)
64
- ? await this.#repository.insertMany(newEntities)
65
- : await this.#repository.upsertMany(['queue', 'tag'], newEntities, update);
98
+ const repository = this.#repository.withOptionalTransaction(options?.transaction);
99
+ const tasks = isUndefined(update)
100
+ ? await repository.insertMany(newEntities)
101
+ : await repository.upsertMany(['queue', 'tag'], newEntities, update);
66
102
  this.#messageBus.publishAndForget();
67
- return jobs;
103
+ if (options?.returnTasks == true) {
104
+ return tasks;
105
+ }
106
+ return undefined;
68
107
  }
69
108
  async has(id) {
70
109
  return await this.#repository.hasByQuery({ queue: this.#queueName, id });
@@ -81,60 +120,539 @@ let PostgresQueue = class PostgresQueue extends Queue {
81
120
  async getByTags(tags) {
82
121
  return await this.#repository.loadManyByQuery({ queue: this.#queueName, tag: { $in: tags } });
83
122
  }
84
- async cancel(id) {
85
- await this.#repository.hardDeleteByQuery({ queue: this.#queueName, id });
123
+ async getTree(idOrIds, transaction) {
124
+ const ids = toArray(idOrIds);
125
+ return await this.getTreeByQuery({ id: { $in: ids } }, transaction);
86
126
  }
87
- async cancelMany(ids) {
88
- await this.#repository.hardDeleteManyByQuery({ queue: this.#queueName, id: { $in: ids } });
127
+ async getTreeByQuery(query, transaction) {
128
+ return await this.#repository.useTransaction(transaction, async (tx) => {
129
+ const repositoryWithTransaction = this.#repository.withTransaction(tx);
130
+ const tasks = await repositoryWithTransaction.loadManyByQuery(query);
131
+ if (tasks.length == 0) {
132
+ return [];
133
+ }
134
+ let currentLevelIds = tasks.map((task) => task.id);
135
+ let depth = 0;
136
+ while (true) {
137
+ if (depth++ > 100) {
138
+ throw new Error('Possible cyclic task parent-child relationship detected in queue "' + this.#queueName + '"');
139
+ }
140
+ const childTasks = await repositoryWithTransaction.loadManyByQuery({ parentId: { $in: currentLevelIds } });
141
+ if (childTasks.length == 0) {
142
+ break;
143
+ }
144
+ currentLevelIds = childTasks.map((task) => task.id);
145
+ tasks.push(...childTasks);
146
+ }
147
+ return tasks;
148
+ });
149
+ }
150
+ async cancel(id, transaction) {
151
+ await this.cancelMany([id], transaction);
152
+ }
153
+ async cancelMany(ids, transaction) {
154
+ await this.#repository.useTransaction(transaction, async (tx) => {
155
+ const tree = await this.getTree(ids, tx);
156
+ const treeIds = tree.map((task) => task.id);
157
+ if (treeIds.length == 0) {
158
+ return;
159
+ }
160
+ await this.#repository.withTransaction(tx).updateMany(treeIds, {
161
+ status: TaskState.Cancelled,
162
+ lease: null,
163
+ completeTimestamp: TRANSACTION_TIMESTAMP,
164
+ });
165
+ const uniqueParents = new Set();
166
+ for (const t of tree) {
167
+ const task = t;
168
+ if (isNotNull(task.parentId)) {
169
+ uniqueParents.add(task.parentId);
170
+ }
171
+ }
172
+ for (const parentId of uniqueParents) {
173
+ await this.#triggerParentFanIn(parentId, tx);
174
+ }
175
+ });
89
176
  }
90
177
  async cancelByTag(tag) {
91
- await this.#repository.hardDeleteByQuery({ queue: this.#queueName, tag });
178
+ await this.cancelManyByTag([tag]);
92
179
  }
93
- async cancelByTags(tags) {
94
- await this.#repository.hardDeleteManyByQuery({ queue: this.#queueName, tag: { $in: tags } });
180
+ async cancelManyByTag(tags) {
181
+ await this.#repository.transaction(async (tx) => {
182
+ const task = await this.getByTags(tags);
183
+ const ids = task.map((t) => t.id);
184
+ await this.cancelMany(ids, tx);
185
+ });
186
+ }
187
+ async clear() {
188
+ await this.#repository.hardDeleteManyByQuery({ queue: this.#queueName });
95
189
  }
96
190
  async dequeue() {
97
- const jobs = await this.dequeueMany(1);
98
- if (jobs.length == 0) {
191
+ const tasks = await this.dequeueMany(1);
192
+ if (tasks.length == 0) {
99
193
  return undefined;
100
194
  }
101
- return jobs[0];
195
+ return tasks[0];
102
196
  }
103
197
  async dequeueMany(count) {
104
- /*
105
- * Materialization required for LIMIT clause
106
- * https://stackoverflow.com/questions/73966670/select-for-update-subquery-not-respecting-limit-clause-under-load
107
- * https://dba.stackexchange.com/questions/69471/postgres-update-limit-1
108
- */
109
- const selection = this.#repository.session.$with('selection').as((qb) => qb
110
- .select({ id: job.id })
111
- .from(job)
112
- .where(and(this.#dequeueQuery, sql `pg_sleep(0) IS NOT NULL` // workaround to force materialization until drizzle implements https://github.com/drizzle-team/drizzle-orm/issues/2318
113
- ))
114
- .orderBy(asc(job.priority), asc(job.enqueueTimestamp), asc(job.lastDequeueTimestamp), asc(job.tries))
115
- .limit(count)
116
- .for('update', { skipLocked: true }));
117
- const rows = await this.#repository.session
118
- .with(selection)
119
- .update(job)
120
- .set(this.#dequeueUpdate)
121
- .where(inArray(job.id, this.#repository.session.select().from(selection)))
122
- .returning();
123
- return await this.#repository.mapManyToEntity(rows);
124
- }
125
- async acknowledge(job) {
126
- await this.cancel(job.id);
127
- }
128
- async acknowledgeMany(jobs) {
129
- const ids = jobs.map((job) => job.id);
130
- await this.cancelMany(ids);
198
+ return await this.#repository.transaction(async (tx) => {
199
+ let effectiveCount = count;
200
+ // 1. Check Circuit Breaker
201
+ const result = await this.#circuitBreaker.check();
202
+ if (!result.allowed) {
203
+ return [];
204
+ }
205
+ if (result.state == CircuitBreakerState.HalfOpen) {
206
+ // If we are probing (HalfOpen), we must ensure no other tasks are running locally.
207
+ // NOTE: PostgresCircuitBreakerService.check() handles the "one probe per timeout" via the DB state transition.
208
+ // However, we still might want to limit this batch to 1 task if we are the probe.
209
+ if (result.isProbe != true) {
210
+ const runningCount = await this.#repository.withTransaction(tx).countByQuery({ queue: this.#queueName, status: TaskState.Running });
211
+ if (runningCount > 0) {
212
+ return [];
213
+ }
214
+ }
215
+ effectiveCount = 1;
216
+ }
217
+ // 2. Check Global Concurrency
218
+ if (isNotNull(this.globalConcurrency)) {
219
+ const runningCount = await this.#repository.withTransaction(tx).countByQuery({ queue: this.#queueName, status: TaskState.Running });
220
+ if (runningCount >= this.globalConcurrency) {
221
+ return [];
222
+ }
223
+ }
224
+ /*
225
+ * Materialization required for LIMIT clause
226
+ * https://stackoverflow.com/questions/73966670/select-for-update-subquery-not-respecting-limit-clause-under-load
227
+ * https://dba.stackexchange.com/questions/69471/postgres-update-limit-1
228
+ */
229
+ const selection = this.#repository.session.$with('selection').as((qb) => qb
230
+ .select({ id: taskTable.id })
231
+ .from(taskTable)
232
+ .where(and(eq(taskTable.queue, this.#queueName), lte(taskTable.scheduleTimestamp, TRANSACTION_TIMESTAMP), or(sqlIsNull(taskTable.expirationTimestamp), lt(TRANSACTION_TIMESTAMP, taskTable.expirationTimestamp)), or(eq(taskTable.status, TaskState.Pending), and(eq(taskTable.status, TaskState.Running), lt(taskTable.lockExpirationTimestamp, TRANSACTION_TIMESTAMP), // Zombie detection (only non-exhausted ones)
233
+ lt(taskTable.tries, this.maxTries))), sql `pg_sleep(0) IS NOT NULL` // Materialization hack until drizzle implements https://github.com/drizzle-team/drizzle-orm/issues/2318
234
+ ))
235
+ .orderBy(asc(taskTable.priority), asc(taskTable.scheduleTimestamp), asc(taskTable.tries))
236
+ .limit(effectiveCount)
237
+ .for('update', { skipLocked: true }));
238
+ const rows = await this.#repository.session
239
+ .with(selection)
240
+ .update(taskTable)
241
+ .set({
242
+ status: TaskState.Running,
243
+ lease: RANDOM_UUID_V4,
244
+ lockExpirationTimestamp: sql `${TRANSACTION_TIMESTAMP} + ${interval(this.processTimeout, 'milliseconds')}`,
245
+ startTimestamp: TRANSACTION_TIMESTAMP,
246
+ // If it was PENDING, it's the first try (tries=0) -> tries=1.
247
+ // If it was RUNNING (Zombie), previous try failed -> increment tries.
248
+ tries: sql `${taskTable.tries} + 1`,
249
+ })
250
+ .where(inArray(taskTable.id, this.#repository.session.select().from(selection)))
251
+ .returning();
252
+ return await this.#repository.mapManyToEntity(rows);
253
+ });
254
+ }
255
+ async reschedule(id, timestamp, transaction) {
256
+ await this.rescheduleMany([id], timestamp, transaction);
257
+ }
258
+ async rescheduleMany(ids, timestamp, transaction) {
259
+ await this.#repository.withOptionalTransaction(transaction).updateMany(ids, {
260
+ status: TaskState.Pending,
261
+ lease: null,
262
+ scheduleTimestamp: timestamp,
263
+ lockExpirationTimestamp: null,
264
+ tries: sql `CASE
265
+ WHEN ${taskTable.status} = ${TaskState.Running} THEN GREATEST(0, ${taskTable.tries} - 1)
266
+ ELSE ${taskTable.tries}
267
+ END`,
268
+ });
269
+ }
270
+ async rescheduleByTag(tag, timestamp, transaction) {
271
+ await this.rescheduleManyByTag([tag], timestamp, transaction);
272
+ }
273
+ async rescheduleManyByTag(tags, timestamp, transaction) {
274
+ await this.#repository.withOptionalTransaction(transaction).updateManyByQuery({
275
+ queue: this.#queueName,
276
+ tag: { $in: tags },
277
+ }, {
278
+ status: TaskState.Pending,
279
+ lease: null,
280
+ scheduleTimestamp: timestamp,
281
+ lockExpirationTimestamp: null,
282
+ tries: sql `CASE
283
+ WHEN ${taskTable.status} = ${TaskState.Running} THEN GREATEST(0, ${taskTable.tries} - 1)
284
+ ELSE ${taskTable.tries}
285
+ END`,
286
+ });
287
+ }
288
+ async touch(task, options) {
289
+ if (isNull(task.lease)) {
290
+ return undefined;
291
+ }
292
+ return await this.#repository.useTransaction(options?.transaction, async (tx) => {
293
+ const update = {
294
+ lockExpirationTimestamp: sql `${TRANSACTION_TIMESTAMP} + ${interval(this.processTimeout, 'milliseconds')}`,
295
+ };
296
+ if (isDefined(options?.progress)) {
297
+ update.progress = options.progress;
298
+ }
299
+ if (isDefined(options?.state)) {
300
+ update.state = options.state;
301
+ }
302
+ // Attempt to update lease.
303
+ // FAILS (returns undefined) if lease has changed (timeout/cancel/stolen)
304
+ // OR if hard execution timeout has passed.
305
+ const result = await this.#repository.withTransaction(tx).tryUpdateByQuery({
306
+ queue: this.#queueName,
307
+ id: task.id,
308
+ status: TaskState.Running,
309
+ lease: task.lease,
310
+ startTimestamp: { $gt: sql `${TRANSACTION_TIMESTAMP} - ${interval(this.executionTimeout, 'milliseconds')}` },
311
+ }, update);
312
+ return result;
313
+ });
314
+ }
315
+ async touchMany(tasks, progresses, states, transaction) {
316
+ if (tasks.length == 0) {
317
+ return [];
318
+ }
319
+ const rows = tasks.map((t, i) => {
320
+ const progress = progresses?.[i] ?? null;
321
+ const state = states?.[i] ?? null;
322
+ return sql `(${t.id}::uuid, ${t.lease}::uuid, ${progress}::numeric, ${state}::jsonb)`;
323
+ });
324
+ const updates = this.#repository.session.$with('updates').as((qb) => qb
325
+ .select({
326
+ updateId: sql `(id)::uuid`.as('update_id'),
327
+ updateLease: sql `(lease)::uuid`.as('update_lease'),
328
+ updateProgress: sql `(progress)::numeric`.as('update_progress'),
329
+ updateState: sql `(state)::jsonb`.as('update_state'),
330
+ })
331
+ .from(sql `(VALUES ${sql.join(rows, sql `, `)}) AS t(id, lease, progress, state)`));
332
+ const updated = this.#repository.session.$with('updated').as(() => this.#repository.session
333
+ .update(taskTable)
334
+ .set({
335
+ lockExpirationTimestamp: sql `${TRANSACTION_TIMESTAMP} + ${interval(this.processTimeout, 'milliseconds')}`,
336
+ progress: coalesce(updates.updateProgress, taskTable.progress),
337
+ state: coalesce(updates.updateState, taskTable['state']),
338
+ })
339
+ .from(updates)
340
+ .where(and(eq(taskTable.id, updates.updateId), eq(taskTable.queue, this.#queueName), eq(taskTable.lease, updates.updateLease), eq(taskTable.status, TaskState.Running), gt(taskTable.startTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.executionTimeout, 'milliseconds')}`)))
341
+ .returning({ id: taskTable.id }));
342
+ const result = await this.#repository.withOptionalTransaction(transaction).session
343
+ .with(updates, updated)
344
+ .select({ id: updated.id })
345
+ .from(updated)
346
+ .execute();
347
+ return result.map((r) => r.id);
348
+ }
349
+ async acknowledge(task, result, transaction, { skipFanIn = false } = {}) {
350
+ await this.#repository.useTransaction(transaction, async (tx) => {
351
+ const repository = this.#repository.withTransaction(tx);
352
+ // 1. Fan-Out Check: Does this task have active children?
353
+ // If yes, this is a Parent task that has spawned children.
354
+ // It should enter 'Waiting' instead of 'Completed'.
355
+ const activeChildrenCount = await repository.countByQuery({
356
+ parentId: task.id,
357
+ status: { $nin: [TaskState.Completed, TaskState.Cancelled, TaskState.Dead] },
358
+ });
359
+ let updatedTask;
360
+ if (activeChildrenCount > 0) {
361
+ updatedTask = await repository.tryUpdateByQuery({
362
+ id: task.id,
363
+ lease: task.lease,
364
+ }, {
365
+ status: TaskState.Waiting,
366
+ lease: null,
367
+ lockExpirationTimestamp: null,
368
+ state: result,
369
+ });
370
+ }
371
+ else {
372
+ updatedTask = await repository.tryUpdateByQuery({
373
+ id: task.id,
374
+ lease: task.lease,
375
+ }, {
376
+ status: TaskState.Completed,
377
+ lease: null,
378
+ result: result,
379
+ progress: 1,
380
+ completeTimestamp: TRANSACTION_TIMESTAMP,
381
+ lockExpirationTimestamp: null,
382
+ });
383
+ }
384
+ if (isUndefined(updatedTask)) {
385
+ return;
386
+ }
387
+ // 2. Reset Circuit Breaker (Success detected)
388
+ await this.#circuitBreaker.recordSuccess();
389
+ // 3. Fan-In: Wake up parent if all siblings are done
390
+ if (!skipFanIn && isNotNull(task.parentId)) {
391
+ await this.#triggerParentFanIn(task.parentId, tx);
392
+ }
393
+ });
394
+ }
395
+ async acknowledgeMany(tasks, results, transaction) {
396
+ if (tasks.length == 0) {
397
+ return;
398
+ }
399
+ await this.#repository.useTransaction(transaction, async (tx) => {
400
+ // 1. Bulk Check for Active Children
401
+ const taskIds = tasks.map((task) => task.id);
402
+ const parentsWithChildrenRows = await this.#repository.withTransaction(tx).session
403
+ .selectDistinct({ id: taskTable.parentId }) // Distinct is cleaner
404
+ .from(taskTable)
405
+ .where(and(inArray(taskTable.parentId, taskIds), inArray(taskTable.status, [TaskState.Pending, TaskState.Running, TaskState.Waiting])))
406
+ .execute();
407
+ const allParentIds = parentsWithChildrenRows.map((row) => row.id);
408
+ const distinctParentIds = new Set(allParentIds);
409
+ // 2. Separate tasks
410
+ const tasksToWait = [];
411
+ const tasksToComplete = [];
412
+ const resultsToComplete = [];
413
+ const resultsToWait = [];
414
+ for (let i = 0; i < tasks.length; i++) {
415
+ const task = tasks[i];
416
+ if (distinctParentIds.has(task.id)) {
417
+ tasksToWait.push(task);
418
+ if (isDefined(results)) {
419
+ resultsToWait.push(results[i]);
420
+ }
421
+ }
422
+ else {
423
+ tasksToComplete.push(task);
424
+ if (isDefined(results)) {
425
+ resultsToComplete.push(results[i]);
426
+ }
427
+ }
428
+ }
429
+ // 3. Update 'Waiting' tasks
430
+ if (tasksToWait.length > 0) {
431
+ const rows = tasksToWait.map((task, i) => {
432
+ const result = isDefined(results) ? resultsToWait[i] : null;
433
+ return sql `(${task.id}::uuid, ${task.lease}::uuid, ${result}::jsonb)`;
434
+ });
435
+ const updates = this.#repository.session.$with('updates').as((qb) => qb
436
+ .select({
437
+ updateId: sql `(id)::uuid`.as('update_id'),
438
+ updateLease: sql `(lease)::uuid`.as('update_lease'),
439
+ updateResult: sql `(result)::jsonb`.as('update_result'),
440
+ })
441
+ .from(sql `(VALUES ${sql.join(rows, sql `, `)}) AS t(id, lease, result)`));
442
+ const updated = this.#repository.session.$with('updated').as(() => this.#repository.session
443
+ .update(taskTable)
444
+ .set({
445
+ status: TaskState.Waiting,
446
+ lease: null,
447
+ lockExpirationTimestamp: null,
448
+ result: updates.updateResult,
449
+ })
450
+ .from(updates)
451
+ .where(and(eq(taskTable.id, updates.updateId), eq(taskTable.lease, updates.updateLease), eq(taskTable.queue, this.#queueName)))
452
+ .returning({ id: taskTable.id }));
453
+ await this.#repository.withTransaction(tx).session
454
+ .with(updates, updated)
455
+ .select({ id: updated.id })
456
+ .from(updated)
457
+ .execute();
458
+ }
459
+ // 4. Update 'Completed' tasks
460
+ if (tasksToComplete.length > 0) {
461
+ const rows = tasksToComplete.map((t, i) => {
462
+ const result = isDefined(results) ? resultsToComplete[i] : null;
463
+ return sql `(${t.id}::uuid, ${t.lease}::uuid, ${result}::jsonb)`;
464
+ });
465
+ const updates = this.#repository.session.$with('updates').as((qb) => qb
466
+ .select({
467
+ updateId: sql `(id)::uuid`.as('update_id'),
468
+ updateLease: sql `(lease)::uuid`.as('update_lease'),
469
+ updateResult: sql `(result)::jsonb`.as('update_result'),
470
+ })
471
+ .from(sql `(VALUES ${sql.join(rows, sql `, `)}) AS t(id, lease, result)`));
472
+ const updated = this.#repository.session.$with('updated').as(() => this.#repository.session
473
+ .update(taskTable)
474
+ .set({
475
+ status: TaskState.Completed,
476
+ lease: null,
477
+ result: updates.updateResult,
478
+ progress: 1,
479
+ completeTimestamp: TRANSACTION_TIMESTAMP,
480
+ lockExpirationTimestamp: null,
481
+ })
482
+ .from(updates)
483
+ .where(and(eq(taskTable.id, updates.updateId), eq(taskTable.lease, updates.updateLease), eq(taskTable.queue, this.#queueName)))
484
+ .returning({ id: taskTable.id }));
485
+ await this.#repository.withTransaction(tx).session
486
+ .with(updates, updated)
487
+ .select({ id: updated.id })
488
+ .from(updated)
489
+ .execute();
490
+ // Reset circuit breaker
491
+ await this.#circuitBreaker.recordSuccess();
492
+ }
493
+ // 5. Fan-In
494
+ const parentIds = Enumerable.from(tasks)
495
+ .filter((task) => isNotNull(task.parentId))
496
+ .map((task) => task.parentId)
497
+ .distinct();
498
+ for (const parentId of parentIds) {
499
+ await this.#triggerParentFanIn(parentId, tx);
500
+ }
501
+ });
502
+ }
503
+ async fail(task, error, fatal = false, transaction, { skipFanIn = false } = {}) {
504
+ const isRetryable = !fatal && (task.tries < this.maxTries);
505
+ const nextStatus = isRetryable ? TaskState.Pending : TaskState.Dead;
506
+ const delay = isRetryable
507
+ ? Math.min(this.retryDelayMaximum, this.retryDelayMinimum * (this.retryDelayGrowth ** task.tries))
508
+ : 0;
509
+ const nextSchedule = currentTimestamp() + delay;
510
+ await this.#repository.useTransaction(transaction, async (tx) => {
511
+ const updatedTask = await this.#repository.withTransaction(tx).tryUpdateByQuery({
512
+ queue: this.#queueName,
513
+ id: task.id,
514
+ lease: task.lease,
515
+ tries: task.tries,
516
+ }, {
517
+ status: nextStatus,
518
+ lease: null,
519
+ error: serializeError(error),
520
+ lockExpirationTimestamp: null,
521
+ scheduleTimestamp: nextSchedule,
522
+ startTimestamp: null,
523
+ completeTimestamp: (nextStatus == TaskState.Dead) ? TRANSACTION_TIMESTAMP : null,
524
+ });
525
+ if (isUndefined(updatedTask)) {
526
+ return;
527
+ }
528
+ await this.#circuitBreaker.recordFailure();
529
+ if (!skipFanIn && (nextStatus == TaskState.Dead) && isNotNull(task.parentId)) {
530
+ await this.#triggerParentFanIn(task.parentId, tx);
531
+ }
532
+ });
533
+ }
534
+ async #triggerParentFanIn(parentId, transaction) {
535
+ await this.#repository.useTransaction(transaction, async (tx) => {
536
+ // 1. Lock Parent
537
+ const [parent] = await this.#repository.withTransaction(tx).session
538
+ .select({ id: taskTable.id, status: taskTable.status })
539
+ .from(taskTable)
540
+ .where(eq(taskTable.id, parentId))
541
+ .for('update')
542
+ .execute();
543
+ if (parent?.status != TaskState.Waiting) {
544
+ return;
545
+ }
546
+ // 2. Check Children
547
+ const hasActiveChildren = await this.#repository.withTransaction(tx).hasByQuery({
548
+ parentId,
549
+ status: { $in: [TaskState.Pending, TaskState.Running, TaskState.Waiting] },
550
+ });
551
+ if (!hasActiveChildren) {
552
+ // 3. Update Parent
553
+ await this.#repository.withTransaction(tx).updateByQuery({ id: parentId }, {
554
+ status: TaskState.Pending,
555
+ scheduleTimestamp: TRANSACTION_TIMESTAMP,
556
+ });
557
+ }
558
+ });
559
+ }
560
+ async failMany(tasks, errors, transaction) {
561
+ if (tasks.length == 0) {
562
+ return;
563
+ }
564
+ await this.#repository.useTransaction(transaction, async (tx) => {
565
+ await Promise.all(tasks.map(async (task, index) => await this.fail(task, errors[index], false, tx, { skipFanIn: true })));
566
+ const parentIds = Enumerable.from(tasks)
567
+ .filter((task) => isNotNull(task.parentId))
568
+ .map((task) => task.parentId)
569
+ .distinct();
570
+ for (const parentId of parentIds) {
571
+ await this.#triggerParentFanIn(parentId, tx);
572
+ }
573
+ });
574
+ }
575
+ async prune() {
576
+ // 1. Clean up old finished tasks
577
+ await this.#repository.deleteManyByQuery({
578
+ queue: this.#queueName,
579
+ status: { $in: [TaskState.Completed, TaskState.Cancelled, TaskState.Dead] },
580
+ completeTimestamp: { $lt: sql `${TRANSACTION_TIMESTAMP} - ${interval(this.retentionPeriod, 'milliseconds')}` },
581
+ });
582
+ await this.#repository.transaction(async (tx) => {
583
+ // 2. Fail tasks that sat in Pending state past their expiration
584
+ const expiredTasks = await this.#repository.updateManyByQuery({
585
+ queue: this.#queueName,
586
+ status: TaskState.Pending,
587
+ expirationTimestamp: { $lt: TRANSACTION_TIMESTAMP },
588
+ }, {
589
+ status: TaskState.Dead,
590
+ lease: null,
591
+ error: { message: 'Queue Timeout: Task expired before processing' },
592
+ completeTimestamp: TRANSACTION_TIMESTAMP,
593
+ });
594
+ // 3. Fail "Rotting Zombies" (Tasks that crashed workers and exceeded max retries)
595
+ // These were excluded from dequeueMany to prevent infinite loops, so we capture them here.
596
+ const zombieTasks = await this.#repository.updateManyByQuery({
597
+ queue: this.#queueName,
598
+ status: TaskState.Running,
599
+ lockExpirationTimestamp: { $lt: TRANSACTION_TIMESTAMP },
600
+ tries: { $gte: this.maxTries },
601
+ }, {
602
+ status: TaskState.Dead,
603
+ lease: null,
604
+ error: { message: 'Zombie Task: Exceeded max retries after repeated crashes' },
605
+ completeTimestamp: TRANSACTION_TIMESTAMP,
606
+ lockExpirationTimestamp: null,
607
+ });
608
+ // 4. Fail tasks that exceeded hard execution timeout
609
+ const hardTimedoutTasks = await this.#repository.updateManyByQuery({
610
+ queue: this.#queueName,
611
+ status: TaskState.Running,
612
+ startTimestamp: { $lt: sql `${TRANSACTION_TIMESTAMP} - ${interval(this.executionTimeout, 'milliseconds')}` },
613
+ }, {
614
+ status: TaskState.Dead,
615
+ lease: null,
616
+ error: { message: `Hard Execution Timeout: Task ran longer than ${this.executionTimeout}ms` },
617
+ completeTimestamp: TRANSACTION_TIMESTAMP,
618
+ lockExpirationTimestamp: null,
619
+ });
620
+ const distinctParentTaskIds = Enumerable
621
+ .from(expiredTasks)
622
+ .concat(zombieTasks, hardTimedoutTasks)
623
+ .filter((task) => isNotNull(task.parentId))
624
+ .map((task) => task.parentId)
625
+ .distinct();
626
+ // 3. Trigger Fan-In for all affected parents
627
+ for (const parentId of distinctParentTaskIds) {
628
+ await this.#triggerParentFanIn(parentId, tx);
629
+ }
630
+ });
631
+ }
632
+ async restart(id, transaction) {
633
+ await this.#repository.withOptionalTransaction(transaction).updateByQuery({
634
+ id,
635
+ $or: [
636
+ { status: { $ne: TaskState.Running } },
637
+ { lockExpirationTimestamp: { $lt: TRANSACTION_TIMESTAMP } },
638
+ ],
639
+ }, {
640
+ status: TaskState.Pending,
641
+ lease: null,
642
+ error: null,
643
+ scheduleTimestamp: TRANSACTION_TIMESTAMP,
644
+ tries: 0,
645
+ progress: 0,
646
+ result: null,
647
+ completeTimestamp: null,
648
+ });
131
649
  }
132
650
  async *getConsumer(cancellationSignal) {
133
651
  const continue$ = merge(this.#messageBus.allMessages$, cancellationSignal);
134
652
  while (cancellationSignal.isUnset) {
135
- const job = await this.dequeue();
136
- if (isDefined(job)) {
137
- yield job;
653
+ const task = await this.dequeue();
654
+ if (isDefined(task)) {
655
+ yield task;
138
656
  continue;
139
657
  }
140
658
  await cancelableTimeout(5 * millisecondsPerSecond, continue$);
@@ -143,9 +661,9 @@ let PostgresQueue = class PostgresQueue extends Queue {
143
661
  async *getBatchConsumer(size, cancellationSignal) {
144
662
  const continue$ = merge(this.#messageBus.allMessages$, cancellationSignal);
145
663
  while (cancellationSignal.isUnset) {
146
- const jobs = await this.dequeueMany(size);
147
- if (jobs.length > 0) {
148
- yield jobs;
664
+ const tasks = await this.dequeueMany(size);
665
+ if (tasks.length > 0) {
666
+ yield tasks;
149
667
  continue;
150
668
  }
151
669
  await cancelableTimeout(5 * millisecondsPerSecond, continue$);