@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.
Files changed (63) hide show
  1. package/CLAUDE.md +129 -87
  2. package/README.md +329 -19
  3. package/package.json +6 -6
  4. package/src/concurrency.ts +144 -0
  5. package/src/counters.ts +105 -0
  6. package/src/describe.ts +29 -0
  7. package/src/driver-memory-operator.ts +297 -0
  8. package/src/driver-memory.ts +79 -37
  9. package/src/driver-nats.ts +2 -2
  10. package/src/driver-pg-ddl.ts +71 -0
  11. package/src/driver-pg-jobs-sql.ts +71 -8
  12. package/src/driver-pg-operator-sql.ts +303 -0
  13. package/src/driver-pg-operator.ts +300 -0
  14. package/src/driver-pg-outbox-sql.ts +110 -0
  15. package/src/driver-pg-rows.ts +31 -0
  16. package/src/driver-pg-settle-sql.ts +71 -0
  17. package/src/driver-pg-sql.ts +80 -129
  18. package/src/driver-pg-wake-sql.ts +76 -0
  19. package/src/driver-pg.ts +74 -34
  20. package/src/driver-redis.ts +2 -2
  21. package/src/driver.ts +96 -34
  22. package/src/enqueue-signal.ts +50 -0
  23. package/src/errors-concurrency.ts +91 -0
  24. package/src/errors-operator.ts +94 -0
  25. package/src/errors.ts +13 -17
  26. package/src/events-pg.ts +38 -28
  27. package/src/events.ts +25 -0
  28. package/src/execute.ts +160 -81
  29. package/src/export.ts +23 -4
  30. package/src/heartbeat.ts +9 -3
  31. package/src/idle-backoff.ts +74 -0
  32. package/src/index.ts +105 -12
  33. package/src/inspect-operator.ts +49 -0
  34. package/src/inspect.ts +42 -2
  35. package/src/introspection.ts +262 -0
  36. package/src/job.ts +78 -15
  37. package/src/leases.ts +49 -9
  38. package/src/metrics.ts +6 -1
  39. package/src/outbox-pg.ts +3 -0
  40. package/src/outbox-relay.ts +114 -39
  41. package/src/outbox.ts +29 -6
  42. package/src/progress.ts +78 -0
  43. package/src/queue-wake.ts +220 -0
  44. package/src/redact-input.ts +26 -0
  45. package/src/renewal-timer.ts +28 -10
  46. package/src/retry-classification.ts +13 -1
  47. package/src/retry.ts +10 -2
  48. package/src/run-deadline.ts +57 -0
  49. package/src/scheduler-leader.ts +26 -0
  50. package/src/scheduler-occurrences.ts +76 -0
  51. package/src/scheduler-pg.ts +55 -2
  52. package/src/scheduler-state.ts +66 -0
  53. package/src/scheduler.ts +143 -103
  54. package/src/settled.ts +161 -0
  55. package/src/worker-admit.ts +163 -0
  56. package/src/worker-fleet-slots.ts +107 -38
  57. package/src/worker-hand-back.ts +6 -1
  58. package/src/worker-key-busy.ts +79 -0
  59. package/src/worker-loop.ts +145 -0
  60. package/src/worker-registry.ts +103 -0
  61. package/src/worker-run.ts +9 -0
  62. package/src/worker-types.ts +23 -0
  63. 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();
@@ -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();