@zucker-framework/task 1.0.0
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/LICENSE +201 -0
- package/NOTICE +22 -0
- package/THIRD_PARTY_NOTICES +4 -0
- package/assets/task-events.postgresql.sql +106 -0
- package/assets/task-runtime.prisma +118 -0
- package/dist/index.d.mts +604 -0
- package/dist/index.d.ts +604 -0
- package/dist/index.js +1933 -0
- package/dist/index.mjs +1881 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1933 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var index_exports = {};
|
|
23
|
+
__export(index_exports, {
|
|
24
|
+
DurableTaskLeaseLostError: () => DurableTaskLeaseLostError,
|
|
25
|
+
DurableTaskNotClaimableError: () => DurableTaskNotClaimableError,
|
|
26
|
+
DurableTaskNotFoundError: () => DurableTaskNotFoundError,
|
|
27
|
+
DurableTaskRetryScheduledError: () => DurableTaskRetryScheduledError,
|
|
28
|
+
DurableWorkerRuntime: () => DurableWorkerRuntime,
|
|
29
|
+
ExponentialTaskRetryPolicy: () => ExponentialTaskRetryPolicy,
|
|
30
|
+
InMemoryTaskOutboxRepository: () => InMemoryTaskOutboxRepository,
|
|
31
|
+
InMemoryTaskRepository: () => InMemoryTaskRepository,
|
|
32
|
+
InvalidTaskTransitionError: () => InvalidTaskTransitionError,
|
|
33
|
+
PostgresTaskOutboxRepository: () => PostgresTaskOutboxRepository,
|
|
34
|
+
PostgresTaskRedriver: () => PostgresTaskRedriver,
|
|
35
|
+
PostgresTaskRepository: () => PostgresTaskRepository,
|
|
36
|
+
PostgresTaskSubmitter: () => PostgresTaskSubmitter,
|
|
37
|
+
PostgresTaskSummaryReader: () => PostgresTaskSummaryReader,
|
|
38
|
+
PostgresTaskTimelineReader: () => PostgresTaskTimelineReader,
|
|
39
|
+
TASK_STATUSES: () => TASK_STATUSES,
|
|
40
|
+
TASK_TIMELINE_EVENT_TYPES: () => TASK_TIMELINE_EVENT_TYPES,
|
|
41
|
+
TaskIdempotencyConflictError: () => TaskIdempotencyConflictError,
|
|
42
|
+
TaskLeaseReconciler: () => TaskLeaseReconciler,
|
|
43
|
+
TaskOutboxConflictError: () => TaskOutboxConflictError,
|
|
44
|
+
TaskOutboxDispatcher: () => TaskOutboxDispatcher,
|
|
45
|
+
TaskQueueReconciler: () => TaskQueueReconciler,
|
|
46
|
+
UnsupportedDurableTaskKindError: () => UnsupportedDurableTaskKindError,
|
|
47
|
+
assertTaskTransition: () => assertTaskTransition,
|
|
48
|
+
canTransitionTask: () => canTransitionTask,
|
|
49
|
+
createTaskFingerprint: () => createTaskFingerprint,
|
|
50
|
+
isTerminalTaskStatus: () => isTerminalTaskStatus,
|
|
51
|
+
requiresTaskLease: () => requiresTaskLease
|
|
52
|
+
});
|
|
53
|
+
module.exports = __toCommonJS(index_exports);
|
|
54
|
+
|
|
55
|
+
// src/task-state-machine.ts
|
|
56
|
+
var TASK_STATUSES = [
|
|
57
|
+
"QUEUED",
|
|
58
|
+
"PROCESSING",
|
|
59
|
+
"WAITING_PROVIDER",
|
|
60
|
+
"RETRYING",
|
|
61
|
+
"COMPLETED",
|
|
62
|
+
"FAILED",
|
|
63
|
+
"CANCELLED"
|
|
64
|
+
];
|
|
65
|
+
var transitions = {
|
|
66
|
+
QUEUED: [
|
|
67
|
+
"PROCESSING",
|
|
68
|
+
"CANCELLED"
|
|
69
|
+
],
|
|
70
|
+
PROCESSING: [
|
|
71
|
+
"WAITING_PROVIDER",
|
|
72
|
+
"RETRYING",
|
|
73
|
+
"COMPLETED",
|
|
74
|
+
"FAILED",
|
|
75
|
+
"CANCELLED"
|
|
76
|
+
],
|
|
77
|
+
WAITING_PROVIDER: [
|
|
78
|
+
"PROCESSING",
|
|
79
|
+
"RETRYING",
|
|
80
|
+
"FAILED",
|
|
81
|
+
"CANCELLED"
|
|
82
|
+
],
|
|
83
|
+
RETRYING: [
|
|
84
|
+
"QUEUED",
|
|
85
|
+
"FAILED",
|
|
86
|
+
"CANCELLED"
|
|
87
|
+
],
|
|
88
|
+
COMPLETED: [],
|
|
89
|
+
FAILED: [],
|
|
90
|
+
CANCELLED: []
|
|
91
|
+
};
|
|
92
|
+
var InvalidTaskTransitionError = class extends Error {
|
|
93
|
+
static {
|
|
94
|
+
__name(this, "InvalidTaskTransitionError");
|
|
95
|
+
}
|
|
96
|
+
from;
|
|
97
|
+
to;
|
|
98
|
+
constructor(from, to) {
|
|
99
|
+
super(`Invalid task transition: ${from} -> ${to}`), this.from = from, this.to = to;
|
|
100
|
+
this.name = "InvalidTaskTransitionError";
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
function canTransitionTask(from, to) {
|
|
104
|
+
return transitions[from].includes(to);
|
|
105
|
+
}
|
|
106
|
+
__name(canTransitionTask, "canTransitionTask");
|
|
107
|
+
function assertTaskTransition(from, to) {
|
|
108
|
+
if (!canTransitionTask(from, to)) {
|
|
109
|
+
throw new InvalidTaskTransitionError(from, to);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
__name(assertTaskTransition, "assertTaskTransition");
|
|
113
|
+
function isTerminalTaskStatus(status2) {
|
|
114
|
+
return status2 === "COMPLETED" || status2 === "FAILED" || status2 === "CANCELLED";
|
|
115
|
+
}
|
|
116
|
+
__name(isTerminalTaskStatus, "isTerminalTaskStatus");
|
|
117
|
+
function requiresTaskLease(status2) {
|
|
118
|
+
return status2 === "PROCESSING" || status2 === "WAITING_PROVIDER";
|
|
119
|
+
}
|
|
120
|
+
__name(requiresTaskLease, "requiresTaskLease");
|
|
121
|
+
|
|
122
|
+
// src/task-fingerprint.ts
|
|
123
|
+
var import_node_crypto = require("crypto");
|
|
124
|
+
function serialize(value, seen) {
|
|
125
|
+
if (value === null) return "null";
|
|
126
|
+
switch (typeof value) {
|
|
127
|
+
case "string":
|
|
128
|
+
case "boolean":
|
|
129
|
+
return JSON.stringify(value);
|
|
130
|
+
case "number":
|
|
131
|
+
if (!Number.isFinite(value)) {
|
|
132
|
+
throw new TypeError("Task fingerprint input cannot contain non-finite numbers");
|
|
133
|
+
}
|
|
134
|
+
return JSON.stringify(value);
|
|
135
|
+
case "undefined":
|
|
136
|
+
return void 0;
|
|
137
|
+
case "bigint":
|
|
138
|
+
case "function":
|
|
139
|
+
case "symbol":
|
|
140
|
+
throw new TypeError(`Task fingerprint input cannot contain ${typeof value}`);
|
|
141
|
+
case "object":
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
if (value instanceof Date) return JSON.stringify(value.toISOString());
|
|
145
|
+
if (seen.has(value)) throw new TypeError("Task fingerprint input cannot contain circular references");
|
|
146
|
+
seen.add(value);
|
|
147
|
+
try {
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
return `[${value.map((entry) => serialize(entry, seen) ?? "null").join(",")}]`;
|
|
150
|
+
}
|
|
151
|
+
const prototype = Object.getPrototypeOf(value);
|
|
152
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
153
|
+
throw new TypeError("Task fingerprint input must contain only JSON-compatible objects");
|
|
154
|
+
}
|
|
155
|
+
const entries = Object.keys(value).sort().flatMap((key) => {
|
|
156
|
+
const serialized = serialize(value[key], seen);
|
|
157
|
+
return serialized === void 0 ? [] : [
|
|
158
|
+
`${JSON.stringify(key)}:${serialized}`
|
|
159
|
+
];
|
|
160
|
+
});
|
|
161
|
+
return `{${entries.join(",")}}`;
|
|
162
|
+
} finally {
|
|
163
|
+
seen.delete(value);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
__name(serialize, "serialize");
|
|
167
|
+
function createTaskFingerprint(value) {
|
|
168
|
+
const canonical = serialize(value, /* @__PURE__ */ new Set());
|
|
169
|
+
if (canonical === void 0) {
|
|
170
|
+
throw new TypeError("Task fingerprint input cannot be undefined");
|
|
171
|
+
}
|
|
172
|
+
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(canonical).digest("hex")}`;
|
|
173
|
+
}
|
|
174
|
+
__name(createTaskFingerprint, "createTaskFingerprint");
|
|
175
|
+
|
|
176
|
+
// src/task-repository.ts
|
|
177
|
+
var TaskIdempotencyConflictError = class extends Error {
|
|
178
|
+
static {
|
|
179
|
+
__name(this, "TaskIdempotencyConflictError");
|
|
180
|
+
}
|
|
181
|
+
idempotencyKey;
|
|
182
|
+
constructor(idempotencyKey) {
|
|
183
|
+
super(`Idempotency key was reused with a different request: ${idempotencyKey}`), this.idempotencyKey = idempotencyKey;
|
|
184
|
+
this.name = "TaskIdempotencyConflictError";
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// src/in-memory-task-repository.ts
|
|
189
|
+
function cloneTask(task) {
|
|
190
|
+
return {
|
|
191
|
+
...task,
|
|
192
|
+
resource: {
|
|
193
|
+
...task.resource
|
|
194
|
+
},
|
|
195
|
+
error: task.error ? {
|
|
196
|
+
...task.error,
|
|
197
|
+
metadata: task.error.metadata ? {
|
|
198
|
+
...task.error.metadata
|
|
199
|
+
} : void 0
|
|
200
|
+
} : void 0,
|
|
201
|
+
availableAt: new Date(task.availableAt),
|
|
202
|
+
createdAt: new Date(task.createdAt),
|
|
203
|
+
updatedAt: new Date(task.updatedAt),
|
|
204
|
+
startedAt: task.startedAt ? new Date(task.startedAt) : void 0,
|
|
205
|
+
completedAt: task.completedAt ? new Date(task.completedAt) : void 0,
|
|
206
|
+
leaseExpiresAt: task.leaseExpiresAt ? new Date(task.leaseExpiresAt) : void 0,
|
|
207
|
+
heartbeatAt: task.heartbeatAt ? new Date(task.heartbeatAt) : void 0
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
__name(cloneTask, "cloneTask");
|
|
211
|
+
function clampProgress(progress) {
|
|
212
|
+
return Math.max(0, Math.min(100, progress));
|
|
213
|
+
}
|
|
214
|
+
__name(clampProgress, "clampProgress");
|
|
215
|
+
var InMemoryTaskRepository = class {
|
|
216
|
+
static {
|
|
217
|
+
__name(this, "InMemoryTaskRepository");
|
|
218
|
+
}
|
|
219
|
+
tasks = /* @__PURE__ */ new Map();
|
|
220
|
+
idempotencyIndex = /* @__PURE__ */ new Map();
|
|
221
|
+
async create(input) {
|
|
222
|
+
const existingId = this.idempotencyIndex.get(input.idempotencyKey);
|
|
223
|
+
if (existingId) {
|
|
224
|
+
const existing = this.tasks.get(existingId);
|
|
225
|
+
if (!existing) throw new Error(`Task index is corrupted for key ${input.idempotencyKey}`);
|
|
226
|
+
if (existing.idempotencyFingerprint !== input.idempotencyFingerprint) {
|
|
227
|
+
throw new TaskIdempotencyConflictError(input.idempotencyKey);
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
task: cloneTask(existing),
|
|
231
|
+
created: false
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (this.tasks.has(input.id)) throw new Error(`Task already exists: ${input.id}`);
|
|
235
|
+
if (input.maxAttempts < 1) throw new RangeError("maxAttempts must be at least 1");
|
|
236
|
+
const task = {
|
|
237
|
+
id: input.id,
|
|
238
|
+
kind: input.kind,
|
|
239
|
+
resource: {
|
|
240
|
+
...input.resource
|
|
241
|
+
},
|
|
242
|
+
userId: input.userId,
|
|
243
|
+
orderId: input.orderId,
|
|
244
|
+
status: "QUEUED",
|
|
245
|
+
progress: 0,
|
|
246
|
+
attempt: 0,
|
|
247
|
+
maxAttempts: input.maxAttempts,
|
|
248
|
+
idempotencyKey: input.idempotencyKey,
|
|
249
|
+
idempotencyFingerprint: input.idempotencyFingerprint,
|
|
250
|
+
handlerVersion: input.handlerVersion,
|
|
251
|
+
payload: input.payload,
|
|
252
|
+
availableAt: new Date(input.availableAt ?? input.now),
|
|
253
|
+
createdAt: new Date(input.now),
|
|
254
|
+
updatedAt: new Date(input.now),
|
|
255
|
+
version: 0
|
|
256
|
+
};
|
|
257
|
+
this.tasks.set(task.id, task);
|
|
258
|
+
this.idempotencyIndex.set(task.idempotencyKey, task.id);
|
|
259
|
+
return {
|
|
260
|
+
task: cloneTask(task),
|
|
261
|
+
created: true
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async findById(id) {
|
|
265
|
+
const task = this.tasks.get(id);
|
|
266
|
+
return task ? cloneTask(task) : null;
|
|
267
|
+
}
|
|
268
|
+
async findByIdempotencyKey(key) {
|
|
269
|
+
const id = this.idempotencyIndex.get(key);
|
|
270
|
+
return id ? this.findById(id) : null;
|
|
271
|
+
}
|
|
272
|
+
async claim(input) {
|
|
273
|
+
const task = this.tasks.get(input.id);
|
|
274
|
+
if (!task || task.status !== "QUEUED" || task.availableAt.getTime() > input.now.getTime() || task.attempt >= task.maxAttempts || input.leaseExpiresAt.getTime() <= input.now.getTime()) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
task.status = "PROCESSING";
|
|
278
|
+
task.workerId = input.workerId;
|
|
279
|
+
task.leaseToken = input.leaseToken;
|
|
280
|
+
task.leaseExpiresAt = new Date(input.leaseExpiresAt);
|
|
281
|
+
task.heartbeatAt = new Date(input.now);
|
|
282
|
+
task.startedAt ??= new Date(input.now);
|
|
283
|
+
task.attempt += 1;
|
|
284
|
+
task.updatedAt = new Date(input.now);
|
|
285
|
+
task.version += 1;
|
|
286
|
+
return cloneTask(task);
|
|
287
|
+
}
|
|
288
|
+
async heartbeat(input) {
|
|
289
|
+
const task = this.tasks.get(input.id);
|
|
290
|
+
if (!task || !requiresTaskLease(task.status) || task.leaseToken !== input.leaseToken || !task.leaseExpiresAt || task.leaseExpiresAt.getTime() <= input.now.getTime() || input.leaseExpiresAt.getTime() <= input.now.getTime()) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
task.heartbeatAt = new Date(input.now);
|
|
294
|
+
task.leaseExpiresAt = new Date(input.leaseExpiresAt);
|
|
295
|
+
task.updatedAt = new Date(input.now);
|
|
296
|
+
task.version += 1;
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
async reportProgress(input) {
|
|
300
|
+
const task = this.tasks.get(input.id);
|
|
301
|
+
if (!task || !requiresTaskLease(task.status) || task.leaseToken !== input.leaseToken || !task.leaseExpiresAt || task.leaseExpiresAt.getTime() <= input.now.getTime()) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
task.stage = input.stage ?? task.stage;
|
|
305
|
+
task.progress = Math.max(task.progress, clampProgress(input.progress));
|
|
306
|
+
task.updatedAt = new Date(input.now);
|
|
307
|
+
task.version += 1;
|
|
308
|
+
return cloneTask(task);
|
|
309
|
+
}
|
|
310
|
+
async transition(input) {
|
|
311
|
+
const task = this.tasks.get(input.id);
|
|
312
|
+
if (!task || task.status !== input.from) return null;
|
|
313
|
+
assertTaskTransition(input.from, input.to);
|
|
314
|
+
if (input.from === "QUEUED" && input.to === "PROCESSING") return null;
|
|
315
|
+
if (requiresTaskLease(input.from)) {
|
|
316
|
+
if (!input.leaseToken || task.leaseToken !== input.leaseToken || !task.leaseExpiresAt || task.leaseExpiresAt.getTime() <= input.now.getTime()) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
task.status = input.to;
|
|
321
|
+
task.stage = input.stage ?? task.stage;
|
|
322
|
+
task.progress = input.to === "COMPLETED" ? 100 : clampProgress(input.progress ?? task.progress);
|
|
323
|
+
task.result = input.result ?? task.result;
|
|
324
|
+
task.error = input.error;
|
|
325
|
+
task.providerRequestId = input.providerRequestId ?? task.providerRequestId;
|
|
326
|
+
task.availableAt = new Date(input.availableAt ?? task.availableAt);
|
|
327
|
+
task.updatedAt = new Date(input.now);
|
|
328
|
+
task.version += 1;
|
|
329
|
+
if (input.to === "QUEUED" || input.to === "RETRYING" || isTerminalTaskStatus(input.to)) {
|
|
330
|
+
task.workerId = void 0;
|
|
331
|
+
task.leaseToken = void 0;
|
|
332
|
+
task.leaseExpiresAt = void 0;
|
|
333
|
+
task.heartbeatAt = void 0;
|
|
334
|
+
}
|
|
335
|
+
if (isTerminalTaskStatus(input.to)) task.completedAt = new Date(input.now);
|
|
336
|
+
return cloneTask(task);
|
|
337
|
+
}
|
|
338
|
+
async findExpiredLeases(input) {
|
|
339
|
+
if (input.limit < 1) return [];
|
|
340
|
+
return [
|
|
341
|
+
...this.tasks.values()
|
|
342
|
+
].filter((task) => requiresTaskLease(task.status) && Boolean(task.leaseExpiresAt && task.leaseExpiresAt.getTime() <= input.now.getTime())).sort((a, b) => (a.leaseExpiresAt?.getTime() ?? 0) - (b.leaseExpiresAt?.getTime() ?? 0)).slice(0, input.limit).map(cloneTask);
|
|
343
|
+
}
|
|
344
|
+
async findReadyQueued(input) {
|
|
345
|
+
if (input.limit < 1) return [];
|
|
346
|
+
return [
|
|
347
|
+
...this.tasks.values()
|
|
348
|
+
].filter((task) => task.status === "QUEUED" && task.availableAt.getTime() <= input.now.getTime() && task.attempt < task.maxAttempts).sort((a, b) => a.availableAt.getTime() - b.availableAt.getTime()).slice(input.offset ?? 0, (input.offset ?? 0) + input.limit).map(cloneTask);
|
|
349
|
+
}
|
|
350
|
+
async recoverExpiredLease(input) {
|
|
351
|
+
const task = this.tasks.get(input.id);
|
|
352
|
+
if (!task || !requiresTaskLease(task.status) || task.leaseToken !== input.leaseToken || !task.leaseExpiresAt || task.leaseExpiresAt.getTime() > input.now.getTime()) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
const attemptsExhausted = task.attempt >= task.maxAttempts;
|
|
356
|
+
task.status = attemptsExhausted ? "FAILED" : "QUEUED";
|
|
357
|
+
task.error = input.error;
|
|
358
|
+
task.availableAt = new Date(input.availableAt ?? input.now);
|
|
359
|
+
task.workerId = void 0;
|
|
360
|
+
task.leaseToken = void 0;
|
|
361
|
+
task.leaseExpiresAt = void 0;
|
|
362
|
+
task.heartbeatAt = void 0;
|
|
363
|
+
task.updatedAt = new Date(input.now);
|
|
364
|
+
task.version += 1;
|
|
365
|
+
if (attemptsExhausted) task.completedAt = new Date(input.now);
|
|
366
|
+
return cloneTask(task);
|
|
367
|
+
}
|
|
368
|
+
async cancel(id, now) {
|
|
369
|
+
const task = this.tasks.get(id);
|
|
370
|
+
if (!task || isTerminalTaskStatus(task.status)) return null;
|
|
371
|
+
task.status = "CANCELLED";
|
|
372
|
+
task.workerId = void 0;
|
|
373
|
+
task.leaseToken = void 0;
|
|
374
|
+
task.leaseExpiresAt = void 0;
|
|
375
|
+
task.heartbeatAt = void 0;
|
|
376
|
+
task.completedAt = new Date(now);
|
|
377
|
+
task.updatedAt = new Date(now);
|
|
378
|
+
task.version += 1;
|
|
379
|
+
return cloneTask(task);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// src/postgres-task-repository.ts
|
|
384
|
+
var CREATE_TASK_SQL = `
|
|
385
|
+
WITH inserted AS (
|
|
386
|
+
INSERT INTO async_jobs (
|
|
387
|
+
"id", "kind", "resourceType", "resourceId", "userId", "orderId",
|
|
388
|
+
"status", "progress", "attempt", "maxAttempts", "idempotencyKey",
|
|
389
|
+
"idempotencyFingerprint", "handlerVersion", "payload", "availableAt",
|
|
390
|
+
"createdAt", "updatedAt", "version"
|
|
391
|
+
) VALUES (
|
|
392
|
+
$1, $2, $3, $4, $5, $6,
|
|
393
|
+
'QUEUED', 0, 0, $7, $8,
|
|
394
|
+
$9, $10, $11::jsonb, $12,
|
|
395
|
+
$13, $13, 0
|
|
396
|
+
)
|
|
397
|
+
ON CONFLICT ("idempotencyKey") DO NOTHING
|
|
398
|
+
RETURNING *
|
|
399
|
+
)
|
|
400
|
+
SELECT inserted.*, TRUE AS "__created"
|
|
401
|
+
FROM inserted
|
|
402
|
+
UNION ALL
|
|
403
|
+
SELECT existing.*, FALSE AS "__created"
|
|
404
|
+
FROM async_jobs AS existing
|
|
405
|
+
WHERE existing."idempotencyKey" = $8
|
|
406
|
+
AND NOT EXISTS (SELECT 1 FROM inserted)
|
|
407
|
+
LIMIT 1
|
|
408
|
+
`;
|
|
409
|
+
var CLAIM_TASK_SQL = `
|
|
410
|
+
UPDATE async_jobs
|
|
411
|
+
SET
|
|
412
|
+
"status" = 'PROCESSING',
|
|
413
|
+
"workerId" = $2,
|
|
414
|
+
"leaseToken" = $3,
|
|
415
|
+
"leaseExpiresAt" = $4,
|
|
416
|
+
"heartbeatAt" = $5,
|
|
417
|
+
"startedAt" = COALESCE("startedAt", $5),
|
|
418
|
+
"attempt" = "attempt" + 1,
|
|
419
|
+
"updatedAt" = $5,
|
|
420
|
+
"version" = "version" + 1
|
|
421
|
+
WHERE "id" = $1
|
|
422
|
+
AND "status" = 'QUEUED'
|
|
423
|
+
AND "availableAt" <= $5
|
|
424
|
+
AND "attempt" < "maxAttempts"
|
|
425
|
+
AND $4 > $5
|
|
426
|
+
RETURNING *
|
|
427
|
+
`;
|
|
428
|
+
var HEARTBEAT_TASK_SQL = `
|
|
429
|
+
UPDATE async_jobs
|
|
430
|
+
SET
|
|
431
|
+
"heartbeatAt" = $4,
|
|
432
|
+
"leaseExpiresAt" = $3,
|
|
433
|
+
"updatedAt" = $4,
|
|
434
|
+
"version" = "version" + 1
|
|
435
|
+
WHERE "id" = $1
|
|
436
|
+
AND "status" IN ('PROCESSING', 'WAITING_PROVIDER')
|
|
437
|
+
AND "leaseToken" = $2
|
|
438
|
+
AND "leaseExpiresAt" > $4
|
|
439
|
+
AND $3 > $4
|
|
440
|
+
RETURNING "id"
|
|
441
|
+
`;
|
|
442
|
+
var REPORT_TASK_PROGRESS_SQL = `
|
|
443
|
+
UPDATE async_jobs
|
|
444
|
+
SET
|
|
445
|
+
"stage" = COALESCE($3::text, "stage"),
|
|
446
|
+
"progress" = GREATEST("progress", GREATEST(0, LEAST(100, $4::integer))),
|
|
447
|
+
"updatedAt" = $5,
|
|
448
|
+
"version" = "version" + 1
|
|
449
|
+
WHERE "id" = $1
|
|
450
|
+
AND "status" IN ('PROCESSING', 'WAITING_PROVIDER')
|
|
451
|
+
AND "leaseToken" = $2
|
|
452
|
+
AND "leaseExpiresAt" > $5
|
|
453
|
+
RETURNING *
|
|
454
|
+
`;
|
|
455
|
+
var TRANSITION_TASK_SQL = `
|
|
456
|
+
UPDATE async_jobs
|
|
457
|
+
SET
|
|
458
|
+
"status" = $3::async_job_status,
|
|
459
|
+
"stage" = COALESCE($5::text, "stage"),
|
|
460
|
+
"progress" = CASE
|
|
461
|
+
WHEN $3 = 'COMPLETED' THEN 100
|
|
462
|
+
WHEN $6::integer IS NULL THEN "progress"
|
|
463
|
+
ELSE GREATEST(0, LEAST(100, $6::integer))
|
|
464
|
+
END,
|
|
465
|
+
"result" = COALESCE($7::jsonb, "result"),
|
|
466
|
+
"errorCode" = $8::text,
|
|
467
|
+
"errorCategory" = $9::text,
|
|
468
|
+
"errorSummary" = $10::text,
|
|
469
|
+
"errorRetryable" = $11::boolean,
|
|
470
|
+
"errorMetadata" = $12::jsonb,
|
|
471
|
+
"providerRequestId" = COALESCE($13::text, "providerRequestId"),
|
|
472
|
+
"availableAt" = COALESCE($14::timestamptz, "availableAt"),
|
|
473
|
+
"workerId" = CASE WHEN $16::boolean THEN NULL ELSE "workerId" END,
|
|
474
|
+
"leaseToken" = CASE WHEN $16::boolean THEN NULL ELSE "leaseToken" END,
|
|
475
|
+
"leaseExpiresAt" = CASE WHEN $16::boolean THEN NULL ELSE "leaseExpiresAt" END,
|
|
476
|
+
"heartbeatAt" = CASE WHEN $16::boolean THEN NULL ELSE "heartbeatAt" END,
|
|
477
|
+
"completedAt" = CASE WHEN $17::boolean THEN $15::timestamptz ELSE "completedAt" END,
|
|
478
|
+
"updatedAt" = $15::timestamptz,
|
|
479
|
+
"version" = "version" + 1
|
|
480
|
+
WHERE "id" = $1
|
|
481
|
+
AND "status" = $2::async_job_status
|
|
482
|
+
AND (
|
|
483
|
+
NOT $18::boolean
|
|
484
|
+
OR (
|
|
485
|
+
$4::text IS NOT NULL
|
|
486
|
+
AND "leaseToken" = $4::text
|
|
487
|
+
AND "leaseExpiresAt" > $15::timestamptz
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
RETURNING *
|
|
491
|
+
`;
|
|
492
|
+
var FIND_EXPIRED_LEASES_SQL = `
|
|
493
|
+
SELECT *
|
|
494
|
+
FROM async_jobs
|
|
495
|
+
WHERE "status" IN ('PROCESSING', 'WAITING_PROVIDER')
|
|
496
|
+
AND "leaseExpiresAt" <= $1
|
|
497
|
+
ORDER BY "leaseExpiresAt" ASC
|
|
498
|
+
LIMIT $2
|
|
499
|
+
`;
|
|
500
|
+
var FIND_READY_QUEUED_TASKS_SQL = `
|
|
501
|
+
SELECT *
|
|
502
|
+
FROM async_jobs
|
|
503
|
+
WHERE "status" = 'QUEUED'
|
|
504
|
+
AND "availableAt" <= $1
|
|
505
|
+
AND "attempt" < "maxAttempts"
|
|
506
|
+
ORDER BY "availableAt" ASC, "createdAt" ASC
|
|
507
|
+
LIMIT $2
|
|
508
|
+
OFFSET $3
|
|
509
|
+
`;
|
|
510
|
+
var RECOVER_EXPIRED_LEASE_SQL = `
|
|
511
|
+
UPDATE async_jobs
|
|
512
|
+
SET
|
|
513
|
+
"status" = CASE
|
|
514
|
+
WHEN "attempt" >= "maxAttempts" THEN 'FAILED'::async_job_status
|
|
515
|
+
ELSE 'QUEUED'::async_job_status
|
|
516
|
+
END,
|
|
517
|
+
"errorCode" = $4,
|
|
518
|
+
"errorCategory" = $5,
|
|
519
|
+
"errorSummary" = $6,
|
|
520
|
+
"errorRetryable" = $7,
|
|
521
|
+
"errorMetadata" = $8::jsonb,
|
|
522
|
+
"availableAt" = COALESCE($9, $3),
|
|
523
|
+
"workerId" = NULL,
|
|
524
|
+
"leaseToken" = NULL,
|
|
525
|
+
"leaseExpiresAt" = NULL,
|
|
526
|
+
"heartbeatAt" = NULL,
|
|
527
|
+
"completedAt" = CASE
|
|
528
|
+
WHEN "attempt" >= "maxAttempts" THEN $3
|
|
529
|
+
ELSE "completedAt"
|
|
530
|
+
END,
|
|
531
|
+
"updatedAt" = $3,
|
|
532
|
+
"version" = "version" + 1
|
|
533
|
+
WHERE "id" = $1
|
|
534
|
+
AND "status" IN ('PROCESSING', 'WAITING_PROVIDER')
|
|
535
|
+
AND "leaseToken" = $2
|
|
536
|
+
AND "leaseExpiresAt" <= $3
|
|
537
|
+
RETURNING *
|
|
538
|
+
`;
|
|
539
|
+
var CANCEL_TASK_SQL = `
|
|
540
|
+
UPDATE async_jobs
|
|
541
|
+
SET
|
|
542
|
+
"status" = 'CANCELLED',
|
|
543
|
+
"workerId" = NULL,
|
|
544
|
+
"leaseToken" = NULL,
|
|
545
|
+
"leaseExpiresAt" = NULL,
|
|
546
|
+
"heartbeatAt" = NULL,
|
|
547
|
+
"completedAt" = $2,
|
|
548
|
+
"updatedAt" = $2,
|
|
549
|
+
"version" = "version" + 1
|
|
550
|
+
WHERE "id" = $1
|
|
551
|
+
AND "status" NOT IN ('COMPLETED', 'FAILED', 'CANCELLED')
|
|
552
|
+
RETURNING *
|
|
553
|
+
`;
|
|
554
|
+
function jsonParameter(value) {
|
|
555
|
+
return value === void 0 ? null : JSON.stringify(value);
|
|
556
|
+
}
|
|
557
|
+
__name(jsonParameter, "jsonParameter");
|
|
558
|
+
function parseJson(value) {
|
|
559
|
+
if (typeof value !== "string") return value;
|
|
560
|
+
return JSON.parse(value);
|
|
561
|
+
}
|
|
562
|
+
__name(parseJson, "parseJson");
|
|
563
|
+
function optionalDate(value) {
|
|
564
|
+
return value === null ? void 0 : new Date(value);
|
|
565
|
+
}
|
|
566
|
+
__name(optionalDate, "optionalDate");
|
|
567
|
+
function taskStatus(value) {
|
|
568
|
+
if (TASK_STATUSES.includes(value)) return value;
|
|
569
|
+
throw new Error(`Unknown task status returned by PostgreSQL: ${value}`);
|
|
570
|
+
}
|
|
571
|
+
__name(taskStatus, "taskStatus");
|
|
572
|
+
function taskError(row) {
|
|
573
|
+
if (!row.errorCode) return void 0;
|
|
574
|
+
return {
|
|
575
|
+
code: row.errorCode,
|
|
576
|
+
category: row.errorCategory ?? "UNKNOWN",
|
|
577
|
+
summary: row.errorSummary ?? row.errorCode,
|
|
578
|
+
retryable: row.errorRetryable ?? false,
|
|
579
|
+
metadata: row.errorMetadata ? parseJson(row.errorMetadata) : void 0
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
__name(taskError, "taskError");
|
|
583
|
+
var PostgresTaskRepository = class {
|
|
584
|
+
static {
|
|
585
|
+
__name(this, "PostgresTaskRepository");
|
|
586
|
+
}
|
|
587
|
+
client;
|
|
588
|
+
constructor(client) {
|
|
589
|
+
this.client = client;
|
|
590
|
+
}
|
|
591
|
+
async create(input) {
|
|
592
|
+
if (input.maxAttempts < 1) throw new RangeError("maxAttempts must be at least 1");
|
|
593
|
+
const result = await this.client.query(CREATE_TASK_SQL, [
|
|
594
|
+
input.id,
|
|
595
|
+
input.kind,
|
|
596
|
+
input.resource.type,
|
|
597
|
+
input.resource.id,
|
|
598
|
+
input.userId ?? null,
|
|
599
|
+
input.orderId ?? null,
|
|
600
|
+
input.maxAttempts,
|
|
601
|
+
input.idempotencyKey,
|
|
602
|
+
input.idempotencyFingerprint,
|
|
603
|
+
input.handlerVersion,
|
|
604
|
+
jsonParameter(input.payload),
|
|
605
|
+
input.availableAt ?? input.now,
|
|
606
|
+
input.now
|
|
607
|
+
]);
|
|
608
|
+
const row = result.rows[0];
|
|
609
|
+
if (!row) {
|
|
610
|
+
const existing = await this.findByIdempotencyKey(input.idempotencyKey);
|
|
611
|
+
if (!existing) throw new Error(`Task already exists with a different id: ${input.id}`);
|
|
612
|
+
if (existing.idempotencyFingerprint !== input.idempotencyFingerprint) {
|
|
613
|
+
throw new TaskIdempotencyConflictError(input.idempotencyKey);
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
task: existing,
|
|
617
|
+
created: false
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
if (row.idempotencyFingerprint !== input.idempotencyFingerprint) {
|
|
621
|
+
throw new TaskIdempotencyConflictError(input.idempotencyKey);
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
task: this.toTask(row),
|
|
625
|
+
created: row.__created
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
async findById(id) {
|
|
629
|
+
const result = await this.client.query('SELECT * FROM async_jobs WHERE "id" = $1 LIMIT 1', [
|
|
630
|
+
id
|
|
631
|
+
]);
|
|
632
|
+
return result.rows[0] ? this.toTask(result.rows[0]) : null;
|
|
633
|
+
}
|
|
634
|
+
async findByIdempotencyKey(key) {
|
|
635
|
+
const result = await this.client.query('SELECT * FROM async_jobs WHERE "idempotencyKey" = $1 LIMIT 1', [
|
|
636
|
+
key
|
|
637
|
+
]);
|
|
638
|
+
return result.rows[0] ? this.toTask(result.rows[0]) : null;
|
|
639
|
+
}
|
|
640
|
+
async claim(input) {
|
|
641
|
+
const result = await this.client.query(CLAIM_TASK_SQL, [
|
|
642
|
+
input.id,
|
|
643
|
+
input.workerId,
|
|
644
|
+
input.leaseToken,
|
|
645
|
+
input.leaseExpiresAt,
|
|
646
|
+
input.now
|
|
647
|
+
]);
|
|
648
|
+
return result.rows[0] ? this.toTask(result.rows[0]) : null;
|
|
649
|
+
}
|
|
650
|
+
async heartbeat(input) {
|
|
651
|
+
const result = await this.client.query(HEARTBEAT_TASK_SQL, [
|
|
652
|
+
input.id,
|
|
653
|
+
input.leaseToken,
|
|
654
|
+
input.leaseExpiresAt,
|
|
655
|
+
input.now
|
|
656
|
+
]);
|
|
657
|
+
return result.rows.length === 1;
|
|
658
|
+
}
|
|
659
|
+
async reportProgress(input) {
|
|
660
|
+
const result = await this.client.query(REPORT_TASK_PROGRESS_SQL, [
|
|
661
|
+
input.id,
|
|
662
|
+
input.leaseToken,
|
|
663
|
+
input.stage ?? null,
|
|
664
|
+
input.progress,
|
|
665
|
+
input.now
|
|
666
|
+
]);
|
|
667
|
+
return result.rows[0] ? this.toTask(result.rows[0]) : null;
|
|
668
|
+
}
|
|
669
|
+
async transition(input) {
|
|
670
|
+
assertTaskTransition(input.from, input.to);
|
|
671
|
+
if (input.from === "QUEUED" && input.to === "PROCESSING") return null;
|
|
672
|
+
const clearsLease = input.to === "QUEUED" || input.to === "RETRYING" || isTerminalTaskStatus(input.to);
|
|
673
|
+
const error = input.error;
|
|
674
|
+
const result = await this.client.query(TRANSITION_TASK_SQL, [
|
|
675
|
+
input.id,
|
|
676
|
+
input.from,
|
|
677
|
+
input.to,
|
|
678
|
+
input.leaseToken ?? null,
|
|
679
|
+
input.stage ?? null,
|
|
680
|
+
input.progress ?? null,
|
|
681
|
+
jsonParameter(input.result),
|
|
682
|
+
error?.code ?? null,
|
|
683
|
+
error?.category ?? null,
|
|
684
|
+
error?.summary ?? null,
|
|
685
|
+
error?.retryable ?? null,
|
|
686
|
+
jsonParameter(error?.metadata),
|
|
687
|
+
input.providerRequestId ?? null,
|
|
688
|
+
input.availableAt ?? null,
|
|
689
|
+
input.now,
|
|
690
|
+
clearsLease,
|
|
691
|
+
isTerminalTaskStatus(input.to),
|
|
692
|
+
requiresTaskLease(input.from)
|
|
693
|
+
]);
|
|
694
|
+
return result.rows[0] ? this.toTask(result.rows[0]) : null;
|
|
695
|
+
}
|
|
696
|
+
async findExpiredLeases(input) {
|
|
697
|
+
if (input.limit < 1) return [];
|
|
698
|
+
const result = await this.client.query(FIND_EXPIRED_LEASES_SQL, [
|
|
699
|
+
input.now,
|
|
700
|
+
input.limit
|
|
701
|
+
]);
|
|
702
|
+
return result.rows.map((row) => this.toTask(row));
|
|
703
|
+
}
|
|
704
|
+
async findReadyQueued(input) {
|
|
705
|
+
if (input.limit < 1) return [];
|
|
706
|
+
const result = await this.client.query(FIND_READY_QUEUED_TASKS_SQL, [
|
|
707
|
+
input.now,
|
|
708
|
+
input.limit,
|
|
709
|
+
input.offset ?? 0
|
|
710
|
+
]);
|
|
711
|
+
return result.rows.map((row) => this.toTask(row));
|
|
712
|
+
}
|
|
713
|
+
async recoverExpiredLease(input) {
|
|
714
|
+
const result = await this.client.query(RECOVER_EXPIRED_LEASE_SQL, [
|
|
715
|
+
input.id,
|
|
716
|
+
input.leaseToken,
|
|
717
|
+
input.now,
|
|
718
|
+
input.error.code,
|
|
719
|
+
input.error.category,
|
|
720
|
+
input.error.summary,
|
|
721
|
+
input.error.retryable,
|
|
722
|
+
jsonParameter(input.error.metadata),
|
|
723
|
+
input.availableAt ?? null
|
|
724
|
+
]);
|
|
725
|
+
return result.rows[0] ? this.toTask(result.rows[0]) : null;
|
|
726
|
+
}
|
|
727
|
+
async cancel(id, now) {
|
|
728
|
+
const result = await this.client.query(CANCEL_TASK_SQL, [
|
|
729
|
+
id,
|
|
730
|
+
now
|
|
731
|
+
]);
|
|
732
|
+
return result.rows[0] ? this.toTask(result.rows[0]) : null;
|
|
733
|
+
}
|
|
734
|
+
toTask(row) {
|
|
735
|
+
return {
|
|
736
|
+
id: row.id,
|
|
737
|
+
kind: row.kind,
|
|
738
|
+
resource: {
|
|
739
|
+
type: row.resourceType,
|
|
740
|
+
id: row.resourceId
|
|
741
|
+
},
|
|
742
|
+
userId: row.userId ?? void 0,
|
|
743
|
+
orderId: row.orderId ?? void 0,
|
|
744
|
+
status: taskStatus(row.status),
|
|
745
|
+
stage: row.stage ?? void 0,
|
|
746
|
+
progress: row.progress,
|
|
747
|
+
attempt: row.attempt,
|
|
748
|
+
maxAttempts: row.maxAttempts,
|
|
749
|
+
idempotencyKey: row.idempotencyKey,
|
|
750
|
+
idempotencyFingerprint: row.idempotencyFingerprint,
|
|
751
|
+
handlerVersion: row.handlerVersion,
|
|
752
|
+
payload: parseJson(row.payload),
|
|
753
|
+
result: row.result === null ? void 0 : parseJson(row.result),
|
|
754
|
+
error: taskError(row),
|
|
755
|
+
providerRequestId: row.providerRequestId ?? void 0,
|
|
756
|
+
workerId: row.workerId ?? void 0,
|
|
757
|
+
leaseToken: row.leaseToken ?? void 0,
|
|
758
|
+
leaseExpiresAt: optionalDate(row.leaseExpiresAt),
|
|
759
|
+
heartbeatAt: optionalDate(row.heartbeatAt),
|
|
760
|
+
availableAt: new Date(row.availableAt),
|
|
761
|
+
createdAt: new Date(row.createdAt),
|
|
762
|
+
startedAt: optionalDate(row.startedAt),
|
|
763
|
+
completedAt: optionalDate(row.completedAt),
|
|
764
|
+
updatedAt: new Date(row.updatedAt),
|
|
765
|
+
version: row.version
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// src/postgres-task-summary-reader.ts
|
|
771
|
+
var SAFE_SUMMARY_COLUMNS = `
|
|
772
|
+
"id", "kind", "resourceType", "resourceId", "status"::text AS "status",
|
|
773
|
+
"stage", "progress", "attempt", "maxAttempts", "errorCode",
|
|
774
|
+
"errorCategory", "errorRetryable", "createdAt",
|
|
775
|
+
"startedAt", "completedAt", "updatedAt", "version"
|
|
776
|
+
`;
|
|
777
|
+
function optionalDate2(value) {
|
|
778
|
+
return value === null ? void 0 : new Date(value);
|
|
779
|
+
}
|
|
780
|
+
__name(optionalDate2, "optionalDate");
|
|
781
|
+
function taskStatus2(value) {
|
|
782
|
+
if (TASK_STATUSES.includes(value)) return value;
|
|
783
|
+
throw new Error(`Unknown task status returned by PostgreSQL: ${value}`);
|
|
784
|
+
}
|
|
785
|
+
__name(taskStatus2, "taskStatus");
|
|
786
|
+
function toSummary(row) {
|
|
787
|
+
const error = row.errorCode ? {
|
|
788
|
+
code: row.errorCode,
|
|
789
|
+
category: row.errorCategory ?? "UNKNOWN",
|
|
790
|
+
summary: row.errorCode,
|
|
791
|
+
retryable: row.errorRetryable ?? false
|
|
792
|
+
} : void 0;
|
|
793
|
+
return {
|
|
794
|
+
id: row.id,
|
|
795
|
+
kind: row.kind,
|
|
796
|
+
resource: {
|
|
797
|
+
type: row.resourceType,
|
|
798
|
+
id: row.resourceId
|
|
799
|
+
},
|
|
800
|
+
status: taskStatus2(row.status),
|
|
801
|
+
stage: row.stage ?? void 0,
|
|
802
|
+
progress: row.progress,
|
|
803
|
+
attempt: row.attempt,
|
|
804
|
+
maxAttempts: row.maxAttempts,
|
|
805
|
+
error,
|
|
806
|
+
createdAt: new Date(row.createdAt),
|
|
807
|
+
startedAt: optionalDate2(row.startedAt),
|
|
808
|
+
completedAt: optionalDate2(row.completedAt),
|
|
809
|
+
updatedAt: new Date(row.updatedAt),
|
|
810
|
+
version: row.version
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
__name(toSummary, "toSummary");
|
|
814
|
+
var PostgresTaskSummaryReader = class {
|
|
815
|
+
static {
|
|
816
|
+
__name(this, "PostgresTaskSummaryReader");
|
|
817
|
+
}
|
|
818
|
+
client;
|
|
819
|
+
constructor(client) {
|
|
820
|
+
this.client = client;
|
|
821
|
+
}
|
|
822
|
+
async findOwnedById(taskId, userId) {
|
|
823
|
+
const result = await this.client.query(`SELECT ${SAFE_SUMMARY_COLUMNS}
|
|
824
|
+
FROM async_jobs
|
|
825
|
+
WHERE "id" = $1 AND "userId" = $2
|
|
826
|
+
LIMIT 1`, [
|
|
827
|
+
taskId,
|
|
828
|
+
userId
|
|
829
|
+
]);
|
|
830
|
+
return result.rows[0] ? toSummary(result.rows[0]) : null;
|
|
831
|
+
}
|
|
832
|
+
async listOwned(input) {
|
|
833
|
+
if (!input.userId) return [];
|
|
834
|
+
const limit = Math.max(1, Math.min(input.limit ?? 50, 100));
|
|
835
|
+
const statuses = input.statuses?.length ? [
|
|
836
|
+
...new Set(input.statuses)
|
|
837
|
+
] : void 0;
|
|
838
|
+
if (statuses?.some((status2) => !TASK_STATUSES.includes(status2))) {
|
|
839
|
+
throw new RangeError("statuses contains an unknown task status");
|
|
840
|
+
}
|
|
841
|
+
const statusPlaceholders = statuses?.map((_, index) => `$${index + 2}::async_job_status`).join(", ");
|
|
842
|
+
const result = statuses ? await this.client.query(`SELECT ${SAFE_SUMMARY_COLUMNS}
|
|
843
|
+
FROM async_jobs
|
|
844
|
+
WHERE "userId" = $1
|
|
845
|
+
AND "status" IN (${statusPlaceholders})
|
|
846
|
+
ORDER BY "createdAt" DESC, "id" DESC
|
|
847
|
+
LIMIT $${statuses.length + 2}`, [
|
|
848
|
+
input.userId,
|
|
849
|
+
...statuses,
|
|
850
|
+
limit
|
|
851
|
+
]) : await this.client.query(`SELECT ${SAFE_SUMMARY_COLUMNS}
|
|
852
|
+
FROM async_jobs
|
|
853
|
+
WHERE "userId" = $1
|
|
854
|
+
ORDER BY "createdAt" DESC, "id" DESC
|
|
855
|
+
LIMIT $2`, [
|
|
856
|
+
input.userId,
|
|
857
|
+
limit
|
|
858
|
+
]);
|
|
859
|
+
return result.rows.map(toSummary);
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
// src/task-timeline.ts
|
|
864
|
+
var TASK_TIMELINE_EVENT_TYPES = [
|
|
865
|
+
"SNAPSHOT",
|
|
866
|
+
"CREATED",
|
|
867
|
+
"CLAIMED",
|
|
868
|
+
"PROGRESS",
|
|
869
|
+
"RETRY_SCHEDULED",
|
|
870
|
+
"REQUEUED",
|
|
871
|
+
"LEASE_RECOVERED",
|
|
872
|
+
"MANUAL_REDRIVE",
|
|
873
|
+
"COMPLETED",
|
|
874
|
+
"FAILED",
|
|
875
|
+
"CANCELLED",
|
|
876
|
+
"STATUS_CHANGED"
|
|
877
|
+
];
|
|
878
|
+
var PUBLIC_COLUMNS = `events."id", events."taskId", events."sequence",
|
|
879
|
+
events."eventType", events."fromStatus"::text AS "fromStatus",
|
|
880
|
+
events."toStatus"::text AS "toStatus", events."stage", events."progress",
|
|
881
|
+
events."attempt", events."maxAttempts", events."errorCode",
|
|
882
|
+
events."errorCategory", events."errorRetryable",
|
|
883
|
+
events."createdAt"`;
|
|
884
|
+
function status(value) {
|
|
885
|
+
if (value === null) return void 0;
|
|
886
|
+
if (TASK_STATUSES.includes(value)) return value;
|
|
887
|
+
throw new Error(`Unknown task status returned by PostgreSQL: ${value}`);
|
|
888
|
+
}
|
|
889
|
+
__name(status, "status");
|
|
890
|
+
function requiredStatus(value) {
|
|
891
|
+
const parsed = status(value);
|
|
892
|
+
if (parsed) return parsed;
|
|
893
|
+
throw new Error("Task timeline event is missing its destination status");
|
|
894
|
+
}
|
|
895
|
+
__name(requiredStatus, "requiredStatus");
|
|
896
|
+
function eventType(value) {
|
|
897
|
+
if (TASK_TIMELINE_EVENT_TYPES.includes(value)) {
|
|
898
|
+
return value;
|
|
899
|
+
}
|
|
900
|
+
throw new Error(`Unknown task timeline event type returned by PostgreSQL: ${value}`);
|
|
901
|
+
}
|
|
902
|
+
__name(eventType, "eventType");
|
|
903
|
+
function toOwnedEvent(row) {
|
|
904
|
+
const error = row.errorCode ? {
|
|
905
|
+
code: row.errorCode,
|
|
906
|
+
category: row.errorCategory ?? "UNKNOWN",
|
|
907
|
+
summary: row.errorCode,
|
|
908
|
+
retryable: row.errorRetryable ?? false
|
|
909
|
+
} : void 0;
|
|
910
|
+
return {
|
|
911
|
+
id: row.id,
|
|
912
|
+
taskId: row.taskId,
|
|
913
|
+
sequence: row.sequence,
|
|
914
|
+
type: eventType(row.eventType),
|
|
915
|
+
fromStatus: status(row.fromStatus),
|
|
916
|
+
toStatus: requiredStatus(row.toStatus),
|
|
917
|
+
stage: row.stage ?? void 0,
|
|
918
|
+
progress: row.progress,
|
|
919
|
+
attempt: row.attempt,
|
|
920
|
+
maxAttempts: row.maxAttempts,
|
|
921
|
+
error,
|
|
922
|
+
createdAt: new Date(row.createdAt)
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
__name(toOwnedEvent, "toOwnedEvent");
|
|
926
|
+
var PostgresTaskTimelineReader = class {
|
|
927
|
+
static {
|
|
928
|
+
__name(this, "PostgresTaskTimelineReader");
|
|
929
|
+
}
|
|
930
|
+
client;
|
|
931
|
+
constructor(client) {
|
|
932
|
+
this.client = client;
|
|
933
|
+
}
|
|
934
|
+
async listOwned(taskId, userId, limit = 200) {
|
|
935
|
+
if (!userId) return [];
|
|
936
|
+
const result = await this.client.query(`SELECT ${PUBLIC_COLUMNS}
|
|
937
|
+
FROM async_job_events AS events
|
|
938
|
+
INNER JOIN async_jobs AS tasks ON tasks."id" = events."taskId"
|
|
939
|
+
WHERE events."taskId" = $1 AND tasks."userId" = $2
|
|
940
|
+
ORDER BY events."sequence" ASC
|
|
941
|
+
LIMIT $3`, [
|
|
942
|
+
taskId,
|
|
943
|
+
userId,
|
|
944
|
+
this.normalizeLimit(limit)
|
|
945
|
+
]);
|
|
946
|
+
return result.rows.map(toOwnedEvent);
|
|
947
|
+
}
|
|
948
|
+
async listByTaskId(taskId, limit = 500) {
|
|
949
|
+
const result = await this.client.query(`SELECT ${PUBLIC_COLUMNS}, events."workerId", events."providerRequestId"
|
|
950
|
+
FROM async_job_events AS events
|
|
951
|
+
WHERE events."taskId" = $1
|
|
952
|
+
ORDER BY events."sequence" ASC
|
|
953
|
+
LIMIT $2`, [
|
|
954
|
+
taskId,
|
|
955
|
+
this.normalizeLimit(limit)
|
|
956
|
+
]);
|
|
957
|
+
return result.rows.map((row) => ({
|
|
958
|
+
...toOwnedEvent(row),
|
|
959
|
+
workerId: row.workerId ?? void 0,
|
|
960
|
+
providerRequestId: row.providerRequestId ?? void 0
|
|
961
|
+
}));
|
|
962
|
+
}
|
|
963
|
+
normalizeLimit(limit) {
|
|
964
|
+
return Math.max(1, Math.min(Math.trunc(limit), 1e3));
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
// src/task-redriver.ts
|
|
969
|
+
var PostgresTaskRedriver = class {
|
|
970
|
+
static {
|
|
971
|
+
__name(this, "PostgresTaskRedriver");
|
|
972
|
+
}
|
|
973
|
+
client;
|
|
974
|
+
constructor(client) {
|
|
975
|
+
this.client = client;
|
|
976
|
+
}
|
|
977
|
+
async redriveFailed(input) {
|
|
978
|
+
const additionalAttempts = input.additionalAttempts ?? 1;
|
|
979
|
+
if (!Number.isInteger(additionalAttempts) || additionalAttempts < 1 || additionalAttempts > 10) {
|
|
980
|
+
throw new RangeError("additionalAttempts must be an integer between 1 and 10");
|
|
981
|
+
}
|
|
982
|
+
const result = await this.client.query(`UPDATE async_jobs
|
|
983
|
+
SET "status" = 'QUEUED'::async_job_status,
|
|
984
|
+
"stage" = $2,
|
|
985
|
+
"maxAttempts" = GREATEST("maxAttempts", "attempt" + $3),
|
|
986
|
+
"result" = NULL,
|
|
987
|
+
"errorCode" = NULL,
|
|
988
|
+
"errorCategory" = NULL,
|
|
989
|
+
"errorSummary" = NULL,
|
|
990
|
+
"errorRetryable" = NULL,
|
|
991
|
+
"errorMetadata" = NULL,
|
|
992
|
+
"providerRequestId" = NULL,
|
|
993
|
+
"workerId" = NULL,
|
|
994
|
+
"leaseToken" = NULL,
|
|
995
|
+
"leaseExpiresAt" = NULL,
|
|
996
|
+
"heartbeatAt" = NULL,
|
|
997
|
+
"availableAt" = $4,
|
|
998
|
+
"completedAt" = NULL,
|
|
999
|
+
"updatedAt" = $4,
|
|
1000
|
+
"version" = "version" + 1
|
|
1001
|
+
WHERE "id" = $1 AND "status" = 'FAILED'::async_job_status
|
|
1002
|
+
RETURNING "id", "attempt", "maxAttempts", "version", "availableAt"`, [
|
|
1003
|
+
input.taskId,
|
|
1004
|
+
input.stage ?? "manual-redrive",
|
|
1005
|
+
additionalAttempts,
|
|
1006
|
+
input.now
|
|
1007
|
+
]);
|
|
1008
|
+
const row = result.rows[0];
|
|
1009
|
+
return row ? {
|
|
1010
|
+
taskId: row.id,
|
|
1011
|
+
attempt: row.attempt,
|
|
1012
|
+
maxAttempts: row.maxAttempts,
|
|
1013
|
+
version: row.version,
|
|
1014
|
+
availableAt: new Date(row.availableAt)
|
|
1015
|
+
} : null;
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
// src/postgres-task-outbox.ts
|
|
1020
|
+
var import_node_util = require("util");
|
|
1021
|
+
|
|
1022
|
+
// src/task-outbox.ts
|
|
1023
|
+
var TaskOutboxConflictError = class extends Error {
|
|
1024
|
+
static {
|
|
1025
|
+
__name(this, "TaskOutboxConflictError");
|
|
1026
|
+
}
|
|
1027
|
+
eventId;
|
|
1028
|
+
constructor(eventId) {
|
|
1029
|
+
super(`Outbox event id was reused with different content: ${eventId}`), this.eventId = eventId;
|
|
1030
|
+
this.name = "TaskOutboxConflictError";
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
// src/postgres-task-outbox.ts
|
|
1035
|
+
var APPEND_OUTBOX_SQL = `
|
|
1036
|
+
WITH inserted AS (
|
|
1037
|
+
INSERT INTO async_job_outbox (
|
|
1038
|
+
"id", "taskId", "topic", "payload", "status", "attempt",
|
|
1039
|
+
"availableAt", "createdAt", "updatedAt"
|
|
1040
|
+
) VALUES (
|
|
1041
|
+
$1, $2, $3, $4::jsonb, 'PENDING', 0,
|
|
1042
|
+
$5, $6, $6
|
|
1043
|
+
)
|
|
1044
|
+
ON CONFLICT ("id") DO NOTHING
|
|
1045
|
+
RETURNING *
|
|
1046
|
+
)
|
|
1047
|
+
SELECT inserted.*, TRUE AS "__created"
|
|
1048
|
+
FROM inserted
|
|
1049
|
+
UNION ALL
|
|
1050
|
+
SELECT existing.*, FALSE AS "__created"
|
|
1051
|
+
FROM async_job_outbox AS existing
|
|
1052
|
+
WHERE existing."id" = $1
|
|
1053
|
+
AND NOT EXISTS (SELECT 1 FROM inserted)
|
|
1054
|
+
LIMIT 1
|
|
1055
|
+
`;
|
|
1056
|
+
var CLAIM_OUTBOX_SQL = `
|
|
1057
|
+
WITH candidates AS (
|
|
1058
|
+
SELECT "id"
|
|
1059
|
+
FROM async_job_outbox
|
|
1060
|
+
WHERE "availableAt" <= $4
|
|
1061
|
+
AND (
|
|
1062
|
+
"status" = 'PENDING'
|
|
1063
|
+
OR (
|
|
1064
|
+
"status" = 'PUBLISHING'
|
|
1065
|
+
AND "leaseExpiresAt" <= $4
|
|
1066
|
+
)
|
|
1067
|
+
)
|
|
1068
|
+
ORDER BY "createdAt" ASC
|
|
1069
|
+
LIMIT $5
|
|
1070
|
+
FOR UPDATE SKIP LOCKED
|
|
1071
|
+
)
|
|
1072
|
+
UPDATE async_job_outbox AS events
|
|
1073
|
+
SET
|
|
1074
|
+
"status" = 'PUBLISHING',
|
|
1075
|
+
"workerId" = $1,
|
|
1076
|
+
"leaseToken" = $2,
|
|
1077
|
+
"leaseExpiresAt" = $3,
|
|
1078
|
+
"attempt" = events."attempt" + 1,
|
|
1079
|
+
"updatedAt" = $4
|
|
1080
|
+
FROM candidates
|
|
1081
|
+
WHERE events."id" = candidates."id"
|
|
1082
|
+
AND $3 > $4
|
|
1083
|
+
RETURNING events.*
|
|
1084
|
+
`;
|
|
1085
|
+
var MARK_OUTBOX_PUBLISHED_SQL = `
|
|
1086
|
+
UPDATE async_job_outbox
|
|
1087
|
+
SET
|
|
1088
|
+
"status" = 'PUBLISHED',
|
|
1089
|
+
"workerId" = NULL,
|
|
1090
|
+
"leaseToken" = NULL,
|
|
1091
|
+
"leaseExpiresAt" = NULL,
|
|
1092
|
+
"publishedAt" = $3,
|
|
1093
|
+
"updatedAt" = $3,
|
|
1094
|
+
"lastError" = NULL
|
|
1095
|
+
WHERE "id" = $1
|
|
1096
|
+
AND "status" = 'PUBLISHING'
|
|
1097
|
+
AND "leaseToken" = $2
|
|
1098
|
+
AND "leaseExpiresAt" > $3
|
|
1099
|
+
RETURNING "id"
|
|
1100
|
+
`;
|
|
1101
|
+
var RESCHEDULE_OUTBOX_SQL = `
|
|
1102
|
+
UPDATE async_job_outbox
|
|
1103
|
+
SET
|
|
1104
|
+
"status" = 'PENDING',
|
|
1105
|
+
"workerId" = NULL,
|
|
1106
|
+
"leaseToken" = NULL,
|
|
1107
|
+
"leaseExpiresAt" = NULL,
|
|
1108
|
+
"availableAt" = $3,
|
|
1109
|
+
"lastError" = $4,
|
|
1110
|
+
"updatedAt" = $5
|
|
1111
|
+
WHERE "id" = $1
|
|
1112
|
+
AND "status" = 'PUBLISHING'
|
|
1113
|
+
AND "leaseToken" = $2
|
|
1114
|
+
AND "leaseExpiresAt" > $5
|
|
1115
|
+
RETURNING "id"
|
|
1116
|
+
`;
|
|
1117
|
+
function parseJson2(value) {
|
|
1118
|
+
if (typeof value !== "string") return value;
|
|
1119
|
+
return JSON.parse(value);
|
|
1120
|
+
}
|
|
1121
|
+
__name(parseJson2, "parseJson");
|
|
1122
|
+
function optionalDate3(value) {
|
|
1123
|
+
return value === null ? void 0 : new Date(value);
|
|
1124
|
+
}
|
|
1125
|
+
__name(optionalDate3, "optionalDate");
|
|
1126
|
+
var PostgresTaskOutboxRepository = class {
|
|
1127
|
+
static {
|
|
1128
|
+
__name(this, "PostgresTaskOutboxRepository");
|
|
1129
|
+
}
|
|
1130
|
+
client;
|
|
1131
|
+
constructor(client) {
|
|
1132
|
+
this.client = client;
|
|
1133
|
+
}
|
|
1134
|
+
async append(input) {
|
|
1135
|
+
const result = await this.client.query(APPEND_OUTBOX_SQL, [
|
|
1136
|
+
input.id,
|
|
1137
|
+
input.taskId,
|
|
1138
|
+
input.topic,
|
|
1139
|
+
JSON.stringify(input.payload),
|
|
1140
|
+
input.availableAt ?? input.now,
|
|
1141
|
+
input.now
|
|
1142
|
+
]);
|
|
1143
|
+
let row = result.rows[0];
|
|
1144
|
+
if (!row) {
|
|
1145
|
+
const existing = await this.client.query('SELECT * FROM async_job_outbox WHERE "id" = $1 LIMIT 1', [
|
|
1146
|
+
input.id
|
|
1147
|
+
]);
|
|
1148
|
+
row = existing.rows[0];
|
|
1149
|
+
if (row) row.__created = false;
|
|
1150
|
+
}
|
|
1151
|
+
if (!row) throw new Error(`Outbox event could not be created: ${input.id}`);
|
|
1152
|
+
if (row.taskId !== input.taskId || row.topic !== input.topic || !(0, import_node_util.isDeepStrictEqual)(parseJson2(row.payload), input.payload)) {
|
|
1153
|
+
throw new TaskOutboxConflictError(input.id);
|
|
1154
|
+
}
|
|
1155
|
+
return {
|
|
1156
|
+
record: this.toRecord(row),
|
|
1157
|
+
created: row.__created
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
async claimBatch(input) {
|
|
1161
|
+
if (input.limit < 1 || input.leaseExpiresAt.getTime() <= input.now.getTime()) return [];
|
|
1162
|
+
const result = await this.client.query(CLAIM_OUTBOX_SQL, [
|
|
1163
|
+
input.workerId,
|
|
1164
|
+
input.leaseToken,
|
|
1165
|
+
input.leaseExpiresAt,
|
|
1166
|
+
input.now,
|
|
1167
|
+
input.limit
|
|
1168
|
+
]);
|
|
1169
|
+
return result.rows.map((row) => this.toRecord(row));
|
|
1170
|
+
}
|
|
1171
|
+
async markPublished(id, leaseToken, now) {
|
|
1172
|
+
const result = await this.client.query(MARK_OUTBOX_PUBLISHED_SQL, [
|
|
1173
|
+
id,
|
|
1174
|
+
leaseToken,
|
|
1175
|
+
now
|
|
1176
|
+
]);
|
|
1177
|
+
return result.rows.length === 1;
|
|
1178
|
+
}
|
|
1179
|
+
async reschedule(id, leaseToken, availableAt, errorSummary, now) {
|
|
1180
|
+
const result = await this.client.query(RESCHEDULE_OUTBOX_SQL, [
|
|
1181
|
+
id,
|
|
1182
|
+
leaseToken,
|
|
1183
|
+
availableAt,
|
|
1184
|
+
errorSummary,
|
|
1185
|
+
now
|
|
1186
|
+
]);
|
|
1187
|
+
return result.rows.length === 1;
|
|
1188
|
+
}
|
|
1189
|
+
toRecord(row) {
|
|
1190
|
+
return {
|
|
1191
|
+
id: row.id,
|
|
1192
|
+
taskId: row.taskId,
|
|
1193
|
+
topic: row.topic,
|
|
1194
|
+
payload: parseJson2(row.payload),
|
|
1195
|
+
status: row.status,
|
|
1196
|
+
attempt: row.attempt,
|
|
1197
|
+
availableAt: new Date(row.availableAt),
|
|
1198
|
+
workerId: row.workerId ?? void 0,
|
|
1199
|
+
leaseToken: row.leaseToken ?? void 0,
|
|
1200
|
+
leaseExpiresAt: optionalDate3(row.leaseExpiresAt),
|
|
1201
|
+
publishedAt: optionalDate3(row.publishedAt),
|
|
1202
|
+
lastError: row.lastError ?? void 0,
|
|
1203
|
+
createdAt: new Date(row.createdAt),
|
|
1204
|
+
updatedAt: new Date(row.updatedAt)
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
// src/postgres-task-submitter.ts
|
|
1210
|
+
var PostgresTaskSubmitter = class {
|
|
1211
|
+
static {
|
|
1212
|
+
__name(this, "PostgresTaskSubmitter");
|
|
1213
|
+
}
|
|
1214
|
+
transactions;
|
|
1215
|
+
constructor(transactions) {
|
|
1216
|
+
this.transactions = transactions;
|
|
1217
|
+
}
|
|
1218
|
+
async submit(input, options = {}) {
|
|
1219
|
+
return this.transactions.runInTransaction(async (client) => {
|
|
1220
|
+
const result = await this.submitWithClient(client, input);
|
|
1221
|
+
await options.transactionalWork?.(client, result);
|
|
1222
|
+
return result;
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
async submitBatch(inputs, options = {}) {
|
|
1226
|
+
return this.transactions.runInTransaction(async (client) => {
|
|
1227
|
+
const results = [];
|
|
1228
|
+
for (const input of inputs) {
|
|
1229
|
+
results.push(await this.submitWithClient(client, input));
|
|
1230
|
+
}
|
|
1231
|
+
await options.transactionalWork?.(client, results);
|
|
1232
|
+
return results;
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
async submitWithClient(client, input) {
|
|
1236
|
+
const tasks = new PostgresTaskRepository(client);
|
|
1237
|
+
const outbox = new PostgresTaskOutboxRepository(client);
|
|
1238
|
+
const task = await tasks.create(input.task);
|
|
1239
|
+
const event = await outbox.append({
|
|
1240
|
+
...input.outbox,
|
|
1241
|
+
taskId: task.task.id,
|
|
1242
|
+
now: input.task.now
|
|
1243
|
+
});
|
|
1244
|
+
return {
|
|
1245
|
+
task,
|
|
1246
|
+
outbox: event
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
// src/task-lease-reconciler.ts
|
|
1252
|
+
var leaseExpiredError = {
|
|
1253
|
+
code: "WORKER_LEASE_EXPIRED",
|
|
1254
|
+
category: "INTERNAL",
|
|
1255
|
+
summary: "Worker heartbeat expired before the task reached a terminal state",
|
|
1256
|
+
retryable: true
|
|
1257
|
+
};
|
|
1258
|
+
var TaskLeaseReconciler = class {
|
|
1259
|
+
static {
|
|
1260
|
+
__name(this, "TaskLeaseReconciler");
|
|
1261
|
+
}
|
|
1262
|
+
repository;
|
|
1263
|
+
batchSize;
|
|
1264
|
+
retryDelayMs;
|
|
1265
|
+
now;
|
|
1266
|
+
constructor(repository, options = {}) {
|
|
1267
|
+
this.repository = repository;
|
|
1268
|
+
this.batchSize = options.batchSize ?? 100;
|
|
1269
|
+
this.retryDelayMs = options.retryDelayMs ?? 0;
|
|
1270
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1271
|
+
if (this.batchSize < 1) throw new RangeError("batchSize must be at least 1");
|
|
1272
|
+
if (this.retryDelayMs < 0) throw new RangeError("retryDelayMs cannot be negative");
|
|
1273
|
+
}
|
|
1274
|
+
async runOnce() {
|
|
1275
|
+
const now = this.now();
|
|
1276
|
+
const expired = await this.repository.findExpiredLeases({
|
|
1277
|
+
now,
|
|
1278
|
+
limit: this.batchSize
|
|
1279
|
+
});
|
|
1280
|
+
const result = {
|
|
1281
|
+
found: expired.length,
|
|
1282
|
+
requeued: 0,
|
|
1283
|
+
failed: 0,
|
|
1284
|
+
conflicts: 0
|
|
1285
|
+
};
|
|
1286
|
+
for (const task of expired) {
|
|
1287
|
+
if (!task.leaseToken) {
|
|
1288
|
+
result.conflicts += 1;
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
const recovered = await this.repository.recoverExpiredLease({
|
|
1292
|
+
id: task.id,
|
|
1293
|
+
leaseToken: task.leaseToken,
|
|
1294
|
+
now,
|
|
1295
|
+
availableAt: new Date(now.getTime() + this.retryDelayMs),
|
|
1296
|
+
error: leaseExpiredError
|
|
1297
|
+
});
|
|
1298
|
+
if (!recovered) result.conflicts += 1;
|
|
1299
|
+
else if (recovered.status === "FAILED") result.failed += 1;
|
|
1300
|
+
else result.requeued += 1;
|
|
1301
|
+
}
|
|
1302
|
+
return result;
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
// src/task-queue-reconciler.ts
|
|
1307
|
+
var TaskQueueReconciler = class {
|
|
1308
|
+
static {
|
|
1309
|
+
__name(this, "TaskQueueReconciler");
|
|
1310
|
+
}
|
|
1311
|
+
repository;
|
|
1312
|
+
publisher;
|
|
1313
|
+
batchSize;
|
|
1314
|
+
now;
|
|
1315
|
+
offset = 0;
|
|
1316
|
+
constructor(repository, publisher, options = {}) {
|
|
1317
|
+
this.repository = repository;
|
|
1318
|
+
this.publisher = publisher;
|
|
1319
|
+
this.batchSize = options.batchSize ?? 100;
|
|
1320
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1321
|
+
if (this.batchSize < 1) throw new RangeError("batchSize must be at least 1");
|
|
1322
|
+
}
|
|
1323
|
+
async runOnce() {
|
|
1324
|
+
let tasks = await this.repository.findReadyQueued({
|
|
1325
|
+
now: this.now(),
|
|
1326
|
+
limit: this.batchSize,
|
|
1327
|
+
offset: this.offset
|
|
1328
|
+
});
|
|
1329
|
+
if (tasks.length === 0 && this.offset > 0) {
|
|
1330
|
+
this.offset = 0;
|
|
1331
|
+
tasks = await this.repository.findReadyQueued({
|
|
1332
|
+
now: this.now(),
|
|
1333
|
+
limit: this.batchSize,
|
|
1334
|
+
offset: 0
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
this.offset = tasks.length < this.batchSize ? 0 : this.offset + tasks.length;
|
|
1338
|
+
const result = {
|
|
1339
|
+
found: tasks.length,
|
|
1340
|
+
ensured: 0,
|
|
1341
|
+
failed: 0
|
|
1342
|
+
};
|
|
1343
|
+
for (const task of tasks) {
|
|
1344
|
+
try {
|
|
1345
|
+
await this.publisher.ensure(task);
|
|
1346
|
+
result.ensured += 1;
|
|
1347
|
+
} catch {
|
|
1348
|
+
result.failed += 1;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
return result;
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
// src/retry-policy.ts
|
|
1356
|
+
var defaultRetryableCategories = [
|
|
1357
|
+
"RATE_LIMIT",
|
|
1358
|
+
"NETWORK",
|
|
1359
|
+
"PROVIDER_5XX",
|
|
1360
|
+
"PROVIDER_TIMEOUT",
|
|
1361
|
+
"INTERNAL"
|
|
1362
|
+
];
|
|
1363
|
+
var ExponentialTaskRetryPolicy = class {
|
|
1364
|
+
static {
|
|
1365
|
+
__name(this, "ExponentialTaskRetryPolicy");
|
|
1366
|
+
}
|
|
1367
|
+
baseDelayMs;
|
|
1368
|
+
maxDelayMs;
|
|
1369
|
+
jitterRatio;
|
|
1370
|
+
retryableCategories;
|
|
1371
|
+
random;
|
|
1372
|
+
constructor(options = {}) {
|
|
1373
|
+
this.baseDelayMs = options.baseDelayMs ?? 1e3;
|
|
1374
|
+
this.maxDelayMs = options.maxDelayMs ?? 6e4;
|
|
1375
|
+
this.jitterRatio = options.jitterRatio ?? 0.2;
|
|
1376
|
+
this.retryableCategories = new Set(options.retryableCategories ?? defaultRetryableCategories);
|
|
1377
|
+
this.random = options.random ?? Math.random;
|
|
1378
|
+
if (this.baseDelayMs < 0) throw new RangeError("baseDelayMs cannot be negative");
|
|
1379
|
+
if (this.maxDelayMs < this.baseDelayMs) {
|
|
1380
|
+
throw new RangeError("maxDelayMs cannot be lower than baseDelayMs");
|
|
1381
|
+
}
|
|
1382
|
+
if (this.jitterRatio < 0 || this.jitterRatio > 1) {
|
|
1383
|
+
throw new RangeError("jitterRatio must be between 0 and 1");
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
decide(context) {
|
|
1387
|
+
if (context.attempt >= context.maxAttempts) {
|
|
1388
|
+
return {
|
|
1389
|
+
retry: false,
|
|
1390
|
+
reason: "ATTEMPTS_EXHAUSTED"
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
if (!context.error.retryable || !this.retryableCategories.has(context.error.category)) {
|
|
1394
|
+
return {
|
|
1395
|
+
retry: false,
|
|
1396
|
+
reason: "NON_RETRYABLE"
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
const exponential = Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** Math.max(0, context.attempt - 1));
|
|
1400
|
+
const retryAfter = Math.max(0, context.retryAfterMs ?? 0);
|
|
1401
|
+
const base = Math.max(exponential, retryAfter);
|
|
1402
|
+
const spread = Math.min(this.maxDelayMs, base) * this.jitterRatio;
|
|
1403
|
+
const jittered = base - spread + this.random() * spread * 2;
|
|
1404
|
+
return {
|
|
1405
|
+
retry: true,
|
|
1406
|
+
delayMs: Math.round(Math.min(this.maxDelayMs, jittered))
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
// src/durable-worker-runtime.ts
|
|
1412
|
+
var import_node_crypto2 = require("crypto");
|
|
1413
|
+
var DurableTaskNotFoundError = class extends Error {
|
|
1414
|
+
static {
|
|
1415
|
+
__name(this, "DurableTaskNotFoundError");
|
|
1416
|
+
}
|
|
1417
|
+
taskId;
|
|
1418
|
+
constructor(taskId) {
|
|
1419
|
+
super(`Durable task was not found: ${taskId}`), this.taskId = taskId;
|
|
1420
|
+
this.name = "DurableTaskNotFoundError";
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
var UnsupportedDurableTaskKindError = class extends Error {
|
|
1424
|
+
static {
|
|
1425
|
+
__name(this, "UnsupportedDurableTaskKindError");
|
|
1426
|
+
}
|
|
1427
|
+
taskId;
|
|
1428
|
+
kind;
|
|
1429
|
+
constructor(taskId, kind) {
|
|
1430
|
+
super(`No durable task handler is registered for kind: ${kind}`), this.taskId = taskId, this.kind = kind;
|
|
1431
|
+
this.name = "UnsupportedDurableTaskKindError";
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
var DurableTaskNotClaimableError = class extends Error {
|
|
1435
|
+
static {
|
|
1436
|
+
__name(this, "DurableTaskNotClaimableError");
|
|
1437
|
+
}
|
|
1438
|
+
taskId;
|
|
1439
|
+
status;
|
|
1440
|
+
constructor(taskId, status2) {
|
|
1441
|
+
super(`Durable task is not claimable: ${taskId} (${status2})`), this.taskId = taskId, this.status = status2;
|
|
1442
|
+
this.name = "DurableTaskNotClaimableError";
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
var DurableTaskLeaseLostError = class extends Error {
|
|
1446
|
+
static {
|
|
1447
|
+
__name(this, "DurableTaskLeaseLostError");
|
|
1448
|
+
}
|
|
1449
|
+
taskId;
|
|
1450
|
+
operation;
|
|
1451
|
+
constructor(taskId, operation) {
|
|
1452
|
+
super(`Durable task lease no longer owns ${operation}: ${taskId}`), this.taskId = taskId, this.operation = operation;
|
|
1453
|
+
this.name = "DurableTaskLeaseLostError";
|
|
1454
|
+
}
|
|
1455
|
+
};
|
|
1456
|
+
var DurableTaskRetryScheduledError = class extends Error {
|
|
1457
|
+
static {
|
|
1458
|
+
__name(this, "DurableTaskRetryScheduledError");
|
|
1459
|
+
}
|
|
1460
|
+
taskId;
|
|
1461
|
+
taskError;
|
|
1462
|
+
retryAt;
|
|
1463
|
+
constructor(taskId, taskError2, retryAt) {
|
|
1464
|
+
super(`Durable task retry was scheduled: ${taskId} (${taskError2.code})`), this.taskId = taskId, this.taskError = taskError2, this.retryAt = retryAt;
|
|
1465
|
+
this.name = "DurableTaskRetryScheduledError";
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
var defaultHeartbeatScheduler = {
|
|
1469
|
+
start(tick, intervalMs) {
|
|
1470
|
+
const handle = setInterval(() => void tick(), intervalMs);
|
|
1471
|
+
handle.unref?.();
|
|
1472
|
+
return handle;
|
|
1473
|
+
},
|
|
1474
|
+
stop(handle) {
|
|
1475
|
+
clearInterval(handle);
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
function terminalResult(task) {
|
|
1479
|
+
return {
|
|
1480
|
+
taskId: task.id,
|
|
1481
|
+
kind: task.kind,
|
|
1482
|
+
status: task.status
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
__name(terminalResult, "terminalResult");
|
|
1486
|
+
var DurableWorkerRuntime = class {
|
|
1487
|
+
static {
|
|
1488
|
+
__name(this, "DurableWorkerRuntime");
|
|
1489
|
+
}
|
|
1490
|
+
options;
|
|
1491
|
+
repository;
|
|
1492
|
+
retryPolicy;
|
|
1493
|
+
workerId;
|
|
1494
|
+
leaseDurationMs;
|
|
1495
|
+
heartbeatIntervalMs;
|
|
1496
|
+
maxConsecutiveHeartbeatFailures;
|
|
1497
|
+
now;
|
|
1498
|
+
createLeaseToken;
|
|
1499
|
+
heartbeatScheduler;
|
|
1500
|
+
constructor(options) {
|
|
1501
|
+
this.options = options;
|
|
1502
|
+
this.repository = options.repository;
|
|
1503
|
+
this.retryPolicy = options.retryPolicy;
|
|
1504
|
+
this.workerId = options.workerId;
|
|
1505
|
+
this.leaseDurationMs = options.leaseDurationMs ?? 9e4;
|
|
1506
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 2e4;
|
|
1507
|
+
this.maxConsecutiveHeartbeatFailures = options.maxConsecutiveHeartbeatFailures ?? 3;
|
|
1508
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1509
|
+
this.createLeaseToken = options.createLeaseToken ?? import_node_crypto2.randomUUID;
|
|
1510
|
+
this.heartbeatScheduler = options.heartbeatScheduler ?? defaultHeartbeatScheduler;
|
|
1511
|
+
if (!this.workerId.trim()) throw new RangeError("workerId cannot be empty");
|
|
1512
|
+
if (this.leaseDurationMs < 1) throw new RangeError("leaseDurationMs must be positive");
|
|
1513
|
+
if (this.heartbeatIntervalMs < 1) {
|
|
1514
|
+
throw new RangeError("heartbeatIntervalMs must be positive");
|
|
1515
|
+
}
|
|
1516
|
+
if (this.heartbeatIntervalMs >= this.leaseDurationMs) {
|
|
1517
|
+
throw new RangeError("heartbeatIntervalMs must be lower than leaseDurationMs");
|
|
1518
|
+
}
|
|
1519
|
+
if (this.maxConsecutiveHeartbeatFailures < 1) {
|
|
1520
|
+
throw new RangeError("maxConsecutiveHeartbeatFailures must be at least 1");
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
async run(taskId) {
|
|
1524
|
+
let task = await this.repository.findById(taskId);
|
|
1525
|
+
if (!task) throw new DurableTaskNotFoundError(taskId);
|
|
1526
|
+
if (isTerminalTaskStatus(task.status)) return terminalResult(task);
|
|
1527
|
+
if (task.status === "RETRYING") {
|
|
1528
|
+
const queued = await this.repository.transition({
|
|
1529
|
+
id: task.id,
|
|
1530
|
+
from: "RETRYING",
|
|
1531
|
+
to: "QUEUED",
|
|
1532
|
+
now: this.now()
|
|
1533
|
+
});
|
|
1534
|
+
if (!queued) {
|
|
1535
|
+
const latest = await this.repository.findById(task.id);
|
|
1536
|
+
if (latest && isTerminalTaskStatus(latest.status)) return terminalResult(latest);
|
|
1537
|
+
throw new DurableTaskNotClaimableError(task.id, latest?.status ?? task.status);
|
|
1538
|
+
}
|
|
1539
|
+
task = queued;
|
|
1540
|
+
}
|
|
1541
|
+
if (task.status !== "QUEUED") {
|
|
1542
|
+
throw new DurableTaskNotClaimableError(task.id, task.status);
|
|
1543
|
+
}
|
|
1544
|
+
const handler = this.options.resolveHandler(task);
|
|
1545
|
+
if (!handler) throw new UnsupportedDurableTaskKindError(task.id, task.kind);
|
|
1546
|
+
const leaseToken = this.createLeaseToken();
|
|
1547
|
+
const claimedAt = this.now();
|
|
1548
|
+
const claimed = await this.repository.claim({
|
|
1549
|
+
id: task.id,
|
|
1550
|
+
workerId: this.workerId,
|
|
1551
|
+
leaseToken,
|
|
1552
|
+
leaseExpiresAt: this.leaseExpiry(claimedAt),
|
|
1553
|
+
now: claimedAt
|
|
1554
|
+
});
|
|
1555
|
+
if (!claimed) throw new DurableTaskNotClaimableError(task.id, task.status);
|
|
1556
|
+
const controller = new AbortController();
|
|
1557
|
+
let abortNotified = false;
|
|
1558
|
+
let heartbeatFailures = 0;
|
|
1559
|
+
let heartbeatInFlight = false;
|
|
1560
|
+
const abortAttempt = /* @__PURE__ */ __name(async (operation) => {
|
|
1561
|
+
if (controller.signal.aborted) return;
|
|
1562
|
+
const reason = new DurableTaskLeaseLostError(claimed.id, operation);
|
|
1563
|
+
controller.abort(reason);
|
|
1564
|
+
if (abortNotified) return;
|
|
1565
|
+
abortNotified = true;
|
|
1566
|
+
try {
|
|
1567
|
+
await this.options.onAbort?.(claimed, reason);
|
|
1568
|
+
} catch {
|
|
1569
|
+
this.options.logger?.warn?.("durable_task_abort_callback_failed", {
|
|
1570
|
+
taskId: claimed.id,
|
|
1571
|
+
kind: claimed.kind
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
}, "abortAttempt");
|
|
1575
|
+
const renewLease = /* @__PURE__ */ __name(async () => {
|
|
1576
|
+
const now = this.now();
|
|
1577
|
+
return this.repository.heartbeat({
|
|
1578
|
+
id: claimed.id,
|
|
1579
|
+
leaseToken,
|
|
1580
|
+
leaseExpiresAt: this.leaseExpiry(now),
|
|
1581
|
+
now
|
|
1582
|
+
});
|
|
1583
|
+
}, "renewLease");
|
|
1584
|
+
const heartbeat = /* @__PURE__ */ __name(async () => {
|
|
1585
|
+
if (heartbeatInFlight || controller.signal.aborted) return;
|
|
1586
|
+
heartbeatInFlight = true;
|
|
1587
|
+
try {
|
|
1588
|
+
const renewed = await renewLease();
|
|
1589
|
+
if (!renewed) {
|
|
1590
|
+
this.options.logger?.error?.("durable_task_lease_lost", {
|
|
1591
|
+
taskId: claimed.id,
|
|
1592
|
+
kind: claimed.kind
|
|
1593
|
+
});
|
|
1594
|
+
await abortAttempt("heartbeat");
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
heartbeatFailures = 0;
|
|
1598
|
+
} catch {
|
|
1599
|
+
heartbeatFailures += 1;
|
|
1600
|
+
this.options.logger?.warn?.("durable_task_heartbeat_failed", {
|
|
1601
|
+
taskId: claimed.id,
|
|
1602
|
+
kind: claimed.kind,
|
|
1603
|
+
consecutiveFailures: heartbeatFailures
|
|
1604
|
+
});
|
|
1605
|
+
if (heartbeatFailures >= this.maxConsecutiveHeartbeatFailures) {
|
|
1606
|
+
await abortAttempt("heartbeat");
|
|
1607
|
+
}
|
|
1608
|
+
} finally {
|
|
1609
|
+
heartbeatInFlight = false;
|
|
1610
|
+
}
|
|
1611
|
+
}, "heartbeat");
|
|
1612
|
+
const context = {
|
|
1613
|
+
task: claimed,
|
|
1614
|
+
leaseToken,
|
|
1615
|
+
signal: controller.signal,
|
|
1616
|
+
now: this.now,
|
|
1617
|
+
assertActive: /* @__PURE__ */ __name(async () => {
|
|
1618
|
+
let active;
|
|
1619
|
+
try {
|
|
1620
|
+
active = await renewLease();
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
await abortAttempt("active check");
|
|
1623
|
+
throw error;
|
|
1624
|
+
}
|
|
1625
|
+
if (active) return;
|
|
1626
|
+
await abortAttempt("active check");
|
|
1627
|
+
throw new DurableTaskLeaseLostError(claimed.id, "active check");
|
|
1628
|
+
}, "assertActive"),
|
|
1629
|
+
reportProgress: /* @__PURE__ */ __name(async (stage, progress) => {
|
|
1630
|
+
const updated = await this.repository.reportProgress({
|
|
1631
|
+
id: claimed.id,
|
|
1632
|
+
leaseToken,
|
|
1633
|
+
stage,
|
|
1634
|
+
progress,
|
|
1635
|
+
now: this.now()
|
|
1636
|
+
});
|
|
1637
|
+
if (updated) return updated;
|
|
1638
|
+
await abortAttempt("progress update");
|
|
1639
|
+
throw new DurableTaskLeaseLostError(claimed.id, "progress update");
|
|
1640
|
+
}, "reportProgress")
|
|
1641
|
+
};
|
|
1642
|
+
const heartbeatHandle = this.heartbeatScheduler.start(heartbeat, this.heartbeatIntervalMs);
|
|
1643
|
+
try {
|
|
1644
|
+
const result = await handler.execute(context);
|
|
1645
|
+
if (controller.signal.aborted) {
|
|
1646
|
+
throw controller.signal.reason instanceof Error ? controller.signal.reason : new DurableTaskLeaseLostError(claimed.id, "completion");
|
|
1647
|
+
}
|
|
1648
|
+
const completed = handler.complete ? await handler.complete(context, result) : Boolean(await this.repository.transition({
|
|
1649
|
+
id: claimed.id,
|
|
1650
|
+
from: "PROCESSING",
|
|
1651
|
+
to: "COMPLETED",
|
|
1652
|
+
leaseToken,
|
|
1653
|
+
result,
|
|
1654
|
+
now: this.now()
|
|
1655
|
+
}));
|
|
1656
|
+
if (!completed) return this.resolveRejectedCommit(claimed, "completion");
|
|
1657
|
+
return {
|
|
1658
|
+
taskId: claimed.id,
|
|
1659
|
+
kind: claimed.kind,
|
|
1660
|
+
status: "COMPLETED"
|
|
1661
|
+
};
|
|
1662
|
+
} catch (executionError) {
|
|
1663
|
+
const latest = await this.findLatestBestEffort(claimed);
|
|
1664
|
+
if (latest && isTerminalTaskStatus(latest.status)) {
|
|
1665
|
+
if (latest.status === "CANCELLED") await handler.onCancelled?.(context);
|
|
1666
|
+
return terminalResult(latest);
|
|
1667
|
+
}
|
|
1668
|
+
if (executionError instanceof DurableTaskRetryScheduledError) throw executionError;
|
|
1669
|
+
if (controller.signal.aborted) {
|
|
1670
|
+
throw controller.signal.reason instanceof Error ? controller.signal.reason : new DurableTaskLeaseLostError(claimed.id, "execution");
|
|
1671
|
+
}
|
|
1672
|
+
if (executionError instanceof DurableTaskLeaseLostError) throw executionError;
|
|
1673
|
+
const taskError2 = this.options.classifyError(executionError, claimed);
|
|
1674
|
+
const decision = this.retryPolicy.decide({
|
|
1675
|
+
error: taskError2,
|
|
1676
|
+
attempt: claimed.attempt,
|
|
1677
|
+
maxAttempts: claimed.maxAttempts,
|
|
1678
|
+
retryAfterMs: this.options.retryAfterMs?.(executionError, claimed)
|
|
1679
|
+
});
|
|
1680
|
+
if (decision.retry) {
|
|
1681
|
+
const retryAt = new Date(this.now().getTime() + decision.delayMs);
|
|
1682
|
+
const retried = handler.retry ? await handler.retry(context, taskError2, retryAt) : await this.retry(context, taskError2, retryAt);
|
|
1683
|
+
if (!retried) return this.resolveRejectedCommit(claimed, "retry");
|
|
1684
|
+
throw new DurableTaskRetryScheduledError(claimed.id, taskError2, retryAt);
|
|
1685
|
+
}
|
|
1686
|
+
const failed = handler.fail ? await handler.fail(context, taskError2) : Boolean(await this.repository.transition({
|
|
1687
|
+
id: claimed.id,
|
|
1688
|
+
from: "PROCESSING",
|
|
1689
|
+
to: "FAILED",
|
|
1690
|
+
leaseToken,
|
|
1691
|
+
error: taskError2,
|
|
1692
|
+
now: this.now()
|
|
1693
|
+
}));
|
|
1694
|
+
if (!failed) return this.resolveRejectedCommit(claimed, "failure");
|
|
1695
|
+
return {
|
|
1696
|
+
taskId: claimed.id,
|
|
1697
|
+
kind: claimed.kind,
|
|
1698
|
+
status: "FAILED"
|
|
1699
|
+
};
|
|
1700
|
+
} finally {
|
|
1701
|
+
this.heartbeatScheduler.stop(heartbeatHandle);
|
|
1702
|
+
try {
|
|
1703
|
+
await handler.cleanup?.(context);
|
|
1704
|
+
} catch {
|
|
1705
|
+
this.options.logger?.warn?.("durable_task_cleanup_failed", {
|
|
1706
|
+
taskId: claimed.id,
|
|
1707
|
+
kind: claimed.kind
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
leaseExpiry(now) {
|
|
1713
|
+
return new Date(now.getTime() + this.leaseDurationMs);
|
|
1714
|
+
}
|
|
1715
|
+
async retry(context, error, retryAt) {
|
|
1716
|
+
const retrying = await this.repository.transition({
|
|
1717
|
+
id: context.task.id,
|
|
1718
|
+
from: "PROCESSING",
|
|
1719
|
+
to: "RETRYING",
|
|
1720
|
+
leaseToken: context.leaseToken,
|
|
1721
|
+
error,
|
|
1722
|
+
availableAt: retryAt,
|
|
1723
|
+
now: this.now()
|
|
1724
|
+
});
|
|
1725
|
+
if (!retrying) return false;
|
|
1726
|
+
return Boolean(await this.repository.transition({
|
|
1727
|
+
id: context.task.id,
|
|
1728
|
+
from: "RETRYING",
|
|
1729
|
+
to: "QUEUED",
|
|
1730
|
+
error,
|
|
1731
|
+
availableAt: retryAt,
|
|
1732
|
+
now: this.now()
|
|
1733
|
+
}));
|
|
1734
|
+
}
|
|
1735
|
+
async resolveRejectedCommit(claimed, operation) {
|
|
1736
|
+
const latest = await this.repository.findById(claimed.id);
|
|
1737
|
+
if (latest && isTerminalTaskStatus(latest.status)) return terminalResult(latest);
|
|
1738
|
+
throw new DurableTaskLeaseLostError(claimed.id, operation);
|
|
1739
|
+
}
|
|
1740
|
+
async findLatestBestEffort(claimed) {
|
|
1741
|
+
try {
|
|
1742
|
+
return await this.repository.findById(claimed.id);
|
|
1743
|
+
} catch {
|
|
1744
|
+
this.options.logger?.warn?.("durable_task_status_refresh_failed", {
|
|
1745
|
+
taskId: claimed.id,
|
|
1746
|
+
kind: claimed.kind
|
|
1747
|
+
});
|
|
1748
|
+
return null;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
// src/in-memory-task-outbox.ts
|
|
1754
|
+
var import_node_util2 = require("util");
|
|
1755
|
+
function cloneRecord(record) {
|
|
1756
|
+
return {
|
|
1757
|
+
...record,
|
|
1758
|
+
availableAt: new Date(record.availableAt),
|
|
1759
|
+
createdAt: new Date(record.createdAt),
|
|
1760
|
+
updatedAt: new Date(record.updatedAt),
|
|
1761
|
+
leaseExpiresAt: record.leaseExpiresAt ? new Date(record.leaseExpiresAt) : void 0,
|
|
1762
|
+
publishedAt: record.publishedAt ? new Date(record.publishedAt) : void 0
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
__name(cloneRecord, "cloneRecord");
|
|
1766
|
+
var InMemoryTaskOutboxRepository = class {
|
|
1767
|
+
static {
|
|
1768
|
+
__name(this, "InMemoryTaskOutboxRepository");
|
|
1769
|
+
}
|
|
1770
|
+
records = /* @__PURE__ */ new Map();
|
|
1771
|
+
async append(input) {
|
|
1772
|
+
const existing = this.records.get(input.id);
|
|
1773
|
+
if (existing) {
|
|
1774
|
+
if (existing.taskId !== input.taskId || existing.topic !== input.topic || !(0, import_node_util2.isDeepStrictEqual)(existing.payload, input.payload)) {
|
|
1775
|
+
throw new TaskOutboxConflictError(input.id);
|
|
1776
|
+
}
|
|
1777
|
+
return {
|
|
1778
|
+
record: cloneRecord(existing),
|
|
1779
|
+
created: false
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
const record = {
|
|
1783
|
+
id: input.id,
|
|
1784
|
+
taskId: input.taskId,
|
|
1785
|
+
topic: input.topic,
|
|
1786
|
+
payload: input.payload,
|
|
1787
|
+
status: "PENDING",
|
|
1788
|
+
attempt: 0,
|
|
1789
|
+
availableAt: new Date(input.availableAt ?? input.now),
|
|
1790
|
+
createdAt: new Date(input.now),
|
|
1791
|
+
updatedAt: new Date(input.now)
|
|
1792
|
+
};
|
|
1793
|
+
this.records.set(record.id, record);
|
|
1794
|
+
return {
|
|
1795
|
+
record: cloneRecord(record),
|
|
1796
|
+
created: true
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
async claimBatch(input) {
|
|
1800
|
+
if (input.limit < 1) return [];
|
|
1801
|
+
if (input.leaseExpiresAt.getTime() <= input.now.getTime()) return [];
|
|
1802
|
+
const claimable = [
|
|
1803
|
+
...this.records.values()
|
|
1804
|
+
].filter((record) => {
|
|
1805
|
+
if (record.availableAt.getTime() > input.now.getTime()) return false;
|
|
1806
|
+
if (record.status === "PENDING") return true;
|
|
1807
|
+
return record.status === "PUBLISHING" && Boolean(record.leaseExpiresAt && record.leaseExpiresAt.getTime() <= input.now.getTime());
|
|
1808
|
+
}).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, input.limit);
|
|
1809
|
+
return claimable.map((record) => {
|
|
1810
|
+
record.status = "PUBLISHING";
|
|
1811
|
+
record.workerId = input.workerId;
|
|
1812
|
+
record.leaseToken = input.leaseToken;
|
|
1813
|
+
record.leaseExpiresAt = new Date(input.leaseExpiresAt);
|
|
1814
|
+
record.attempt += 1;
|
|
1815
|
+
record.updatedAt = new Date(input.now);
|
|
1816
|
+
return cloneRecord(record);
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
async markPublished(id, leaseToken, now) {
|
|
1820
|
+
const record = this.records.get(id);
|
|
1821
|
+
if (!record || record.status !== "PUBLISHING" || record.leaseToken !== leaseToken || !record.leaseExpiresAt || record.leaseExpiresAt.getTime() <= now.getTime()) return false;
|
|
1822
|
+
record.status = "PUBLISHED";
|
|
1823
|
+
record.workerId = void 0;
|
|
1824
|
+
record.leaseToken = void 0;
|
|
1825
|
+
record.leaseExpiresAt = void 0;
|
|
1826
|
+
record.publishedAt = new Date(now);
|
|
1827
|
+
record.updatedAt = new Date(now);
|
|
1828
|
+
record.lastError = void 0;
|
|
1829
|
+
return true;
|
|
1830
|
+
}
|
|
1831
|
+
async reschedule(id, leaseToken, availableAt, errorSummary, now) {
|
|
1832
|
+
const record = this.records.get(id);
|
|
1833
|
+
if (!record || record.status !== "PUBLISHING" || record.leaseToken !== leaseToken || !record.leaseExpiresAt || record.leaseExpiresAt.getTime() <= now.getTime()) return false;
|
|
1834
|
+
record.status = "PENDING";
|
|
1835
|
+
record.workerId = void 0;
|
|
1836
|
+
record.leaseToken = void 0;
|
|
1837
|
+
record.leaseExpiresAt = void 0;
|
|
1838
|
+
record.availableAt = new Date(availableAt);
|
|
1839
|
+
record.updatedAt = new Date(now);
|
|
1840
|
+
record.lastError = errorSummary;
|
|
1841
|
+
return true;
|
|
1842
|
+
}
|
|
1843
|
+
};
|
|
1844
|
+
|
|
1845
|
+
// src/task-outbox-dispatcher.ts
|
|
1846
|
+
var TaskOutboxDispatcher = class {
|
|
1847
|
+
static {
|
|
1848
|
+
__name(this, "TaskOutboxDispatcher");
|
|
1849
|
+
}
|
|
1850
|
+
repository;
|
|
1851
|
+
publisher;
|
|
1852
|
+
options;
|
|
1853
|
+
batchSize;
|
|
1854
|
+
leaseDurationMs;
|
|
1855
|
+
retryDelayMs;
|
|
1856
|
+
now;
|
|
1857
|
+
constructor(repository, publisher, options) {
|
|
1858
|
+
this.repository = repository;
|
|
1859
|
+
this.publisher = publisher;
|
|
1860
|
+
this.options = options;
|
|
1861
|
+
this.batchSize = options.batchSize ?? 100;
|
|
1862
|
+
this.leaseDurationMs = options.leaseDurationMs ?? 3e4;
|
|
1863
|
+
this.retryDelayMs = options.retryDelayMs ?? ((attempt) => Math.min(6e4, 1e3 * 2 ** (attempt - 1)));
|
|
1864
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1865
|
+
if (!options.workerId) throw new Error("workerId is required");
|
|
1866
|
+
if (this.batchSize < 1) throw new RangeError("batchSize must be at least 1");
|
|
1867
|
+
if (this.leaseDurationMs < 1) throw new RangeError("leaseDurationMs must be at least 1");
|
|
1868
|
+
}
|
|
1869
|
+
async runOnce() {
|
|
1870
|
+
const claimedAt = this.now();
|
|
1871
|
+
const leaseToken = this.options.createLeaseToken();
|
|
1872
|
+
const messages = await this.repository.claimBatch({
|
|
1873
|
+
workerId: this.options.workerId,
|
|
1874
|
+
leaseToken,
|
|
1875
|
+
leaseExpiresAt: new Date(claimedAt.getTime() + this.leaseDurationMs),
|
|
1876
|
+
limit: this.batchSize,
|
|
1877
|
+
now: claimedAt
|
|
1878
|
+
});
|
|
1879
|
+
const result = {
|
|
1880
|
+
claimed: messages.length,
|
|
1881
|
+
published: 0,
|
|
1882
|
+
rescheduled: 0,
|
|
1883
|
+
conflicts: 0
|
|
1884
|
+
};
|
|
1885
|
+
for (const message of messages) {
|
|
1886
|
+
try {
|
|
1887
|
+
await this.publisher.publish(message);
|
|
1888
|
+
const marked = await this.repository.markPublished(message.id, leaseToken, this.now());
|
|
1889
|
+
if (marked) result.published += 1;
|
|
1890
|
+
else result.conflicts += 1;
|
|
1891
|
+
} catch (error) {
|
|
1892
|
+
const now = this.now();
|
|
1893
|
+
const delayMs = Math.max(0, this.retryDelayMs(message.attempt));
|
|
1894
|
+
const summary = error instanceof Error ? error.message : String(error);
|
|
1895
|
+
const rescheduled = await this.repository.reschedule(message.id, leaseToken, new Date(now.getTime() + delayMs), summary.slice(0, 280), now);
|
|
1896
|
+
if (rescheduled) result.rescheduled += 1;
|
|
1897
|
+
else result.conflicts += 1;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
return result;
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1904
|
+
0 && (module.exports = {
|
|
1905
|
+
DurableTaskLeaseLostError,
|
|
1906
|
+
DurableTaskNotClaimableError,
|
|
1907
|
+
DurableTaskNotFoundError,
|
|
1908
|
+
DurableTaskRetryScheduledError,
|
|
1909
|
+
DurableWorkerRuntime,
|
|
1910
|
+
ExponentialTaskRetryPolicy,
|
|
1911
|
+
InMemoryTaskOutboxRepository,
|
|
1912
|
+
InMemoryTaskRepository,
|
|
1913
|
+
InvalidTaskTransitionError,
|
|
1914
|
+
PostgresTaskOutboxRepository,
|
|
1915
|
+
PostgresTaskRedriver,
|
|
1916
|
+
PostgresTaskRepository,
|
|
1917
|
+
PostgresTaskSubmitter,
|
|
1918
|
+
PostgresTaskSummaryReader,
|
|
1919
|
+
PostgresTaskTimelineReader,
|
|
1920
|
+
TASK_STATUSES,
|
|
1921
|
+
TASK_TIMELINE_EVENT_TYPES,
|
|
1922
|
+
TaskIdempotencyConflictError,
|
|
1923
|
+
TaskLeaseReconciler,
|
|
1924
|
+
TaskOutboxConflictError,
|
|
1925
|
+
TaskOutboxDispatcher,
|
|
1926
|
+
TaskQueueReconciler,
|
|
1927
|
+
UnsupportedDurableTaskKindError,
|
|
1928
|
+
assertTaskTransition,
|
|
1929
|
+
canTransitionTask,
|
|
1930
|
+
createTaskFingerprint,
|
|
1931
|
+
isTerminalTaskStatus,
|
|
1932
|
+
requiresTaskLease
|
|
1933
|
+
});
|