@ultimat3/jobs 1.2.0 → 2.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.
@@ -8,6 +8,7 @@ import type {
8
8
  ClaimOptions,
9
9
  EnqueueRequest,
10
10
  EnqueueResult,
11
+ HeartbeatOptions,
11
12
  JobDriver,
12
13
  NackOptions,
13
14
  QueueStats,
@@ -63,7 +64,7 @@ export function createNatsDriver(_options: NatsDriverOptions = {}): JobDriver {
63
64
  nack(_jobId: string, _options: NackOptions): Promise<void> {
64
65
  return unavailable('nack');
65
66
  },
66
- heartbeat(_jobId: string, _options: { readonly visibilityTimeoutMs: number }): Promise<void> {
67
+ heartbeat(_jobId: string, _options: HeartbeatOptions): Promise<boolean> {
67
68
  return unavailable('heartbeat');
68
69
  },
69
70
  stats(): Promise<readonly QueueStats[]> {
@@ -0,0 +1,180 @@
1
+ // The SCHEMA the Postgres driver installs, apart from the statements it runs against it
2
+ // (`driver-pg-sql.ts`): one constant an operator can read, apply and diff on its own.
3
+ //
4
+ // `SQL_JOBS_TABLE` is the queue's ONE install point — every durable table this package owns is
5
+ // declared in it, so boot applies one constant and `x dev` and production get the same schema.
6
+ // A table that shipped is extended with `alter table ... add column if not exists`, never by
7
+ // editing its `create`: `create table if not exists` is a no-op against a database that already
8
+ // has the table, so a new column added only there reaches new installs and nothing else.
9
+ //
10
+ // `dev-queue.ts` splits this constant on `;` and applies it statement by statement, so comments
11
+ // inside it carry NO semicolons and NO apostrophes — a `;` in prose yields a comment-only chunk
12
+ // and an odd quote count means a `;` sits inside an open literal. `driver-pg-sql.test.ts` pins
13
+ // both, plus the shape of every chunk the split yields.
14
+
15
+ export const SQL_JOBS_TABLE = `
16
+ create table if not exists x_jobs (
17
+ id uuid primary key,
18
+ name text not null,
19
+ queue text not null default 'default',
20
+ input jsonb not null,
21
+ idempotency_key text not null,
22
+ run_id uuid not null,
23
+ attempt int not null default 0,
24
+ max_attempts int not null default 3,
25
+ state text not null default 'ready',
26
+ run_at timestamptz not null default now(),
27
+ visible_at timestamptz,
28
+ claimed_by text,
29
+ last_error text,
30
+ tenant_id text,
31
+ created_at timestamptz not null default now(),
32
+ updated_at timestamptz not null default now()
33
+ );
34
+
35
+ -- The enqueuing requests trace and actor, carried onto the row so the job span is a CHILD of
36
+ -- the request that queued it and an audit trail can say who asked. Added by alter because
37
+ -- x_jobs shipped without them.
38
+ alter table x_jobs add column if not exists traceparent text;
39
+
40
+ alter table x_jobs add column if not exists enqueued_by text;
41
+
42
+ -- Partial unique index: one LIVE job per (name, idempotency key). Completed rows stay for
43
+ -- history, so re-running the same work tomorrow is allowed and re-delivering it today is not.
44
+ --
45
+ -- The NAME is in the key, and its absence was silent data loss: two jobs that happened to derive
46
+ -- the same natural key from the same input ("user:42") shared one namespace, so the second
47
+ -- enqueue deduped against the FIRST jobs row and returned its id. The work never ran, no error
48
+ -- was raised, and the queue showed one healthy job. The old index is dropped rather than left
49
+ -- beside the new one — it is strictly narrower, so keeping it would keep enforcing exactly the
50
+ -- collision this fixes.
51
+ drop index if exists x_jobs_idempotency_live_idx;
52
+
53
+ create unique index if not exists x_jobs_name_idempotency_live_idx
54
+ on x_jobs (name, idempotency_key)
55
+ where state in ('ready', 'delayed', 'running', 'suspended');
56
+
57
+ create index if not exists x_jobs_claim_idx
58
+ on x_jobs (queue, run_at)
59
+ where state in ('ready', 'delayed', 'suspended');
60
+
61
+ create table if not exists x_job_steps (
62
+ run_id uuid not null,
63
+ name text not null,
64
+ status text not null,
65
+ output jsonb,
66
+ started_at timestamptz not null default now(),
67
+ completed_at timestamptz,
68
+ wake_at timestamptz,
69
+ event text,
70
+ correlation_key text,
71
+ attempts int not null default 1,
72
+ error text,
73
+ primary key (run_id, name)
74
+ );
75
+
76
+ -- The backfill ledger. Keyed by RUN, not by name: a completed name blocks a re-run, and a forced
77
+ -- one writes a new row, so what each pass swept survives the rerun that followed it.
78
+ create table if not exists x_backfills (
79
+ run_id uuid primary key,
80
+ name text not null,
81
+ checksum text not null,
82
+ status text not null default 'running',
83
+ app_version text not null,
84
+ rows_processed bigint not null default 0,
85
+ last_cursor text,
86
+ started_at timestamptz not null default now(),
87
+ completed_at timestamptz
88
+ );
89
+
90
+ create index if not exists x_backfills_name_idx on x_backfills (name, started_at desc);
91
+
92
+ -- The transactional outbox. Staged by the SAME connection as the callers business rows, so it
93
+ -- commits or vanishes with them. The relay publishes what committed.
94
+ create table if not exists x_outbox (
95
+ id uuid primary key,
96
+ job text not null,
97
+ queue text not null default 'default',
98
+ input jsonb not null,
99
+ idempotency_key text not null,
100
+ max_attempts int not null default 3,
101
+ run_at timestamptz not null default now(),
102
+ staged_at timestamptz not null default now(),
103
+ tenant_id text,
104
+ traceparent text,
105
+ enqueued_by text,
106
+ published_at timestamptz
107
+ );
108
+
109
+ create index if not exists x_outbox_unpublished_idx
110
+ on x_outbox (staged_at) where published_at is null;
111
+
112
+ -- The scheduler watermark. Without a durable one a redeployed scheduler has no idea what the
113
+ -- pod it replaced already fired, so runRound takes the arming branch and every occurrence
114
+ -- between the two processes is dropped with nothing logged.
115
+ create table if not exists x_scheduler_state (
116
+ task_name text primary key,
117
+ last_fired_at timestamptz not null,
118
+ updated_at timestamptz not null default now()
119
+ );
120
+
121
+ -- Leader election as an EXPIRING LEASE rather than a session advisory lock: the executor this
122
+ -- package is handed is a pool, and a session-level pg_try_advisory_lock is released the moment
123
+ -- that connection goes back to it. A row with an expiry needs no connection affinity at all.
124
+ create table if not exists x_scheduler_leader (
125
+ lock_key text primary key,
126
+ holder text not null,
127
+ expires_at timestamptz not null
128
+ );
129
+
130
+ -- Fleet-wide concurrency. One row per HELD SLOT, so the primary key is what serialises two
131
+ -- workers reaching for the same slot — job.concurrency was documented, in the manifest, and
132
+ -- enforced by nothing before this table existed.
133
+ create table if not exists x_job_leases (
134
+ lease_key text not null,
135
+ slot int not null,
136
+ holder text not null,
137
+ expires_at timestamptz not null,
138
+ primary key (lease_key, slot)
139
+ );
140
+
141
+ create index if not exists x_job_leases_expiry_idx on x_job_leases (expires_at);
142
+
143
+ -- Events step.waitForEvent consumes. Stored and not broadcast: the publisher is a web pod and
144
+ -- the resumer is a worker pod, so an in-heap bus strands every waiting run in a real deployment.
145
+ create table if not exists x_job_events (
146
+ id uuid primary key,
147
+ name text not null,
148
+ payload jsonb not null,
149
+ correlation_key text,
150
+ published_at timestamptz not null default now(),
151
+ expires_at timestamptz not null
152
+ );
153
+
154
+ create index if not exists x_job_events_lookup_idx
155
+ on x_job_events (name, published_at);
156
+ `.trim();
157
+
158
+ /**
159
+ * Kept as its own constant because it is a public export and `x_outbox` is a table an operator
160
+ * may need to create alone. It is ALSO inside `SQL_JOBS_TABLE`, which is the one boot applies —
161
+ * two install points for one table is how the outbox came to be documented and never created.
162
+ */
163
+ export const SQL_OUTBOX_TABLE = `
164
+ create table if not exists x_outbox (
165
+ id uuid primary key,
166
+ job text not null,
167
+ queue text not null default 'default',
168
+ input jsonb not null,
169
+ idempotency_key text not null,
170
+ max_attempts int not null default 3,
171
+ run_at timestamptz not null default now(),
172
+ staged_at timestamptz not null default now(),
173
+ tenant_id text,
174
+ traceparent text,
175
+ enqueued_by text,
176
+ published_at timestamptz
177
+ );
178
+ create index if not exists x_outbox_unpublished_idx
179
+ on x_outbox (staged_at) where published_at is null;
180
+ `.trim();
@@ -0,0 +1,123 @@
1
+ // What Postgres hands back, and what the queue speaks in: the row shapes the driver's statements
2
+ // return and the one mapping from each onto its wire record. Apart from `driver-pg.ts` because
3
+ // decoding a row is not control flow — every number arrives as `number | string` (a bigint is a
4
+ // string in every client) and every absent column as `null`, and that translation is its own job.
5
+
6
+ import type { BackfillRun, BackfillStatus } from './backfill-ledger';
7
+ import type { JobRecord } from './driver';
8
+ import type { StepRecord } from './steps';
9
+
10
+ export interface StepRow {
11
+ readonly run_id: string;
12
+ readonly name: string;
13
+ readonly status: string;
14
+ readonly output: unknown;
15
+ readonly started_at: number | string;
16
+ readonly completed_at: number | string | null;
17
+ readonly wake_at: number | string | null;
18
+ readonly event: string | null;
19
+ readonly correlation_key: string | null;
20
+ readonly attempts: number;
21
+ readonly error: string | null;
22
+ }
23
+
24
+ export interface BackfillRow {
25
+ readonly run_id: string;
26
+ readonly name: string;
27
+ readonly checksum: string;
28
+ readonly status: string;
29
+ readonly app_version: string;
30
+ readonly rows_processed: number | string;
31
+ readonly last_cursor: string | null;
32
+ readonly started_at: number | string;
33
+ readonly completed_at: number | string | null;
34
+ }
35
+
36
+ export interface JobRow {
37
+ readonly id: string;
38
+ readonly name: string;
39
+ readonly queue: string;
40
+ readonly input: unknown;
41
+ readonly idempotency_key: string;
42
+ readonly run_id: string;
43
+ readonly attempt: number;
44
+ readonly max_attempts: number;
45
+ readonly state: string;
46
+ readonly tenant_id: string | null;
47
+ readonly last_error: string | null;
48
+ readonly claimed_by: string | null;
49
+ readonly run_at: number | string;
50
+ readonly visible_at: number | string | null;
51
+ readonly created_at: number | string;
52
+ readonly updated_at: number | string;
53
+ readonly traceparent?: string | null;
54
+ readonly enqueued_by?: string | null;
55
+ }
56
+
57
+ export const num = (value: number | string | null | undefined): number =>
58
+ value === null || value === undefined ? 0 : Number(value);
59
+
60
+ const optionalNum = (value: number | string | null | undefined): number | undefined =>
61
+ value === null || value === undefined ? undefined : Number(value);
62
+
63
+ export function toJobRecord(row: JobRow): JobRecord {
64
+ const visibleAt = optionalNum(row.visible_at);
65
+ return {
66
+ id: row.id,
67
+ name: row.name,
68
+ queue: row.queue,
69
+ input: row.input,
70
+ idempotencyKey: row.idempotency_key,
71
+ runId: row.run_id,
72
+ attempt: row.attempt,
73
+ maxAttempts: row.max_attempts,
74
+ state: row.state as JobRecord['state'],
75
+ runAt: num(row.run_at),
76
+ createdAt: num(row.created_at),
77
+ updatedAt: num(row.updated_at),
78
+ ...(row.tenant_id === null ? {} : { tenantId: row.tenant_id }),
79
+ ...(row.last_error === null ? {} : { lastError: row.last_error }),
80
+ ...(row.claimed_by === null ? {} : { claimedBy: row.claimed_by }),
81
+ ...(visibleAt === undefined ? {} : { visibleAt }),
82
+ ...(row.traceparent === null || row.traceparent === undefined
83
+ ? {}
84
+ : { traceparent: row.traceparent }),
85
+ ...(row.enqueued_by === null || row.enqueued_by === undefined
86
+ ? {}
87
+ : { enqueuedBy: row.enqueued_by }),
88
+ };
89
+ }
90
+
91
+ export function toStepRecord(row: StepRow): StepRecord {
92
+ const completedAt = optionalNum(row.completed_at);
93
+ const wakeAt = optionalNum(row.wake_at);
94
+ return {
95
+ runId: row.run_id,
96
+ name: row.name,
97
+ status: row.status as StepRecord['status'],
98
+ output: row.output,
99
+ startedAt: num(row.started_at),
100
+ attempts: row.attempts,
101
+ ...(completedAt === undefined ? {} : { completedAt }),
102
+ ...(wakeAt === undefined ? {} : { wakeAt }),
103
+ ...(row.event === null ? {} : { event: row.event }),
104
+ ...(row.correlation_key === null ? {} : { correlationKey: row.correlation_key }),
105
+ ...(row.error === null ? {} : { error: row.error }),
106
+ };
107
+ }
108
+
109
+ export function toBackfillRun(row: BackfillRow): BackfillRun {
110
+ const completedAt = optionalNum(row.completed_at);
111
+ return {
112
+ runId: row.run_id,
113
+ name: row.name,
114
+ checksum: row.checksum,
115
+ status: row.status as BackfillStatus,
116
+ appVersion: row.app_version,
117
+ // `rows_processed` is a bigint, which every Postgres client hands back as a string.
118
+ rows: num(row.rows_processed),
119
+ cursor: row.last_cursor,
120
+ startedAt: num(row.started_at),
121
+ ...(completedAt === undefined ? {} : { completedAt }),
122
+ };
123
+ }