@nicnocquee/dataqueue 1.31.0 → 1.32.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/dist/index.cjs +2418 -1936
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +151 -16
- package/dist/index.d.ts +151 -16
- package/dist/index.js +2418 -1936
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/backend.ts +70 -4
- package/src/backends/postgres.ts +345 -29
- package/src/backends/redis-scripts.ts +197 -22
- package/src/backends/redis.test.ts +621 -0
- package/src/backends/redis.ts +400 -21
- package/src/index.ts +12 -29
- package/src/processor.ts +14 -93
- package/src/queue.test.ts +29 -0
- package/src/queue.ts +19 -251
- package/src/types.ts +28 -10
package/package.json
CHANGED
package/src/backend.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
CronScheduleRecord,
|
|
10
10
|
CronScheduleStatus,
|
|
11
11
|
EditCronScheduleOptions,
|
|
12
|
+
WaitpointRecord,
|
|
13
|
+
CreateTokenOptions,
|
|
12
14
|
} from './types.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -149,11 +151,11 @@ export interface QueueBackend {
|
|
|
149
151
|
updates: JobUpdates,
|
|
150
152
|
): Promise<number>;
|
|
151
153
|
|
|
152
|
-
/** Delete completed jobs older than N days. Returns count deleted. */
|
|
153
|
-
cleanupOldJobs(daysToKeep?: number): Promise<number>;
|
|
154
|
+
/** Delete completed jobs older than N days. Deletes in batches for scale safety. Returns count deleted. */
|
|
155
|
+
cleanupOldJobs(daysToKeep?: number, batchSize?: number): Promise<number>;
|
|
154
156
|
|
|
155
|
-
/** Delete job events older than N days. Returns count deleted. */
|
|
156
|
-
cleanupOldJobEvents(daysToKeep?: number): Promise<number>;
|
|
157
|
+
/** Delete job events older than N days. Deletes in batches for scale safety. Returns count deleted. */
|
|
158
|
+
cleanupOldJobEvents(daysToKeep?: number, batchSize?: number): Promise<number>;
|
|
157
159
|
|
|
158
160
|
/** Reclaim jobs stuck in 'processing' for too long. Returns count. */
|
|
159
161
|
reclaimStuckJobs(maxProcessingTimeMinutes?: number): Promise<number>;
|
|
@@ -222,6 +224,70 @@ export interface QueueBackend {
|
|
|
222
224
|
nextRunAt: Date | null,
|
|
223
225
|
): Promise<void>;
|
|
224
226
|
|
|
227
|
+
// ── Wait / step-data support ────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Transition a job from 'processing' to 'waiting' status.
|
|
231
|
+
* Persists step data so the handler can resume from where it left off.
|
|
232
|
+
*
|
|
233
|
+
* @param jobId - The job to pause.
|
|
234
|
+
* @param options - Wait configuration including optional waitUntil date, token ID, and step data.
|
|
235
|
+
*/
|
|
236
|
+
waitJob(
|
|
237
|
+
jobId: number,
|
|
238
|
+
options: {
|
|
239
|
+
waitUntil?: Date;
|
|
240
|
+
waitTokenId?: string;
|
|
241
|
+
stepData: Record<string, any>;
|
|
242
|
+
},
|
|
243
|
+
): Promise<void>;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Persist step data for a job. Called after each `ctx.run()` step completes
|
|
247
|
+
* to save intermediate progress. Best-effort: should not throw.
|
|
248
|
+
*
|
|
249
|
+
* @param jobId - The job to update.
|
|
250
|
+
* @param stepData - The step data to persist.
|
|
251
|
+
*/
|
|
252
|
+
updateStepData(jobId: number, stepData: Record<string, any>): Promise<void>;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Create a waitpoint token that can pause a job until an external signal completes it.
|
|
256
|
+
*
|
|
257
|
+
* @param jobId - The job ID to associate with the token (null if created outside a handler).
|
|
258
|
+
* @param options - Optional timeout string (e.g. '10m', '1h') and tags.
|
|
259
|
+
* @returns The created waitpoint with its unique ID.
|
|
260
|
+
*/
|
|
261
|
+
createWaitpoint(
|
|
262
|
+
jobId: number | null,
|
|
263
|
+
options?: CreateTokenOptions,
|
|
264
|
+
): Promise<{ id: string }>;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Complete a waitpoint token, optionally providing output data.
|
|
268
|
+
* Moves the associated job from 'waiting' back to 'pending' so it gets picked up.
|
|
269
|
+
*
|
|
270
|
+
* @param tokenId - The waitpoint token ID to complete.
|
|
271
|
+
* @param data - Optional data to pass to the waiting handler.
|
|
272
|
+
*/
|
|
273
|
+
completeWaitpoint(tokenId: string, data?: any): Promise<void>;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Retrieve a waitpoint token by its ID.
|
|
277
|
+
*
|
|
278
|
+
* @param tokenId - The waitpoint token ID to look up.
|
|
279
|
+
* @returns The waitpoint record, or null if not found.
|
|
280
|
+
*/
|
|
281
|
+
getWaitpoint(tokenId: string): Promise<WaitpointRecord | null>;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Expire timed-out waitpoint tokens and move their associated jobs back to 'pending'.
|
|
285
|
+
* Should be called periodically (e.g., alongside reclaimStuckJobs).
|
|
286
|
+
*
|
|
287
|
+
* @returns The number of tokens that were expired.
|
|
288
|
+
*/
|
|
289
|
+
expireTimedOutWaitpoints(): Promise<number>;
|
|
290
|
+
|
|
225
291
|
// ── Internal helpers ──────────────────────────────────────────────────
|
|
226
292
|
|
|
227
293
|
/** Set a pending reason for unpicked jobs of a given type. */
|
package/src/backends/postgres.ts
CHANGED
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
CronScheduleRecord,
|
|
11
11
|
CronScheduleStatus,
|
|
12
12
|
EditCronScheduleOptions,
|
|
13
|
+
WaitpointRecord,
|
|
14
|
+
CreateTokenOptions,
|
|
13
15
|
} from '../types.js';
|
|
16
|
+
import { randomUUID } from 'crypto';
|
|
14
17
|
import {
|
|
15
18
|
QueueBackend,
|
|
16
19
|
JobFilters,
|
|
@@ -19,6 +22,43 @@ import {
|
|
|
19
22
|
} from '../backend.js';
|
|
20
23
|
import { log } from '../log-context.js';
|
|
21
24
|
|
|
25
|
+
const MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/** Parse a timeout string like '10m', '1h', '24h', '7d' into milliseconds. */
|
|
28
|
+
function parseTimeoutString(timeout: string): number {
|
|
29
|
+
const match = timeout.match(/^(\d+)(s|m|h|d)$/);
|
|
30
|
+
if (!match) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Invalid timeout format: "${timeout}". Expected format like "10m", "1h", "24h", "7d".`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const value = parseInt(match[1], 10);
|
|
36
|
+
const unit = match[2];
|
|
37
|
+
let ms: number;
|
|
38
|
+
switch (unit) {
|
|
39
|
+
case 's':
|
|
40
|
+
ms = value * 1000;
|
|
41
|
+
break;
|
|
42
|
+
case 'm':
|
|
43
|
+
ms = value * 60 * 1000;
|
|
44
|
+
break;
|
|
45
|
+
case 'h':
|
|
46
|
+
ms = value * 60 * 60 * 1000;
|
|
47
|
+
break;
|
|
48
|
+
case 'd':
|
|
49
|
+
ms = value * 24 * 60 * 60 * 1000;
|
|
50
|
+
break;
|
|
51
|
+
default:
|
|
52
|
+
throw new Error(`Unknown timeout unit: "${unit}"`);
|
|
53
|
+
}
|
|
54
|
+
if (!Number.isFinite(ms) || ms > MAX_TIMEOUT_MS) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Timeout value "${timeout}" is too large. Maximum allowed is 365 days.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return ms;
|
|
60
|
+
}
|
|
61
|
+
|
|
22
62
|
export class PostgresBackend implements QueueBackend {
|
|
23
63
|
constructor(private pool: Pool) {}
|
|
24
64
|
|
|
@@ -987,46 +1027,93 @@ export class PostgresBackend implements QueueBackend {
|
|
|
987
1027
|
}
|
|
988
1028
|
}
|
|
989
1029
|
|
|
990
|
-
|
|
991
|
-
|
|
1030
|
+
/**
|
|
1031
|
+
* Delete completed jobs older than the given number of days.
|
|
1032
|
+
* Deletes in batches of 1000 to avoid long-running transactions
|
|
1033
|
+
* and excessive WAL bloat at scale.
|
|
1034
|
+
*
|
|
1035
|
+
* @param daysToKeep - Number of days to retain completed jobs (default 30).
|
|
1036
|
+
* @param batchSize - Number of rows to delete per batch (default 1000).
|
|
1037
|
+
* @returns Total number of deleted jobs.
|
|
1038
|
+
*/
|
|
1039
|
+
async cleanupOldJobs(daysToKeep = 30, batchSize = 1000): Promise<number> {
|
|
1040
|
+
let totalDeleted = 0;
|
|
1041
|
+
|
|
992
1042
|
try {
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1043
|
+
let deletedInBatch: number;
|
|
1044
|
+
do {
|
|
1045
|
+
const client = await this.pool.connect();
|
|
1046
|
+
try {
|
|
1047
|
+
const result = await client.query(
|
|
1048
|
+
`
|
|
1049
|
+
DELETE FROM job_queue
|
|
1050
|
+
WHERE id IN (
|
|
1051
|
+
SELECT id FROM job_queue
|
|
1052
|
+
WHERE status = 'completed'
|
|
1053
|
+
AND updated_at < NOW() - INTERVAL '1 day' * $1::int
|
|
1054
|
+
LIMIT $2
|
|
1055
|
+
)
|
|
1056
|
+
`,
|
|
1057
|
+
[daysToKeep, batchSize],
|
|
1058
|
+
);
|
|
1059
|
+
deletedInBatch = result.rowCount || 0;
|
|
1060
|
+
totalDeleted += deletedInBatch;
|
|
1061
|
+
} finally {
|
|
1062
|
+
client.release();
|
|
1063
|
+
}
|
|
1064
|
+
} while (deletedInBatch === batchSize);
|
|
1065
|
+
|
|
1066
|
+
log(`Deleted ${totalDeleted} old jobs`);
|
|
1067
|
+
return totalDeleted;
|
|
1004
1068
|
} catch (error) {
|
|
1005
1069
|
log(`Error cleaning up old jobs: ${error}`);
|
|
1006
1070
|
throw error;
|
|
1007
|
-
} finally {
|
|
1008
|
-
client.release();
|
|
1009
1071
|
}
|
|
1010
1072
|
}
|
|
1011
1073
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1074
|
+
/**
|
|
1075
|
+
* Delete job events older than the given number of days.
|
|
1076
|
+
* Deletes in batches of 1000 to avoid long-running transactions
|
|
1077
|
+
* and excessive WAL bloat at scale.
|
|
1078
|
+
*
|
|
1079
|
+
* @param daysToKeep - Number of days to retain events (default 30).
|
|
1080
|
+
* @param batchSize - Number of rows to delete per batch (default 1000).
|
|
1081
|
+
* @returns Total number of deleted events.
|
|
1082
|
+
*/
|
|
1083
|
+
async cleanupOldJobEvents(
|
|
1084
|
+
daysToKeep = 30,
|
|
1085
|
+
batchSize = 1000,
|
|
1086
|
+
): Promise<number> {
|
|
1087
|
+
let totalDeleted = 0;
|
|
1088
|
+
|
|
1014
1089
|
try {
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1090
|
+
let deletedInBatch: number;
|
|
1091
|
+
do {
|
|
1092
|
+
const client = await this.pool.connect();
|
|
1093
|
+
try {
|
|
1094
|
+
const result = await client.query(
|
|
1095
|
+
`
|
|
1096
|
+
DELETE FROM job_events
|
|
1097
|
+
WHERE id IN (
|
|
1098
|
+
SELECT id FROM job_events
|
|
1099
|
+
WHERE created_at < NOW() - INTERVAL '1 day' * $1::int
|
|
1100
|
+
LIMIT $2
|
|
1101
|
+
)
|
|
1102
|
+
`,
|
|
1103
|
+
[daysToKeep, batchSize],
|
|
1104
|
+
);
|
|
1105
|
+
deletedInBatch = result.rowCount || 0;
|
|
1106
|
+
totalDeleted += deletedInBatch;
|
|
1107
|
+
} finally {
|
|
1108
|
+
client.release();
|
|
1109
|
+
}
|
|
1110
|
+
} while (deletedInBatch === batchSize);
|
|
1111
|
+
|
|
1112
|
+
log(`Deleted ${totalDeleted} old job events`);
|
|
1113
|
+
return totalDeleted;
|
|
1025
1114
|
} catch (error) {
|
|
1026
1115
|
log(`Error cleaning up old job events: ${error}`);
|
|
1027
1116
|
throw error;
|
|
1028
|
-
} finally {
|
|
1029
|
-
client.release();
|
|
1030
1117
|
}
|
|
1031
1118
|
}
|
|
1032
1119
|
|
|
@@ -1411,6 +1498,235 @@ export class PostgresBackend implements QueueBackend {
|
|
|
1411
1498
|
}
|
|
1412
1499
|
}
|
|
1413
1500
|
|
|
1501
|
+
// ── Wait / step-data support ────────────────────────────────────────
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Transition a job from 'processing' to 'waiting' status.
|
|
1505
|
+
* Persists step data so the handler can resume from where it left off.
|
|
1506
|
+
*
|
|
1507
|
+
* @param jobId - The job to pause.
|
|
1508
|
+
* @param options - Wait configuration including optional waitUntil date, token ID, and step data.
|
|
1509
|
+
*/
|
|
1510
|
+
async waitJob(
|
|
1511
|
+
jobId: number,
|
|
1512
|
+
options: {
|
|
1513
|
+
waitUntil?: Date;
|
|
1514
|
+
waitTokenId?: string;
|
|
1515
|
+
stepData: Record<string, any>;
|
|
1516
|
+
},
|
|
1517
|
+
): Promise<void> {
|
|
1518
|
+
const client = await this.pool.connect();
|
|
1519
|
+
try {
|
|
1520
|
+
const result = await client.query(
|
|
1521
|
+
`
|
|
1522
|
+
UPDATE job_queue
|
|
1523
|
+
SET status = 'waiting',
|
|
1524
|
+
wait_until = $2,
|
|
1525
|
+
wait_token_id = $3,
|
|
1526
|
+
step_data = $4,
|
|
1527
|
+
locked_at = NULL,
|
|
1528
|
+
locked_by = NULL,
|
|
1529
|
+
updated_at = NOW()
|
|
1530
|
+
WHERE id = $1 AND status = 'processing'
|
|
1531
|
+
`,
|
|
1532
|
+
[
|
|
1533
|
+
jobId,
|
|
1534
|
+
options.waitUntil ?? null,
|
|
1535
|
+
options.waitTokenId ?? null,
|
|
1536
|
+
JSON.stringify(options.stepData),
|
|
1537
|
+
],
|
|
1538
|
+
);
|
|
1539
|
+
if (result.rowCount === 0) {
|
|
1540
|
+
log(
|
|
1541
|
+
`Job ${jobId} could not be set to waiting (may have been reclaimed or is no longer processing)`,
|
|
1542
|
+
);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
await this.recordJobEvent(jobId, JobEventType.Waiting, {
|
|
1546
|
+
waitUntil: options.waitUntil?.toISOString() ?? null,
|
|
1547
|
+
waitTokenId: options.waitTokenId ?? null,
|
|
1548
|
+
});
|
|
1549
|
+
log(`Job ${jobId} set to waiting`);
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
log(`Error setting job ${jobId} to waiting: ${error}`);
|
|
1552
|
+
throw error;
|
|
1553
|
+
} finally {
|
|
1554
|
+
client.release();
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Persist step data for a job. Called after each ctx.run() step completes.
|
|
1560
|
+
* Best-effort: does not throw to avoid killing the running handler.
|
|
1561
|
+
*
|
|
1562
|
+
* @param jobId - The job to update.
|
|
1563
|
+
* @param stepData - The step data to persist.
|
|
1564
|
+
*/
|
|
1565
|
+
async updateStepData(
|
|
1566
|
+
jobId: number,
|
|
1567
|
+
stepData: Record<string, any>,
|
|
1568
|
+
): Promise<void> {
|
|
1569
|
+
const client = await this.pool.connect();
|
|
1570
|
+
try {
|
|
1571
|
+
await client.query(
|
|
1572
|
+
`UPDATE job_queue SET step_data = $2, updated_at = NOW() WHERE id = $1`,
|
|
1573
|
+
[jobId, JSON.stringify(stepData)],
|
|
1574
|
+
);
|
|
1575
|
+
} catch (error) {
|
|
1576
|
+
log(`Error updating step_data for job ${jobId}: ${error}`);
|
|
1577
|
+
} finally {
|
|
1578
|
+
client.release();
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Create a waitpoint token in the database.
|
|
1584
|
+
*
|
|
1585
|
+
* @param jobId - The job ID to associate with the token (null if created outside a handler).
|
|
1586
|
+
* @param options - Optional timeout string (e.g. '10m', '1h') and tags.
|
|
1587
|
+
* @returns The created waitpoint with its unique ID.
|
|
1588
|
+
*/
|
|
1589
|
+
async createWaitpoint(
|
|
1590
|
+
jobId: number | null,
|
|
1591
|
+
options?: CreateTokenOptions,
|
|
1592
|
+
): Promise<{ id: string }> {
|
|
1593
|
+
const client = await this.pool.connect();
|
|
1594
|
+
try {
|
|
1595
|
+
const id = `wp_${randomUUID()}`;
|
|
1596
|
+
let timeoutAt: Date | null = null;
|
|
1597
|
+
|
|
1598
|
+
if (options?.timeout) {
|
|
1599
|
+
const ms = parseTimeoutString(options.timeout);
|
|
1600
|
+
timeoutAt = new Date(Date.now() + ms);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
await client.query(
|
|
1604
|
+
`INSERT INTO waitpoints (id, job_id, status, timeout_at, tags) VALUES ($1, $2, 'waiting', $3, $4)`,
|
|
1605
|
+
[id, jobId, timeoutAt, options?.tags ?? null],
|
|
1606
|
+
);
|
|
1607
|
+
|
|
1608
|
+
log(`Created waitpoint ${id} for job ${jobId}`);
|
|
1609
|
+
return { id };
|
|
1610
|
+
} catch (error) {
|
|
1611
|
+
log(`Error creating waitpoint: ${error}`);
|
|
1612
|
+
throw error;
|
|
1613
|
+
} finally {
|
|
1614
|
+
client.release();
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Complete a waitpoint token and move the associated job back to 'pending'.
|
|
1620
|
+
*
|
|
1621
|
+
* @param tokenId - The waitpoint token ID to complete.
|
|
1622
|
+
* @param data - Optional data to pass to the waiting handler.
|
|
1623
|
+
*/
|
|
1624
|
+
async completeWaitpoint(tokenId: string, data?: any): Promise<void> {
|
|
1625
|
+
const client = await this.pool.connect();
|
|
1626
|
+
try {
|
|
1627
|
+
await client.query('BEGIN');
|
|
1628
|
+
|
|
1629
|
+
const wpResult = await client.query(
|
|
1630
|
+
`UPDATE waitpoints SET status = 'completed', output = $2, completed_at = NOW()
|
|
1631
|
+
WHERE id = $1 AND status = 'waiting'
|
|
1632
|
+
RETURNING job_id`,
|
|
1633
|
+
[tokenId, data != null ? JSON.stringify(data) : null],
|
|
1634
|
+
);
|
|
1635
|
+
|
|
1636
|
+
if (wpResult.rows.length === 0) {
|
|
1637
|
+
await client.query('ROLLBACK');
|
|
1638
|
+
log(`Waitpoint ${tokenId} not found or already completed`);
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const jobId = wpResult.rows[0].job_id;
|
|
1643
|
+
|
|
1644
|
+
if (jobId != null) {
|
|
1645
|
+
await client.query(
|
|
1646
|
+
`UPDATE job_queue
|
|
1647
|
+
SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
|
|
1648
|
+
WHERE id = $1 AND status = 'waiting'`,
|
|
1649
|
+
[jobId],
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
await client.query('COMMIT');
|
|
1654
|
+
log(`Completed waitpoint ${tokenId} for job ${jobId}`);
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
await client.query('ROLLBACK');
|
|
1657
|
+
log(`Error completing waitpoint ${tokenId}: ${error}`);
|
|
1658
|
+
throw error;
|
|
1659
|
+
} finally {
|
|
1660
|
+
client.release();
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Retrieve a waitpoint token by its ID.
|
|
1666
|
+
*
|
|
1667
|
+
* @param tokenId - The waitpoint token ID to look up.
|
|
1668
|
+
* @returns The waitpoint record, or null if not found.
|
|
1669
|
+
*/
|
|
1670
|
+
async getWaitpoint(tokenId: string): Promise<WaitpointRecord | null> {
|
|
1671
|
+
const client = await this.pool.connect();
|
|
1672
|
+
try {
|
|
1673
|
+
const result = await client.query(
|
|
1674
|
+
`SELECT id, job_id AS "jobId", status, output, timeout_at AS "timeoutAt", created_at AS "createdAt", completed_at AS "completedAt", tags FROM waitpoints WHERE id = $1`,
|
|
1675
|
+
[tokenId],
|
|
1676
|
+
);
|
|
1677
|
+
if (result.rows.length === 0) return null;
|
|
1678
|
+
return result.rows[0] as WaitpointRecord;
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
log(`Error getting waitpoint ${tokenId}: ${error}`);
|
|
1681
|
+
throw error;
|
|
1682
|
+
} finally {
|
|
1683
|
+
client.release();
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* Expire timed-out waitpoint tokens and move their associated jobs back to 'pending'.
|
|
1689
|
+
*
|
|
1690
|
+
* @returns The number of tokens that were expired.
|
|
1691
|
+
*/
|
|
1692
|
+
async expireTimedOutWaitpoints(): Promise<number> {
|
|
1693
|
+
const client = await this.pool.connect();
|
|
1694
|
+
try {
|
|
1695
|
+
await client.query('BEGIN');
|
|
1696
|
+
|
|
1697
|
+
const result = await client.query(
|
|
1698
|
+
`UPDATE waitpoints
|
|
1699
|
+
SET status = 'timed_out'
|
|
1700
|
+
WHERE status = 'waiting' AND timeout_at IS NOT NULL AND timeout_at <= NOW()
|
|
1701
|
+
RETURNING id, job_id`,
|
|
1702
|
+
);
|
|
1703
|
+
|
|
1704
|
+
for (const row of result.rows) {
|
|
1705
|
+
if (row.job_id != null) {
|
|
1706
|
+
await client.query(
|
|
1707
|
+
`UPDATE job_queue
|
|
1708
|
+
SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
|
|
1709
|
+
WHERE id = $1 AND status = 'waiting'`,
|
|
1710
|
+
[row.job_id],
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
await client.query('COMMIT');
|
|
1716
|
+
const count = result.rowCount || 0;
|
|
1717
|
+
if (count > 0) {
|
|
1718
|
+
log(`Expired ${count} timed-out waitpoints`);
|
|
1719
|
+
}
|
|
1720
|
+
return count;
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
await client.query('ROLLBACK');
|
|
1723
|
+
log(`Error expiring timed-out waitpoints: ${error}`);
|
|
1724
|
+
throw error;
|
|
1725
|
+
} finally {
|
|
1726
|
+
client.release();
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1414
1730
|
// ── Internal helpers ──────────────────────────────────────────────────
|
|
1415
1731
|
|
|
1416
1732
|
async setPendingReasonForUnpickedJobs(
|