@ultimat3/jobs 22.14.0 → 23.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/CLAUDE.md +129 -87
- package/README.md +329 -19
- package/package.json +6 -6
- package/src/concurrency.ts +144 -0
- package/src/counters.ts +105 -0
- package/src/describe.ts +29 -0
- package/src/driver-memory-operator.ts +297 -0
- package/src/driver-memory.ts +79 -37
- package/src/driver-nats.ts +2 -2
- package/src/driver-pg-ddl.ts +71 -0
- package/src/driver-pg-jobs-sql.ts +71 -8
- package/src/driver-pg-operator-sql.ts +303 -0
- package/src/driver-pg-operator.ts +300 -0
- package/src/driver-pg-outbox-sql.ts +110 -0
- package/src/driver-pg-rows.ts +31 -0
- package/src/driver-pg-settle-sql.ts +71 -0
- package/src/driver-pg-sql.ts +80 -129
- package/src/driver-pg-wake-sql.ts +76 -0
- package/src/driver-pg.ts +74 -34
- package/src/driver-redis.ts +2 -2
- package/src/driver.ts +96 -34
- package/src/enqueue-signal.ts +50 -0
- package/src/errors-concurrency.ts +91 -0
- package/src/errors-operator.ts +94 -0
- package/src/errors.ts +13 -17
- package/src/events-pg.ts +38 -28
- package/src/events.ts +25 -0
- package/src/execute.ts +160 -81
- package/src/export.ts +23 -4
- package/src/heartbeat.ts +9 -3
- package/src/idle-backoff.ts +74 -0
- package/src/index.ts +105 -12
- package/src/inspect-operator.ts +49 -0
- package/src/inspect.ts +42 -2
- package/src/introspection.ts +262 -0
- package/src/job.ts +78 -15
- package/src/leases.ts +49 -9
- package/src/metrics.ts +6 -1
- package/src/outbox-pg.ts +3 -0
- package/src/outbox-relay.ts +114 -39
- package/src/outbox.ts +29 -6
- package/src/progress.ts +78 -0
- package/src/queue-wake.ts +220 -0
- package/src/redact-input.ts +26 -0
- package/src/renewal-timer.ts +28 -10
- package/src/retry-classification.ts +13 -1
- package/src/retry.ts +10 -2
- package/src/run-deadline.ts +57 -0
- package/src/scheduler-leader.ts +26 -0
- package/src/scheduler-occurrences.ts +76 -0
- package/src/scheduler-pg.ts +55 -2
- package/src/scheduler-state.ts +66 -0
- package/src/scheduler.ts +143 -103
- package/src/settled.ts +161 -0
- package/src/worker-admit.ts +163 -0
- package/src/worker-fleet-slots.ts +107 -38
- package/src/worker-hand-back.ts +6 -1
- package/src/worker-key-busy.ts +79 -0
- package/src/worker-loop.ts +145 -0
- package/src/worker-registry.ts +103 -0
- package/src/worker-run.ts +9 -0
- package/src/worker-types.ts +23 -0
- package/src/worker.ts +103 -134
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// The pg driver's operator surface: every `JobIntrospection` member the queue grew for an
|
|
2
|
+
// operator, each ONE statement from `driver-pg-operator-sql.ts`. Apart from `driver-pg.ts` because
|
|
3
|
+
// that file is the claim/settle driver and sits near its size ceiling; this is the read-and-repair
|
|
4
|
+
// half a dashboard and `x jobs` call.
|
|
5
|
+
|
|
6
|
+
import { assert, finiteOption } from '@ultimat3/core';
|
|
7
|
+
import { REQUEUEABLE_STATES } from './driver';
|
|
8
|
+
import type { PgExecutor } from './driver-pg';
|
|
9
|
+
import { SQL_JOB_LIST, SQL_JOB_LIST_BEFORE } from './driver-pg-jobs-sql';
|
|
10
|
+
import {
|
|
11
|
+
SQL_COUNTER_DROP,
|
|
12
|
+
SQL_COUNTER_FOLD,
|
|
13
|
+
SQL_COUNTER_TOTALS,
|
|
14
|
+
SQL_COUNTERS,
|
|
15
|
+
SQL_JOB_PROGRESS,
|
|
16
|
+
SQL_JOB_PROMOTE,
|
|
17
|
+
SQL_JOB_PROMOTE_MANY,
|
|
18
|
+
SQL_JOB_REMOVE,
|
|
19
|
+
SQL_JOB_REMOVE_MANY,
|
|
20
|
+
SQL_JOB_REQUEUE_MANY,
|
|
21
|
+
SQL_PAUSE,
|
|
22
|
+
SQL_PAUSED,
|
|
23
|
+
SQL_RESUME,
|
|
24
|
+
SQL_TASK_FIRE_RECORD,
|
|
25
|
+
SQL_TASK_FIRES,
|
|
26
|
+
SQL_WORKER_ANNOUNCE,
|
|
27
|
+
SQL_WORKER_FORGET,
|
|
28
|
+
SQL_WORKERS,
|
|
29
|
+
} from './driver-pg-operator-sql';
|
|
30
|
+
import type { JobRow } from './driver-pg-rows';
|
|
31
|
+
import { num, toJobRecord } from './driver-pg-rows';
|
|
32
|
+
import { JOBS_WAKE_CHANNEL, SQL_WAKE } from './driver-pg-wake-sql';
|
|
33
|
+
import { JobNotRemovableError } from './errors-operator';
|
|
34
|
+
import type {
|
|
35
|
+
BulkFilter,
|
|
36
|
+
BulkResult,
|
|
37
|
+
CounterBucketMs,
|
|
38
|
+
JobIntrospection,
|
|
39
|
+
PausedName,
|
|
40
|
+
} from './introspection';
|
|
41
|
+
import {
|
|
42
|
+
COUNTER_TIERS,
|
|
43
|
+
MAX_BULK_ROWS,
|
|
44
|
+
MAX_TASK_FIRES,
|
|
45
|
+
MAX_WORKER_IN_FLIGHT,
|
|
46
|
+
PROMOTABLE_STATES,
|
|
47
|
+
pageLimit,
|
|
48
|
+
parseJobCursor,
|
|
49
|
+
} from './introspection';
|
|
50
|
+
|
|
51
|
+
export type PgOperatorMembers = Omit<
|
|
52
|
+
JobIntrospection,
|
|
53
|
+
'job' | 'deadLetters' | 'requeue' | 'cancel'
|
|
54
|
+
>;
|
|
55
|
+
|
|
56
|
+
type Count = number | string;
|
|
57
|
+
|
|
58
|
+
interface CounterRow {
|
|
59
|
+
readonly job: string;
|
|
60
|
+
readonly bucket_ms?: Count;
|
|
61
|
+
readonly bucket_start?: Count;
|
|
62
|
+
readonly done: Count;
|
|
63
|
+
readonly retried: Count;
|
|
64
|
+
readonly failed: Count;
|
|
65
|
+
readonly dead: Count;
|
|
66
|
+
readonly duration_ms: Count;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const counts = (row: CounterRow) => ({
|
|
70
|
+
job: row.job,
|
|
71
|
+
done: num(row.done),
|
|
72
|
+
retried: num(row.retried),
|
|
73
|
+
failed: num(row.failed),
|
|
74
|
+
dead: num(row.dead),
|
|
75
|
+
durationMs: num(row.duration_ms),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** A `jsonb` list of strings, from a client with a type map or without one. */
|
|
79
|
+
function stringList(raw: unknown): readonly string[] {
|
|
80
|
+
let value: unknown = raw;
|
|
81
|
+
if (typeof raw === 'string') {
|
|
82
|
+
try {
|
|
83
|
+
value = JSON.parse(raw) as unknown;
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function pgOperator(
|
|
92
|
+
exec: () => PgExecutor,
|
|
93
|
+
job: JobIntrospection['job'],
|
|
94
|
+
): PgOperatorMembers {
|
|
95
|
+
const bulk = async (sql: string, filter: BulkFilter): Promise<BulkResult> => {
|
|
96
|
+
const rows = await exec().query<{ affected: Count; matching: Count }>(sql, [
|
|
97
|
+
filter.state,
|
|
98
|
+
filter.queue ?? null,
|
|
99
|
+
filter.name ?? null,
|
|
100
|
+
MAX_BULK_ROWS,
|
|
101
|
+
filter.tenantId ?? null,
|
|
102
|
+
]);
|
|
103
|
+
const affected = num(rows[0]?.affected);
|
|
104
|
+
return { affected, remaining: Math.max(0, num(rows[0]?.matching) - affected) };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const paused = async (kind: 'queue' | 'task'): Promise<readonly PausedName[]> => {
|
|
108
|
+
const rows = await exec().query<{ name: string; paused_at: Count }>(SQL_PAUSED, [kind]);
|
|
109
|
+
return rows.map((row) => ({ name: row.name, pausedAt: num(row.paused_at) }));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* An operator made rows claimable — on `queue`, or on any when none is named. Announced, so
|
|
114
|
+
* `x jobs retry` is a job starting now rather than at the end of an idle worker's wait.
|
|
115
|
+
*/
|
|
116
|
+
const wake = async (queue?: string): Promise<void> => {
|
|
117
|
+
await exec().query(SQL_WAKE, [JOBS_WAKE_CHANNEL, queue ?? '']);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
async list(filter = {}) {
|
|
122
|
+
assert(
|
|
123
|
+
filter.after === undefined || filter.before === undefined,
|
|
124
|
+
'list was asked for a page both after and before a cursor',
|
|
125
|
+
'pass one of after: jobCursor(lastRow) or before: jobCursor(firstRow)',
|
|
126
|
+
);
|
|
127
|
+
const limit = pageLimit('the pg driver list', filter.limit);
|
|
128
|
+
const raw = filter.before ?? filter.after;
|
|
129
|
+
const cursor = raw === undefined ? undefined : parseJobCursor(raw, 'the pg driver list');
|
|
130
|
+
const rows = await exec().query<JobRow>(
|
|
131
|
+
filter.before === undefined ? SQL_JOB_LIST : SQL_JOB_LIST_BEFORE,
|
|
132
|
+
[
|
|
133
|
+
filter.queue ?? null,
|
|
134
|
+
filter.name ?? null,
|
|
135
|
+
filter.state ?? null,
|
|
136
|
+
limit,
|
|
137
|
+
filter.idPrefix ?? null,
|
|
138
|
+
filter.createdFrom ?? null,
|
|
139
|
+
filter.createdTo ?? null,
|
|
140
|
+
cursor?.createdAt ?? null,
|
|
141
|
+
cursor?.id ?? null,
|
|
142
|
+
filter.tenantId ?? null,
|
|
143
|
+
],
|
|
144
|
+
);
|
|
145
|
+
return rows.map(toJobRecord);
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
async remove(jobId) {
|
|
149
|
+
const current = await job(jobId);
|
|
150
|
+
if (current === undefined) return undefined;
|
|
151
|
+
if (current.state === 'running') throw new JobNotRemovableError({ jobId });
|
|
152
|
+
const gone = await exec().query<{ run_id: string }>(SQL_JOB_REMOVE, [jobId]);
|
|
153
|
+
// Read as queued a moment ago and claimed since: the claim won, and the row is a worker's.
|
|
154
|
+
if (gone.length === 0) throw new JobNotRemovableError({ jobId });
|
|
155
|
+
return current;
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// `async`, so a refused state REJECTS — the memory driver's answer, never a synchronous throw.
|
|
159
|
+
async requeueMany(filter) {
|
|
160
|
+
assert(
|
|
161
|
+
REQUEUEABLE_STATES.has(filter.state),
|
|
162
|
+
`requeueMany was asked for ${filter.state} jobs, and only a finished job can be requeued`,
|
|
163
|
+
"pass state: 'dead', 'failed', 'cancelled' or 'done' to requeueMany",
|
|
164
|
+
);
|
|
165
|
+
const result = await bulk(SQL_JOB_REQUEUE_MANY, filter);
|
|
166
|
+
if (result.affected > 0) await wake(filter.queue);
|
|
167
|
+
return result;
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// `async`, so a refused state REJECTS, as `requeueMany` does.
|
|
171
|
+
async promoteMany(filter) {
|
|
172
|
+
assert(
|
|
173
|
+
PROMOTABLE_STATES.has(filter.state),
|
|
174
|
+
`promoteMany was asked for ${filter.state} jobs, and only a job waiting on its run time can be promoted`,
|
|
175
|
+
"pass state: 'delayed' or 'ready' to promoteMany",
|
|
176
|
+
);
|
|
177
|
+
const rows = await exec().query<{ affected: Count; matching: Count }>(SQL_JOB_PROMOTE_MANY, [
|
|
178
|
+
filter.state,
|
|
179
|
+
filter.queue ?? null,
|
|
180
|
+
filter.name ?? null,
|
|
181
|
+
MAX_BULK_ROWS,
|
|
182
|
+
filter.tenantId ?? null,
|
|
183
|
+
]);
|
|
184
|
+
return { affected: num(rows[0]?.affected), remaining: num(rows[0]?.matching) };
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async removeMany(filter) {
|
|
188
|
+
if (filter.state === 'running') throw new JobNotRemovableError({});
|
|
189
|
+
return bulk(SQL_JOB_REMOVE_MANY, filter);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async promote(jobId) {
|
|
193
|
+
const rows = await exec().query<JobRow>(SQL_JOB_PROMOTE, [jobId]);
|
|
194
|
+
const row = rows[0];
|
|
195
|
+
return row === undefined ? undefined : toJobRecord(row);
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async pauseQueue(queue) {
|
|
199
|
+
await exec().query(SQL_PAUSE, ['queue', queue]);
|
|
200
|
+
},
|
|
201
|
+
async resumeQueue(queue) {
|
|
202
|
+
await exec().query(SQL_RESUME, ['queue', queue]);
|
|
203
|
+
await wake(queue);
|
|
204
|
+
},
|
|
205
|
+
pausedQueues: () => paused('queue'),
|
|
206
|
+
async pauseTask(task) {
|
|
207
|
+
await exec().query(SQL_PAUSE, ['task', task]);
|
|
208
|
+
},
|
|
209
|
+
async resumeTask(task) {
|
|
210
|
+
await exec().query(SQL_RESUME, ['task', task]);
|
|
211
|
+
},
|
|
212
|
+
pausedTasks: () => paused('task'),
|
|
213
|
+
|
|
214
|
+
async recordTaskFire({ task, occurrenceMs }) {
|
|
215
|
+
await exec().query(SQL_TASK_FIRE_RECORD, [task, occurrenceMs]);
|
|
216
|
+
},
|
|
217
|
+
async taskFires() {
|
|
218
|
+
const rows = await exec().query<{ task_name: string; occurrence: Count; fired_at: Count }>(
|
|
219
|
+
SQL_TASK_FIRES,
|
|
220
|
+
[MAX_TASK_FIRES],
|
|
221
|
+
);
|
|
222
|
+
return rows.map((row) => ({
|
|
223
|
+
task: row.task_name,
|
|
224
|
+
occurrenceMs: num(row.occurrence),
|
|
225
|
+
firedAt: num(row.fired_at),
|
|
226
|
+
}));
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
async announceWorker(worker, ttlMs) {
|
|
230
|
+
await exec().query(SQL_WORKER_ANNOUNCE, [
|
|
231
|
+
worker.id,
|
|
232
|
+
worker.host,
|
|
233
|
+
JSON.stringify(worker.queues),
|
|
234
|
+
worker.concurrency,
|
|
235
|
+
JSON.stringify(worker.inFlight.slice(0, MAX_WORKER_IN_FLIGHT)),
|
|
236
|
+
worker.startedAt,
|
|
237
|
+
finiteOption('the pg worker registry', 'ttlMs', ttlMs),
|
|
238
|
+
]);
|
|
239
|
+
},
|
|
240
|
+
async forgetWorker(workerId) {
|
|
241
|
+
await exec().query(SQL_WORKER_FORGET, [workerId]);
|
|
242
|
+
},
|
|
243
|
+
async workers() {
|
|
244
|
+
const rows = await exec().query<{
|
|
245
|
+
id: string;
|
|
246
|
+
host: string;
|
|
247
|
+
queues: unknown;
|
|
248
|
+
concurrency: Count;
|
|
249
|
+
in_flight: unknown;
|
|
250
|
+
started_at: Count;
|
|
251
|
+
heartbeat_at: Count;
|
|
252
|
+
}>(SQL_WORKERS, []);
|
|
253
|
+
return rows.map((row) => ({
|
|
254
|
+
id: row.id,
|
|
255
|
+
host: row.host,
|
|
256
|
+
startedAt: num(row.started_at),
|
|
257
|
+
queues: stringList(row.queues),
|
|
258
|
+
concurrency: num(row.concurrency),
|
|
259
|
+
inFlight: stringList(row.in_flight),
|
|
260
|
+
heartbeatAt: num(row.heartbeat_at),
|
|
261
|
+
}));
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
async recordProgress(jobId, by, progress) {
|
|
265
|
+
await exec().query(SQL_JOB_PROGRESS, [
|
|
266
|
+
jobId,
|
|
267
|
+
by.workerId,
|
|
268
|
+
JSON.stringify(progress),
|
|
269
|
+
by.claim,
|
|
270
|
+
]);
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
async counters(query) {
|
|
274
|
+
const rows = await exec().query<CounterRow>(SQL_COUNTERS, [query.job, query.sinceMs]);
|
|
275
|
+
return rows.map((row) => ({
|
|
276
|
+
...counts(row),
|
|
277
|
+
bucketStart: num(row.bucket_start),
|
|
278
|
+
bucketMs: num(row.bucket_ms) as CounterBucketMs,
|
|
279
|
+
}));
|
|
280
|
+
},
|
|
281
|
+
async counterTotals(sinceMs) {
|
|
282
|
+
const rows = await exec().query<CounterRow>(SQL_COUNTER_TOTALS, [sinceMs]);
|
|
283
|
+
return rows.map(counts);
|
|
284
|
+
},
|
|
285
|
+
async rollupCounters() {
|
|
286
|
+
let moved = 0;
|
|
287
|
+
for (const [index, tier] of COUNTER_TIERS.entries()) {
|
|
288
|
+
const next = COUNTER_TIERS[index + 1];
|
|
289
|
+
const rows = await exec().query<{ moved: Count }>(
|
|
290
|
+
next === undefined ? SQL_COUNTER_DROP : SQL_COUNTER_FOLD,
|
|
291
|
+
next === undefined
|
|
292
|
+
? [tier.bucketMs, tier.keepMs]
|
|
293
|
+
: [tier.bucketMs, tier.keepMs, next.bucketMs],
|
|
294
|
+
);
|
|
295
|
+
moved += num(rows[0]?.moved);
|
|
296
|
+
}
|
|
297
|
+
return moved;
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Every statement the `x_outbox` store runs. Split off `driver-pg-sql.ts` at its size ceiling and
|
|
2
|
+
// re-exported from it, the way the settle and whole-row statements are.
|
|
3
|
+
|
|
4
|
+
import { NOTIFY_STAGED } from './driver-pg-wake-sql';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The stage, and its wake. One statement on the CALLER's connection, so the notification is in
|
|
8
|
+
* the business transaction with the row: delivered when it commits, never when it rolls back.
|
|
9
|
+
*/
|
|
10
|
+
export const SQL_OUTBOX_STAGE = `
|
|
11
|
+
with staged as (
|
|
12
|
+
insert into x_outbox
|
|
13
|
+
(id, job, queue, input, idempotency_key, max_attempts, run_at, staged_at, tenant_id,
|
|
14
|
+
traceparent, enqueued_by, run_id)
|
|
15
|
+
values ($1, $2, $3, $4::jsonb, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($8 / 1000.0),
|
|
16
|
+
$9, $10, $11, $12)
|
|
17
|
+
returning id
|
|
18
|
+
)
|
|
19
|
+
select id,${NOTIFY_STAGED}::text as woke
|
|
20
|
+
from staged
|
|
21
|
+
`.trim();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The claim, and it has to be ONE statement. `for update skip locked` in a bare select holds its
|
|
25
|
+
* row locks only until that statement ends — under autocommit, before `claim()` even resolves — so
|
|
26
|
+
* two relays polling 200ms apart read the same unpublished rows and both hand them to `enqueue`.
|
|
27
|
+
* `SQL_ENQUEUE` collapses the repeat only while the first job is still LIVE: its conflict target
|
|
28
|
+
* is a partial index over the live states, so a second publish landing after that job reached a
|
|
29
|
+
* terminal state inserts a second row and the handler runs again. (The mechanism is Postgres
|
|
30
|
+
* semantics; how often the two orderings line up in a deployment was never measured.)
|
|
31
|
+
*
|
|
32
|
+
* So the lock and the claim commit together, the CTE shape `SQL_CLAIM` already uses, and
|
|
33
|
+
* `claimed_at` is a LEASE: `$2` is the window after which a row a dead relay was holding is
|
|
34
|
+
* claimable again, because a claim nothing can expire strands its rows forever.
|
|
35
|
+
*
|
|
36
|
+
* The outer `select ... order by staged_at, id` is not cosmetic. `update ... returning` has no
|
|
37
|
+
* defined row order and the relay publishes in the order it is handed rows, so an app staging
|
|
38
|
+
* `createInvoice` then `chargeCard` in one transaction depends on this line.
|
|
39
|
+
*
|
|
40
|
+
* `, id` is what makes that key TOTAL, and the CTE needs it as much as the projection does: every
|
|
41
|
+
* row staged in one transaction shares a `staged_at`, so `staged_at` alone leaves the tie to the
|
|
42
|
+
* planner — which rows a `limit` takes, and in which order they publish, then differ between two
|
|
43
|
+
* relays and between two runs of one relay. No column was added for it: `id` is a UUIDv7 minted by
|
|
44
|
+
* `uuid()`, monotonic and already the primary key, so the tiebreak IS stage order.
|
|
45
|
+
*/
|
|
46
|
+
export const SQL_OUTBOX_CLAIM = `
|
|
47
|
+
with claimable as (
|
|
48
|
+
select id
|
|
49
|
+
from x_outbox
|
|
50
|
+
where published_at is null
|
|
51
|
+
and (claimed_at is null
|
|
52
|
+
or claimed_at <= now() - ($2::bigint * interval '1 millisecond'))
|
|
53
|
+
order by staged_at, id
|
|
54
|
+
limit $1
|
|
55
|
+
for update skip locked
|
|
56
|
+
), claimed as (
|
|
57
|
+
update x_outbox o
|
|
58
|
+
set claimed_at = now(), claimed_by = $3
|
|
59
|
+
from claimable c
|
|
60
|
+
where o.id = c.id
|
|
61
|
+
returning o.id, o.run_id, o.job, o.queue, o.input, o.idempotency_key, o.max_attempts,
|
|
62
|
+
o.tenant_id, o.traceparent, o.enqueued_by, o.claimed_by, o.run_at, o.staged_at
|
|
63
|
+
)
|
|
64
|
+
select id, run_id, job, queue, input, idempotency_key, max_attempts, tenant_id,
|
|
65
|
+
traceparent, enqueued_by, claimed_by,
|
|
66
|
+
(extract(epoch from run_at) * 1000)::bigint as run_at,
|
|
67
|
+
(extract(epoch from staged_at) * 1000)::bigint as staged_at
|
|
68
|
+
from claimed
|
|
69
|
+
order by staged_at, id
|
|
70
|
+
`.trim();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Hand a claim back early. The relay stops its batch on the first publish that fails, and without
|
|
74
|
+
* this the rows behind it would wait out the whole lease before any relay could retry them — a
|
|
75
|
+
* pool blip during a failover becoming tens of seconds of unpublished, committed work.
|
|
76
|
+
*
|
|
77
|
+
* Fenced on `published_at is null` so it can never unclaim a row some other pass already
|
|
78
|
+
* published, AND on `claimed_by` so it can never unclaim one a NEWER claimant now holds: a relay
|
|
79
|
+
* whose lease lapsed while it stalled wakes into a world where its batch is another relay's, and
|
|
80
|
+
* an unfenced release frees rows that relay is mid-publish on — a third relay claims them and
|
|
81
|
+
* publishes them again, which is the duplicate the lease exists to prevent.
|
|
82
|
+
*/
|
|
83
|
+
export const SQL_OUTBOX_RELEASE = `
|
|
84
|
+
update x_outbox
|
|
85
|
+
set claimed_at = null, claimed_by = null
|
|
86
|
+
where id = any($1::uuid[]) and published_at is null and claimed_by = $2
|
|
87
|
+
`.trim();
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Same fence, and here it is the more expensive one to miss: marking a row published is LOSING it,
|
|
91
|
+
* so a lapsed claimant stamping a row the current one has not published yet drops that job with
|
|
92
|
+
* nothing to notice. `published_at is null` makes the stamp first-writer-wins rather than a
|
|
93
|
+
* rewrite of an audit timestamp.
|
|
94
|
+
*/
|
|
95
|
+
export const SQL_OUTBOX_MARK_PUBLISHED = `
|
|
96
|
+
update x_outbox set published_at = to_timestamp($2 / 1000.0)
|
|
97
|
+
where id = $1 and published_at is null and claimed_by = $3
|
|
98
|
+
`.trim();
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The job a staged row's publish made, by the id the two share. Read when `SQL_ENQUEUE` inserted
|
|
102
|
+
* nothing for a request that named its id: the answer is that publish, repeated.
|
|
103
|
+
*/
|
|
104
|
+
export const SQL_OUTBOX_PUBLISHED_JOB = `
|
|
105
|
+
select id, run_id from x_jobs where id = $1
|
|
106
|
+
`.trim();
|
|
107
|
+
|
|
108
|
+
export const SQL_OUTBOX_PENDING_COUNT = `
|
|
109
|
+
select count(*)::bigint as pending from x_outbox where published_at is null
|
|
110
|
+
`.trim();
|
package/src/driver-pg-rows.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { BACKFILL_STATUSES, type BackfillRun, isBackfillStatus } from './backfill-ledger';
|
|
7
7
|
import { isJobState, JOB_STATES, type JobRecord, type JobState } from './driver';
|
|
8
8
|
import { JobRowStatusUnknownError } from './errors';
|
|
9
|
+
import type { JobProgress } from './introspection';
|
|
9
10
|
import { isStepStatus, STEP_STATUSES, type StepRecord, type StepStatus } from './steps';
|
|
10
11
|
|
|
11
12
|
export interface StepRow {
|
|
@@ -47,12 +48,17 @@ export interface JobRow {
|
|
|
47
48
|
readonly tenant_id: string | null;
|
|
48
49
|
readonly last_error: string | null;
|
|
49
50
|
readonly claimed_by: string | null;
|
|
51
|
+
/** Absent from a row an older statement projected. */
|
|
52
|
+
readonly claims?: number | string | null;
|
|
50
53
|
readonly run_at: number | string;
|
|
51
54
|
readonly visible_at: number | string | null;
|
|
52
55
|
readonly created_at: number | string;
|
|
53
56
|
readonly updated_at: number | string;
|
|
54
57
|
readonly traceparent?: string | null;
|
|
55
58
|
readonly enqueued_by?: string | null;
|
|
59
|
+
/** `jsonb`: an object from a client with a type map, its text from one without. */
|
|
60
|
+
readonly progress?: unknown;
|
|
61
|
+
readonly last_error_stack?: string | null;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/**
|
|
@@ -98,6 +104,7 @@ export function toJobRecord(row: JobRow): JobRecord {
|
|
|
98
104
|
...(row.tenant_id === null ? {} : { tenantId: row.tenant_id }),
|
|
99
105
|
...(row.last_error === null ? {} : { lastError: row.last_error }),
|
|
100
106
|
...(row.claimed_by === null ? {} : { claimedBy: row.claimed_by }),
|
|
107
|
+
...(num(row.claims ?? 0) > 0 ? { claim: num(row.claims ?? 0) } : {}),
|
|
101
108
|
...(visibleAt === undefined ? {} : { visibleAt }),
|
|
102
109
|
...(row.traceparent === null || row.traceparent === undefined
|
|
103
110
|
? {}
|
|
@@ -105,9 +112,33 @@ export function toJobRecord(row: JobRow): JobRecord {
|
|
|
105
112
|
...(row.enqueued_by === null || row.enqueued_by === undefined
|
|
106
113
|
? {}
|
|
107
114
|
: { enqueuedBy: row.enqueued_by }),
|
|
115
|
+
...(row.last_error_stack === null || row.last_error_stack === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: { lastErrorStack: row.last_error_stack }),
|
|
118
|
+
...progressOf(row.progress),
|
|
108
119
|
};
|
|
109
120
|
}
|
|
110
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The `progress` column, or nothing. Total: the value was written by `recordProgress` in whatever
|
|
124
|
+
* build was deployed then, and a row that carries something else has no progress, not a throw.
|
|
125
|
+
*/
|
|
126
|
+
function progressOf(raw: unknown): { readonly progress?: JobProgress } {
|
|
127
|
+
const value: unknown = typeof raw === 'string' ? parseJson(raw) : raw;
|
|
128
|
+
if (typeof value !== 'object' || value === null) return {};
|
|
129
|
+
const { done, total, note, at } = value as Readonly<Record<string, unknown>>;
|
|
130
|
+
if (typeof done !== 'number' || typeof total !== 'number' || typeof at !== 'number') return {};
|
|
131
|
+
return { progress: { done, total, at, ...(typeof note === 'string' ? { note } : {}) } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseJson(text: string): unknown {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(text) as unknown;
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
111
142
|
export function toStepRecord(row: StepRow): StepRecord {
|
|
112
143
|
const completedAt = optionalNum(row.completed_at);
|
|
113
144
|
const wakeAt = optionalNum(row.wake_at);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The two statements that SETTLE a claimed row, each also adding the settle to its job's
|
|
2
|
+
// one-minute counter bucket. One statement, not two: a count that can disagree with the rows is
|
|
3
|
+
// worse than no count, and a second round trip per job is a cost every job would pay. Split off
|
|
4
|
+
// `driver-pg-sql.ts` at its size ceiling and re-exported from it.
|
|
5
|
+
|
|
6
|
+
import { COUNTER_BUCKET_MS } from './introspection';
|
|
7
|
+
|
|
8
|
+
/** `x_job_counters`'s conflict target and the bucket every settle writes — spelled once. */
|
|
9
|
+
const BUCKET = `${COUNTER_BUCKET_MS}, date_trunc('minute', now())`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `and state = 'running'`, `and claimed_by = $2` and `and claims = $4` are all FENCES, not
|
|
13
|
+
* filters. Without the first, an ack from a worker that was cancelled overwrites the
|
|
14
|
+
* cancellation. Without the second, an ack from a worker whose lease lapsed marks `done` a run
|
|
15
|
+
* ANOTHER worker has since claimed and is still executing — the row is `running` again, so the
|
|
16
|
+
* state fence alone lets it through. Without the third, the same thing happens when the worker
|
|
17
|
+
* that claimed it again is the SAME one: its id did not change, only the claim's ordinal did.
|
|
18
|
+
* `returning` is how the caller learns whether its settle landed.
|
|
19
|
+
*
|
|
20
|
+
* $1 id, $2 worker, $3 the attempt's duration in ms, $4 the claim, $5 whether it is counted — a
|
|
21
|
+
* row `x jobs drain` moved is `done` here and is not a completed run.
|
|
22
|
+
*/
|
|
23
|
+
export const SQL_ACK = `
|
|
24
|
+
with settled as (
|
|
25
|
+
update x_jobs
|
|
26
|
+
set state = 'done', visible_at = null, claimed_by = null, updated_at = now()
|
|
27
|
+
where id = $1 and state = 'running' and claimed_by = $2 and claims = $4::int
|
|
28
|
+
returning name
|
|
29
|
+
), counted as (
|
|
30
|
+
insert into x_job_counters (job, bucket_ms, bucket_start, done, duration_ms)
|
|
31
|
+
select name, ${BUCKET}, 1, $3::bigint from settled where $5::boolean
|
|
32
|
+
on conflict (job, bucket_ms, bucket_start) do update
|
|
33
|
+
set done = x_job_counters.done + 1,
|
|
34
|
+
duration_ms = x_job_counters.duration_ms + excluded.duration_ms
|
|
35
|
+
)
|
|
36
|
+
select name from settled
|
|
37
|
+
`.trim();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The same three fences. The counter row is written only when one of the three counts is set: a
|
|
41
|
+
* shed, a suspension and a drained attempt are handed back UNCOUNTED and are not history.
|
|
42
|
+
*
|
|
43
|
+
* $1 id, $2 state, $3 counts-as-attempt, $4 delay ms, $5 error, $6 worker, $7 stack,
|
|
44
|
+
* $8 retried, $9 failed, $10 dead (each 0 or 1), $11 the attempt's duration in ms, $12 the claim.
|
|
45
|
+
*/
|
|
46
|
+
export const SQL_NACK = `
|
|
47
|
+
with settled as (
|
|
48
|
+
update x_jobs
|
|
49
|
+
set state = $2,
|
|
50
|
+
attempt = case when $3::boolean then attempt else greatest(attempt - 1, 0) end,
|
|
51
|
+
run_at = now() + ($4::bigint * interval '1 millisecond'),
|
|
52
|
+
visible_at = null,
|
|
53
|
+
claimed_by = null,
|
|
54
|
+
last_error = coalesce($5::text, last_error),
|
|
55
|
+
last_error_stack = case when $5::text is null then last_error_stack else $7::text end,
|
|
56
|
+
updated_at = now()
|
|
57
|
+
where id = $1 and state = 'running' and claimed_by = $6 and claims = $12::int
|
|
58
|
+
returning name
|
|
59
|
+
), counted as (
|
|
60
|
+
insert into x_job_counters (job, bucket_ms, bucket_start, retried, failed, dead, duration_ms)
|
|
61
|
+
select name, ${BUCKET}, $8::int, $9::int, $10::int, $11::bigint
|
|
62
|
+
from settled
|
|
63
|
+
where $8::int + $9::int + $10::int > 0
|
|
64
|
+
on conflict (job, bucket_ms, bucket_start) do update
|
|
65
|
+
set retried = x_job_counters.retried + excluded.retried,
|
|
66
|
+
failed = x_job_counters.failed + excluded.failed,
|
|
67
|
+
dead = x_job_counters.dead + excluded.dead,
|
|
68
|
+
duration_ms = x_job_counters.duration_ms + excluded.duration_ms
|
|
69
|
+
)
|
|
70
|
+
select name from settled
|
|
71
|
+
`.trim();
|