@ultimat3/jobs 1.2.0 → 3.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 +660 -0
- package/README.md +432 -17
- package/package.json +7 -5
- package/src/backfill-gate.ts +97 -0
- package/src/backfill-inspect.ts +73 -0
- package/src/backfill-ledger.ts +183 -0
- package/src/backfill-pass.ts +276 -0
- package/src/backfill-pending.ts +131 -0
- package/src/backfill-rate.ts +109 -0
- package/src/backfill-registry.ts +108 -0
- package/src/backfill-scope.ts +70 -0
- package/src/backfill.ts +213 -0
- package/src/driver-memory.ts +61 -9
- package/src/driver-nats.ts +2 -1
- package/src/driver-pg-ddl.ts +191 -0
- package/src/driver-pg-rows.ts +123 -0
- package/src/driver-pg-sql.ts +312 -55
- package/src/driver-pg.ts +138 -92
- package/src/driver-redis.ts +2 -1
- package/src/driver.ts +91 -7
- package/src/errors.ts +314 -5
- package/src/events-pg.ts +121 -0
- package/src/events.ts +7 -1
- package/src/execute.ts +308 -0
- package/src/heartbeat.ts +148 -0
- package/src/index.ts +128 -27
- package/src/inspect.ts +43 -2
- package/src/job.ts +127 -3
- package/src/leases.ts +90 -0
- package/src/limits.ts +0 -0
- package/src/metrics.ts +35 -0
- package/src/outbox-lease.ts +29 -0
- package/src/outbox-pg.ts +188 -0
- package/src/outbox.ts +204 -59
- package/src/register.ts +1 -1
- package/src/renewal-timer.ts +35 -0
- package/src/retry-classification.ts +112 -0
- package/src/retry.ts +6 -1
- package/src/run-signal.ts +50 -0
- package/src/scheduler-pg.ts +103 -0
- package/src/scheduler.ts +159 -245
- package/src/steps.ts +155 -31
- package/src/task.ts +239 -0
- package/src/tenant.ts +61 -0
- package/src/worker-fleet-slots.ts +129 -0
- package/src/worker-run.ts +132 -0
- package/src/worker.ts +207 -190
package/src/index.ts
CHANGED
|
@@ -10,6 +10,54 @@ import './register';
|
|
|
10
10
|
/** Re-exported so a `job`/`task` file needs one import, not two. Same object as schema's. */
|
|
11
11
|
export type { Infer } from '@ultimat3/schema';
|
|
12
12
|
export { t } from '@ultimat3/schema';
|
|
13
|
+
export type {
|
|
14
|
+
BackfillBatch,
|
|
15
|
+
BackfillDefinition,
|
|
16
|
+
BackfillInput,
|
|
17
|
+
BackfillReport,
|
|
18
|
+
} from './backfill';
|
|
19
|
+
export { backfill, DEFAULT_BACKFILL_BATCH } from './backfill';
|
|
20
|
+
export type { BackfillGate, BackfillGateInput } from './backfill-gate';
|
|
21
|
+
export { checkBackfillEnvironment, gateBackfill } from './backfill-gate';
|
|
22
|
+
export type { BackfillProgress } from './backfill-inspect';
|
|
23
|
+
export { backfillForRun, inspectBackfills, toBackfillProgress } from './backfill-inspect';
|
|
24
|
+
export type {
|
|
25
|
+
BackfillFilter,
|
|
26
|
+
BackfillLedger,
|
|
27
|
+
BackfillRun,
|
|
28
|
+
BackfillStatus,
|
|
29
|
+
BackfillVerdict,
|
|
30
|
+
} from './backfill-ledger';
|
|
31
|
+
export {
|
|
32
|
+
BACKFILL_STATUSES,
|
|
33
|
+
backfillChecksum,
|
|
34
|
+
createMemoryBackfillLedger,
|
|
35
|
+
decideBackfill,
|
|
36
|
+
isBackfillStatus,
|
|
37
|
+
} from './backfill-ledger';
|
|
38
|
+
export type {
|
|
39
|
+
BackfillPendingReport,
|
|
40
|
+
BackfillState,
|
|
41
|
+
BackfillStateRow,
|
|
42
|
+
} from './backfill-pending';
|
|
43
|
+
export {
|
|
44
|
+
BACKFILL_STATES,
|
|
45
|
+
isPendingBackfillState,
|
|
46
|
+
PENDING_BACKFILL_STATES,
|
|
47
|
+
pendingBackfills,
|
|
48
|
+
} from './backfill-pending';
|
|
49
|
+
export type { Pacer, PacerOptions } from './backfill-rate';
|
|
50
|
+
export { createPacer, DEFAULT_BACKFILL_RATE } from './backfill-rate';
|
|
51
|
+
export type { BackfillCount, BackfillDeclaration, BackfillOrigin } from './backfill-registry';
|
|
52
|
+
// `stampBackfill` is deliberately absent, for the reason `registerJob` is: a second way to make a
|
|
53
|
+
// handle claim it is a backfill would let a plain `job()` inherit the pending diff and the gate.
|
|
54
|
+
export {
|
|
55
|
+
backfillOrigin,
|
|
56
|
+
declarationOf,
|
|
57
|
+
getBackfill,
|
|
58
|
+
isBackfill,
|
|
59
|
+
registeredBackfills,
|
|
60
|
+
} from './backfill-registry';
|
|
13
61
|
export type { JobDescriptor } from './describe';
|
|
14
62
|
export type {
|
|
15
63
|
ClaimedJob,
|
|
@@ -17,6 +65,7 @@ export type {
|
|
|
17
65
|
ConflictPolicy,
|
|
18
66
|
EnqueueRequest,
|
|
19
67
|
EnqueueResult,
|
|
68
|
+
HeartbeatOptions,
|
|
20
69
|
JobDriver,
|
|
21
70
|
JobFilter,
|
|
22
71
|
JobIntrospection,
|
|
@@ -41,11 +90,28 @@ export { createPgDriver, createPgLeader } from './driver-pg';
|
|
|
41
90
|
export {
|
|
42
91
|
SQL_ACK,
|
|
43
92
|
SQL_ADVISORY_UNLOCK,
|
|
93
|
+
SQL_BACKFILL_FINISH,
|
|
94
|
+
SQL_BACKFILL_LIST,
|
|
95
|
+
SQL_BACKFILL_PROGRESS,
|
|
96
|
+
SQL_BACKFILL_START,
|
|
97
|
+
SQL_CANCEL,
|
|
44
98
|
SQL_CLAIM,
|
|
45
99
|
SQL_ENQUEUE,
|
|
46
100
|
SQL_HEARTBEAT,
|
|
47
101
|
SQL_JOBS_TABLE,
|
|
102
|
+
SQL_LEADER_ACQUIRE,
|
|
103
|
+
SQL_LEADER_RELEASE,
|
|
104
|
+
SQL_LEASE_ACQUIRE,
|
|
105
|
+
SQL_LEASE_RELEASE,
|
|
106
|
+
SQL_LEASE_RENEW,
|
|
48
107
|
SQL_NACK,
|
|
108
|
+
SQL_OUTBOX_CLAIM,
|
|
109
|
+
SQL_OUTBOX_MARK_PUBLISHED,
|
|
110
|
+
SQL_OUTBOX_RELEASE,
|
|
111
|
+
SQL_OUTBOX_STAGE,
|
|
112
|
+
SQL_OUTBOX_TABLE,
|
|
113
|
+
SQL_SCHEDULER_STATE_GET,
|
|
114
|
+
SQL_SCHEDULER_STATE_MARK,
|
|
49
115
|
SQL_STATS,
|
|
50
116
|
SQL_STEP_GET,
|
|
51
117
|
SQL_STEP_PUT,
|
|
@@ -55,20 +121,38 @@ export type { RedisDriverOptions } from './driver-redis';
|
|
|
55
121
|
export { createRedisDriver } from './driver-redis';
|
|
56
122
|
export type { JobErrorCode } from './errors';
|
|
57
123
|
export {
|
|
124
|
+
BackfillAppliedError,
|
|
125
|
+
BackfillEnvironmentError,
|
|
126
|
+
BackfillMigrationPendingError,
|
|
127
|
+
BackfillPendingError,
|
|
128
|
+
BackfillRunningError,
|
|
129
|
+
BackfillStalledError,
|
|
130
|
+
BackfillUnknownError,
|
|
131
|
+
CancelUnsupportedError,
|
|
132
|
+
ConcurrencyUnenforceableError,
|
|
58
133
|
DriverUnavailableError,
|
|
59
134
|
IdempotencyRequiredError,
|
|
60
135
|
JOB_ERROR_CODES,
|
|
61
136
|
JOB_ERROR_TITLES,
|
|
137
|
+
JobAbortedError,
|
|
62
138
|
JobDuplicateError,
|
|
63
139
|
JobMaxAttemptsError,
|
|
64
140
|
JobNameTakenError,
|
|
141
|
+
JobNotCancellableError,
|
|
142
|
+
JobSlotLostError,
|
|
65
143
|
JobsNotImplementedError,
|
|
144
|
+
JobTenantRequiredError,
|
|
66
145
|
JobTimeoutError,
|
|
146
|
+
LeaseLostError,
|
|
67
147
|
OutboxNoTxError,
|
|
68
148
|
StepDuplicateError,
|
|
69
149
|
} from './errors';
|
|
70
150
|
export type { EventBus, JobEvent, MemoryEventBusOptions, PublishOptions } from './events';
|
|
71
151
|
export { createMemoryEventBus, eventBus, publishEvent, setEventBus } from './events';
|
|
152
|
+
export type { PgEventBusOptions } from './events-pg';
|
|
153
|
+
export { createPgEventBus } from './events-pg';
|
|
154
|
+
export type { ExecuteJobOptions, JobExecution, JobOutcome } from './execute';
|
|
155
|
+
export { executeJob } from './execute';
|
|
72
156
|
export type {
|
|
73
157
|
DeadLetterEntry,
|
|
74
158
|
JobsManifest,
|
|
@@ -77,6 +161,7 @@ export type {
|
|
|
77
161
|
StepTrace,
|
|
78
162
|
} from './inspect';
|
|
79
163
|
export {
|
|
164
|
+
cancelJob,
|
|
80
165
|
inspectDeadLetters,
|
|
81
166
|
inspectJob,
|
|
82
167
|
inspectJobList,
|
|
@@ -86,6 +171,8 @@ export {
|
|
|
86
171
|
} from './inspect';
|
|
87
172
|
export type { AnyJobHandle, JobActor, JobDefinition, JobHandle, JobRunArgs } from './job';
|
|
88
173
|
export { describeJobs, getJob, isJobHandle, job, registeredJobs, resetJobs } from './job';
|
|
174
|
+
export type { HeldLease, LeaseStore, MemoryLeaseStoreOptions } from './leases';
|
|
175
|
+
export { createMemoryLeaseStore, jobLeaseKey } from './leases';
|
|
89
176
|
export type {
|
|
90
177
|
Lease,
|
|
91
178
|
LimitConfig,
|
|
@@ -96,9 +183,17 @@ export type {
|
|
|
96
183
|
RateLimit,
|
|
97
184
|
} from './limits';
|
|
98
185
|
export { createLimiter, NO_TENANT, tenantKeyFrom } from './limits';
|
|
186
|
+
export {
|
|
187
|
+
queueDeadJobs,
|
|
188
|
+
queueOldestReady,
|
|
189
|
+
recordQueueDeadJobs,
|
|
190
|
+
recordQueueOldestReady,
|
|
191
|
+
} from './metrics';
|
|
99
192
|
export type {
|
|
100
193
|
EnqueueOptions,
|
|
101
194
|
JobsFacade,
|
|
195
|
+
MemoryOutboxOptions,
|
|
196
|
+
MemoryOutboxStore,
|
|
102
197
|
OutboxDeps,
|
|
103
198
|
OutboxRecord,
|
|
104
199
|
OutboxRelay,
|
|
@@ -112,39 +207,34 @@ export {
|
|
|
112
207
|
enqueueInTx,
|
|
113
208
|
jobsFacade,
|
|
114
209
|
resetJobsFacade,
|
|
115
|
-
SQL_OUTBOX_CLAIM,
|
|
116
|
-
SQL_OUTBOX_MARK_PUBLISHED,
|
|
117
|
-
SQL_OUTBOX_STAGE,
|
|
118
|
-
SQL_OUTBOX_TABLE,
|
|
119
210
|
setJobsFacade,
|
|
120
211
|
} from './outbox';
|
|
212
|
+
// One definition of the lease, consumed by both stores — a memory default and a pg default that
|
|
213
|
+
// could drift are two answers to "how long is a claim mine for", and the shorter one duplicates.
|
|
214
|
+
export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
|
|
215
|
+
export type { PgOutboxOptions } from './outbox-pg';
|
|
216
|
+
export { createPgOutboxStore } from './outbox-pg';
|
|
121
217
|
|
|
122
218
|
export type { BackoffStrategy, Random, RetryDecision, RetryPolicy } from './retry';
|
|
123
219
|
export { backoffDelayMs, DEFAULT_RETRY, nextRetry, retrySchedule } from './retry';
|
|
220
|
+
export type { JobRetryDecision, JobStopReason } from './retry-classification';
|
|
221
|
+
export { classifyThrown, nextRetryForError } from './retry-classification';
|
|
124
222
|
export type {
|
|
125
|
-
CatchUpPolicy,
|
|
126
223
|
CronResolver,
|
|
127
224
|
DispatchedOccurrence,
|
|
128
225
|
LeaderElection,
|
|
129
226
|
Scheduler,
|
|
130
227
|
SchedulerOptions,
|
|
131
228
|
SchedulerState,
|
|
132
|
-
TaskDefinition,
|
|
133
|
-
TaskDescriptor,
|
|
134
|
-
TaskEnqueueEntry,
|
|
135
|
-
TaskHandle,
|
|
136
|
-
TaskJobResult,
|
|
137
229
|
} from './scheduler';
|
|
230
|
+
export { createMemorySchedulerState, createScheduler, soleLeader } from './scheduler';
|
|
231
|
+
export type { PgLeaseLeaderOptions } from './scheduler-pg';
|
|
138
232
|
export {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
resetTasks,
|
|
145
|
-
soleLeader,
|
|
146
|
-
task,
|
|
147
|
-
} from './scheduler';
|
|
233
|
+
createPgLeaseLeader,
|
|
234
|
+
currentLeader,
|
|
235
|
+
DEFAULT_LEADER_TTL_MS,
|
|
236
|
+
pgSchedulerState,
|
|
237
|
+
} from './scheduler-pg';
|
|
148
238
|
export type {
|
|
149
239
|
EventLookup,
|
|
150
240
|
StepApi,
|
|
@@ -159,14 +249,25 @@ export {
|
|
|
159
249
|
createMemoryStepStore,
|
|
160
250
|
createStepRunner,
|
|
161
251
|
isStepSuspension,
|
|
252
|
+
MAX_TRACE_NAMES,
|
|
162
253
|
StepSuspension,
|
|
163
254
|
} from './steps';
|
|
164
255
|
export type {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
} from './
|
|
172
|
-
export {
|
|
256
|
+
CatchUpPolicy,
|
|
257
|
+
TaskDefinition,
|
|
258
|
+
TaskDescriptor,
|
|
259
|
+
TaskEnqueueEntry,
|
|
260
|
+
TaskHandle,
|
|
261
|
+
TaskJobResult,
|
|
262
|
+
} from './task';
|
|
263
|
+
export { getTask, isTaskHandle, registeredTasks, resetTasks, task } from './task';
|
|
264
|
+
/**
|
|
265
|
+
* The tenant a job's body runs under. The TYPE only: `NO_JOB_TENANT`, `jobRunActor` and
|
|
266
|
+
* `jobTenantFor` stay unexported. The first would be a second spelling of `'none'` (axiom 1 — the
|
|
267
|
+
* literal is what the type says and what a declaration reads as), and the other two are
|
|
268
|
+
* `executeJob`'s and `job()`'s: a second caller deriving a run's org would be a second answer to
|
|
269
|
+
* "whose tenant is this", which is the thing this declaration exists to make singular.
|
|
270
|
+
*/
|
|
271
|
+
export type { JobTenant } from './tenant';
|
|
272
|
+
export type { Worker, WorkerOptions, WorkerStats } from './worker';
|
|
273
|
+
export { createWorker } from './worker';
|
package/src/inspect.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// object so `x jobs ... --json` and the MCP tool share one shape — an agent debugging a stuck
|
|
3
3
|
// queue reads exactly what the dashboard renders.
|
|
4
4
|
|
|
5
|
+
import type { BackfillProgress } from './backfill-inspect';
|
|
6
|
+
import { backfillForRun } from './backfill-inspect';
|
|
5
7
|
import type { JobDriver, JobFilter, JobRecord, QueueStats } from './driver';
|
|
6
|
-
import { JobsNotImplementedError } from './errors';
|
|
8
|
+
import { CancelUnsupportedError, JobNotCancellableError, JobsNotImplementedError } from './errors';
|
|
7
9
|
import { registeredJobs } from './job';
|
|
8
10
|
import { retrySchedule } from './retry';
|
|
9
11
|
import type { Scheduler } from './scheduler';
|
|
10
|
-
import { registeredTasks } from './scheduler';
|
|
11
12
|
import type { StepRecord } from './steps';
|
|
13
|
+
import { registeredTasks } from './task';
|
|
12
14
|
|
|
13
15
|
export interface QueueDepthReport {
|
|
14
16
|
readonly driver: string;
|
|
@@ -67,9 +69,19 @@ export interface JobTrace {
|
|
|
67
69
|
readonly runAt: string;
|
|
68
70
|
readonly lastError: string | null;
|
|
69
71
|
readonly tenantId: string | null;
|
|
72
|
+
/** W3C `traceparent` of the request that queued it — paste it into the trace viewer. */
|
|
73
|
+
readonly traceparent: string | null;
|
|
74
|
+
/** Actor id of whoever asked. Audit only: the body ran with system authority. */
|
|
75
|
+
readonly enqueuedBy: string | null;
|
|
70
76
|
readonly steps: readonly StepTrace[];
|
|
71
77
|
/** Remaining retry delays in ms, jitter excluded. */
|
|
72
78
|
readonly retryDelaysMs: readonly number[];
|
|
79
|
+
/**
|
|
80
|
+
* The `x_backfills` row this run wrote, when the job is a `backfill()`. `null` for every other
|
|
81
|
+
* job and for a driver with no ledger — a step trace says which batch is next, and this says how
|
|
82
|
+
* many rows the pass has actually put behind it.
|
|
83
|
+
*/
|
|
84
|
+
readonly backfill: BackfillProgress | null;
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
const iso = (ms: number | undefined): string | null =>
|
|
@@ -103,6 +115,7 @@ export async function inspectJob(driver: JobDriver, jobId: string): Promise<JobT
|
|
|
103
115
|
if (record === undefined) return undefined;
|
|
104
116
|
const steps = await driver.steps.list(record.runId);
|
|
105
117
|
const handle = registeredJobs().find((candidate) => candidate.name === record.name);
|
|
118
|
+
const backfill = await backfillForRun(driver, record.runId);
|
|
106
119
|
return {
|
|
107
120
|
id: record.id,
|
|
108
121
|
name: record.name,
|
|
@@ -115,8 +128,11 @@ export async function inspectJob(driver: JobDriver, jobId: string): Promise<JobT
|
|
|
115
128
|
runAt: new Date(record.runAt).toISOString(),
|
|
116
129
|
lastError: record.lastError ?? null,
|
|
117
130
|
tenantId: record.tenantId ?? null,
|
|
131
|
+
traceparent: record.traceparent ?? null,
|
|
132
|
+
enqueuedBy: record.enqueuedBy ?? null,
|
|
118
133
|
steps: steps.map(toStepTrace),
|
|
119
134
|
retryDelaysMs: handle === undefined ? [] : [...retrySchedule(handle.retry)],
|
|
135
|
+
backfill: backfill ?? null,
|
|
120
136
|
};
|
|
121
137
|
}
|
|
122
138
|
|
|
@@ -169,6 +185,31 @@ export async function retryFromStep(
|
|
|
169
185
|
return inspectJob(driver, jobId);
|
|
170
186
|
}
|
|
171
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Stop a job from outside — the surface `x jobs cancel <id>` binds to. The hard half was already
|
|
190
|
+
* built: `execute.ts` cancels the attempt and `steps.ts` fences every write behind that signal.
|
|
191
|
+
* What was missing was anything that could TRIGGER it, so a runaway backfill against production
|
|
192
|
+
* left two options: scale the worker to zero (stopping every job) or `UPDATE x_jobs` by hand,
|
|
193
|
+
* which the worker's next ack overwrote because `SQL_ACK` had no state guard.
|
|
194
|
+
*
|
|
195
|
+
* Refuses loudly rather than answering "nothing happened": an operator cancelling a 40M-row sweep
|
|
196
|
+
* has to know whether they stopped it or missed it.
|
|
197
|
+
*/
|
|
198
|
+
export async function cancelJob(
|
|
199
|
+
driver: JobDriver,
|
|
200
|
+
jobId: string,
|
|
201
|
+
reason?: string,
|
|
202
|
+
): Promise<JobTrace | undefined> {
|
|
203
|
+
const introspect = requireIntrospection(driver);
|
|
204
|
+
if (introspect.cancel === undefined) throw new CancelUnsupportedError({ driver: driver.name });
|
|
205
|
+
const record = await introspect.cancel(jobId, reason);
|
|
206
|
+
if (record === undefined) {
|
|
207
|
+
const current = await introspect.job(jobId);
|
|
208
|
+
throw new JobNotCancellableError({ jobId, state: current?.state ?? 'missing' });
|
|
209
|
+
}
|
|
210
|
+
return inspectJob(driver, jobId);
|
|
211
|
+
}
|
|
212
|
+
|
|
172
213
|
export interface JobsManifest {
|
|
173
214
|
readonly jobs: readonly {
|
|
174
215
|
readonly name: string;
|
package/src/job.ts
CHANGED
|
@@ -24,10 +24,18 @@ import { jobsFacade } from './outbox';
|
|
|
24
24
|
import type { RetryPolicy } from './retry';
|
|
25
25
|
import { DEFAULT_RETRY } from './retry';
|
|
26
26
|
import type { StepApi } from './steps';
|
|
27
|
+
import type { JobTenant } from './tenant';
|
|
28
|
+
import { assertJobTenant, jobTenantFor } from './tenant';
|
|
27
29
|
|
|
28
30
|
export interface JobRunArgs<I> {
|
|
29
31
|
readonly input: I;
|
|
30
32
|
readonly step: StepApi;
|
|
33
|
+
/**
|
|
34
|
+
* `ctx.signal` aborts when this attempt's `timeout` passes — the same seam an action reads, so
|
|
35
|
+
* `throwIfAborted(ctx)` and `fetch(url, { signal: ctx.signal })` work here unchanged. Past it
|
|
36
|
+
* the run belongs to whoever claims it next and `step.run` refuses to write, so a loop that
|
|
37
|
+
* never checks it is a body running beside its own retry.
|
|
38
|
+
*/
|
|
31
39
|
readonly ctx: Ctx;
|
|
32
40
|
/** 1-based. Assume at-least-once: never branch on `attempt === 1` for correctness. */
|
|
33
41
|
readonly attempt: number;
|
|
@@ -44,11 +52,50 @@ export interface JobDefinition<I> {
|
|
|
44
52
|
readonly input: StandardSchemaV1<unknown, I>;
|
|
45
53
|
/** REQUIRED. See the file header — this is the whole point. */
|
|
46
54
|
readonly idempotencyKey: (input: I) => string;
|
|
55
|
+
/**
|
|
56
|
+
* REQUIRED, and the org this job's body runs under. `tenant: (input) => input.orgId` derives it
|
|
57
|
+
* from the payload; `tenant: 'none'` says this job belongs to no tenant, and then every
|
|
58
|
+
* tenant-scoped read inside it fails closed with `X_TENANCY_ACTOR_ORG_REQUIRED`.
|
|
59
|
+
*
|
|
60
|
+
* There is no default, because both candidates are wrong. Until this field existed the worker ran
|
|
61
|
+
* a body with no ambient context at all, so `@ultimat3/entity`'s tenant guard — which derives
|
|
62
|
+
* from `tryUseContext()` and not from the ctx it is handed — read no actor, added no predicate
|
|
63
|
+
* and accepted a caller-named `orgId` unchecked: the same write refused over HTTP as
|
|
64
|
+
* `X_TENANCY_ACTOR_MISMATCH` was ACCEPTED through the job surface. A boot-supplied service actor
|
|
65
|
+
* would close that with ONE identity for every job, which is a cross-tenant read waiting for the
|
|
66
|
+
* first job that takes an org id in its input. So the job declares it, per job, from its own
|
|
67
|
+
* payload — the value an author already had to pass anyway.
|
|
68
|
+
*/
|
|
69
|
+
readonly tenant: JobTenant<I>;
|
|
47
70
|
readonly retry: RetryPolicy;
|
|
48
71
|
readonly queue?: string;
|
|
49
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Max in-flight runs of THIS job across the fleet. Omit for the queue-wide cap.
|
|
74
|
+
*
|
|
75
|
+
* Enforced by `JobDriver.leases` — a row every replica can see — and NOT by `limits.ts`, which
|
|
76
|
+
* counts one process's heap. A driver with no lease store cannot hold this cap, so
|
|
77
|
+
* `createWorker().start()` refuses to boot rather than let it pass silently
|
|
78
|
+
* (`X_JOB_CONCURRENCY_UNENFORCEABLE`): this field was declared, documented and in the manifest
|
|
79
|
+
* while nothing read it, which is exactly what axiom 3 exists to prevent.
|
|
80
|
+
*/
|
|
50
81
|
readonly concurrency?: number;
|
|
51
82
|
readonly timeout?: DurationInput;
|
|
83
|
+
/**
|
|
84
|
+
* Ceiling for ONE `step.run`, where `timeout` is the ceiling for the whole attempt. Folded into
|
|
85
|
+
* the signal the step body is handed, so a body reads one signal and sees whichever deadline
|
|
86
|
+
* lands first — and it ABORTS before it rejects, because the attempt that replaces this one is
|
|
87
|
+
* claimable the moment the nack lands.
|
|
88
|
+
*
|
|
89
|
+
* Declared here and nowhere else: the runner has implemented this ceiling since 1.0 and no
|
|
90
|
+
* declaration could ask for it, which is a documented guarantee that does nothing.
|
|
91
|
+
*/
|
|
92
|
+
readonly stepTimeout?: DurationInput;
|
|
93
|
+
/**
|
|
94
|
+
* How long a `step.waitForEvent` parks between polls. Default 30s. Lower it for a wait a user
|
|
95
|
+
* is watching; the step suspends for exactly this long each time, so it is also the resolution
|
|
96
|
+
* of the resume, never a busy loop.
|
|
97
|
+
*/
|
|
98
|
+
readonly eventPoll?: DurationInput;
|
|
52
99
|
run(args: JobRunArgs<I>): Promise<unknown>;
|
|
53
100
|
}
|
|
54
101
|
|
|
@@ -58,6 +105,18 @@ export interface JobDefinition<I> {
|
|
|
58
105
|
*/
|
|
59
106
|
export interface JobActor {
|
|
60
107
|
readonly orgId?: string | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* The actor's id, recorded as `enqueuedBy` — ATTRIBUTION, never authority.
|
|
110
|
+
*
|
|
111
|
+
* The framework picks one answer to "whose permissions does a job run with" (axiom 1) and it is
|
|
112
|
+
* this: a job body runs with SYSTEM authority and this is an audit column. Impersonating the
|
|
113
|
+
* enqueuer at claim time is the defensible alternative and is rejected for one reason — a job
|
|
114
|
+
* that sleeps three days, or dead-letters and is retried next quarter, would then act as
|
|
115
|
+
* somebody whose role, org membership or employment has changed since. `02-primitives.md`
|
|
116
|
+
* already frames a job as server-authoritative work. A job that must act FOR a user takes that
|
|
117
|
+
* user's id in its input and re-authorises it in the body, where the check is visible in review.
|
|
118
|
+
*/
|
|
119
|
+
readonly id?: string | undefined;
|
|
61
120
|
}
|
|
62
121
|
|
|
63
122
|
/**
|
|
@@ -71,9 +130,20 @@ export interface JobHandle<I = unknown> {
|
|
|
71
130
|
readonly retry: RetryPolicy;
|
|
72
131
|
readonly concurrency: number | undefined;
|
|
73
132
|
readonly timeoutMs: number | undefined;
|
|
133
|
+
/** The declared per-step ceiling in ms; `executeJob` hands it to the step runner. */
|
|
134
|
+
readonly stepTimeoutMs: number | undefined;
|
|
135
|
+
/** The declared event-poll interval in ms; `undefined` leaves the runner's 30s default. */
|
|
136
|
+
readonly eventPollMs: number | undefined;
|
|
74
137
|
readonly input: StandardSchemaV1<unknown, I>;
|
|
75
138
|
parse(raw: unknown): I;
|
|
76
139
|
idempotencyKeyFor(input: I): string;
|
|
140
|
+
/**
|
|
141
|
+
* The org THIS payload's run acts under — `undefined` for `tenant: 'none'`. A method and never a
|
|
142
|
+
* `readonly tenant: JobTenant<I>` field: a function-typed property is contravariant in its
|
|
143
|
+
* parameter, so `JobHandle<OrgInput>` would stop being assignable to `AnyJobHandle` and the
|
|
144
|
+
* registry, the worker and a task's enqueue list could no longer hold heterogeneous handles.
|
|
145
|
+
*/
|
|
146
|
+
tenantFor(input: I): string | undefined;
|
|
77
147
|
run(args: JobRunArgs<I>): Promise<unknown>;
|
|
78
148
|
/**
|
|
79
149
|
* Put this job on the queue. Joins the caller's transaction when the app installed the
|
|
@@ -112,15 +182,48 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
|
112
182
|
anonymous += 1;
|
|
113
183
|
const name = definition.name ?? `anonymous-job-${anonymous}`;
|
|
114
184
|
|
|
115
|
-
// Runtime
|
|
185
|
+
// Runtime backstops for generated code and JS callers; TS already forbids omitting either.
|
|
116
186
|
if (typeof definition.idempotencyKey !== 'function') {
|
|
117
187
|
throw new IdempotencyRequiredError({ job: name });
|
|
118
188
|
}
|
|
189
|
+
assertJobTenant(name, definition.tenant);
|
|
119
190
|
assert(
|
|
120
191
|
definition.retry.attempts >= 1,
|
|
121
192
|
`job "${name}" needs retry.attempts >= 1, got ${String(definition.retry.attempts)}`,
|
|
122
193
|
`set retry: { attempts: 1 } or higher on job("${name}") — 0 attempts means the job is never executed at all, not that it never retries`,
|
|
123
194
|
);
|
|
195
|
+
// `concurrency: 0` is not "no cap" — it is a fleet slot table that grants nothing.
|
|
196
|
+
// `createFleetSlots.acquire` reads `limit === undefined` as uncapped, so a declared `0` reaches
|
|
197
|
+
// `leases.acquire(key, 0, …)`, answers `false` forever with no log line, and the job is
|
|
198
|
+
// permanently unrunnable. Refused where it is written, the way `createPacer` refuses `rate: 0`.
|
|
199
|
+
assert(
|
|
200
|
+
definition.concurrency === undefined ||
|
|
201
|
+
(Number.isInteger(definition.concurrency) && definition.concurrency >= 1),
|
|
202
|
+
`job "${name}" declares concurrency ${String(definition.concurrency)}, which no worker can ever fill`,
|
|
203
|
+
`set a whole concurrency of 1 or more on job("${name}"), or omit the field for no cap at all`,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const stepTimeoutMs =
|
|
207
|
+
definition.stepTimeout === undefined ? undefined : toMs(definition.stepTimeout);
|
|
208
|
+
const eventPollMs = definition.eventPoll === undefined ? undefined : toMs(definition.eventPoll);
|
|
209
|
+
// `withStepTimeout` reads `<= 0` as "no ceiling at all" and a poll of zero is a suspension that
|
|
210
|
+
// resumes immediately, forever. Both are an author who asked for a limit and got the opposite,
|
|
211
|
+
// so they are refused where they are written — the same answer `concurrency: 0` gets.
|
|
212
|
+
//
|
|
213
|
+
// FINITE, not merely positive: `> 0` admits `Infinity`, which is the same defect spelled the
|
|
214
|
+
// other way. `eventPoll: Infinity` parks a waiting step and schedules the poll that would wake
|
|
215
|
+
// it for never; `stepTimeout: Infinity` is a ceiling no step can reach. `NaN` fails `> 0` on its
|
|
216
|
+
// own, and is covered here so the predicate says what it means rather than passing by accident.
|
|
217
|
+
assert(
|
|
218
|
+
stepTimeoutMs === undefined || (Number.isFinite(stepTimeoutMs) && stepTimeoutMs > 0),
|
|
219
|
+
`job "${name}" declares stepTimeout ${String(definition.stepTimeout)}, which is no ceiling at all`,
|
|
220
|
+
`set a finite positive stepTimeout on job("${name}") — "30s" or 30_000 — or omit the field for no per-step ceiling`,
|
|
221
|
+
);
|
|
222
|
+
assert(
|
|
223
|
+
eventPollMs === undefined || (Number.isFinite(eventPollMs) && eventPollMs > 0),
|
|
224
|
+
`job "${name}" declares eventPoll ${String(definition.eventPoll)}, which parks a waiting step for no time at all`,
|
|
225
|
+
`set a finite positive eventPoll on job("${name}") — "5s" or 5_000 — or omit the field for the 30s default`,
|
|
226
|
+
);
|
|
124
227
|
|
|
125
228
|
const handle: JobHandle<I> = {
|
|
126
229
|
kind: 'job',
|
|
@@ -129,6 +232,8 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
|
129
232
|
retry: { ...DEFAULT_RETRY, ...definition.retry },
|
|
130
233
|
concurrency: definition.concurrency,
|
|
131
234
|
timeoutMs: definition.timeout === undefined ? undefined : toMs(definition.timeout),
|
|
235
|
+
stepTimeoutMs,
|
|
236
|
+
eventPollMs,
|
|
132
237
|
input: definition.input,
|
|
133
238
|
parse(raw: unknown): I {
|
|
134
239
|
return parse(definition.input, raw) as I;
|
|
@@ -142,6 +247,9 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
|
142
247
|
);
|
|
143
248
|
return key;
|
|
144
249
|
},
|
|
250
|
+
tenantFor(input: I): string | undefined {
|
|
251
|
+
return jobTenantFor(name, definition.tenant, input);
|
|
252
|
+
},
|
|
145
253
|
run(args: JobRunArgs<I>): Promise<unknown> {
|
|
146
254
|
return definition.run(args);
|
|
147
255
|
},
|
|
@@ -152,9 +260,15 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
|
152
260
|
const tenantId = options.tenantId ?? tenantFor(actor);
|
|
153
261
|
// `NO_TENANT` is the limiter's own bucket for an absent tenant, so leaving the column
|
|
154
262
|
// empty is the same limit and one less fake org id on the row.
|
|
263
|
+
//
|
|
264
|
+
// `enqueuedBy` is the actor's id and NOTHING ELSE crosses: the body runs with system
|
|
265
|
+
// authority, so this is an audit column, not a principal. See `JobActor.id` for why the
|
|
266
|
+
// framework chose attribution over impersonation.
|
|
267
|
+
const enqueuedBy = options.enqueuedBy ?? actor?.id;
|
|
155
268
|
return handle.enqueue(input, {
|
|
156
269
|
...options,
|
|
157
270
|
...(tenantId === NO_TENANT ? {} : { tenantId }),
|
|
271
|
+
...(enqueuedBy === undefined ? {} : { enqueuedBy }),
|
|
158
272
|
});
|
|
159
273
|
},
|
|
160
274
|
// Reads `handle`, never the captured `name`: `nameJobs()` rebinds the property in place.
|
|
@@ -241,8 +355,18 @@ export function getJob(name: string): AnyJobHandle | undefined {
|
|
|
241
355
|
return registry.get(name);
|
|
242
356
|
}
|
|
243
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Code-unit compare, never `localeCompare`. This list is projected into `x.manifest.json`, which
|
|
360
|
+
* both tracked apps COMMIT and `x verify`'s drift step diffs byte for byte — and `localeCompare`
|
|
361
|
+
* with no locale argument answers from the runtime's ICU default and collation version, so the
|
|
362
|
+
* same source could sort two ways on two machines. `@ultimat3/http`'s `describeRoutes` states the
|
|
363
|
+
* same rule; the comparator is restated rather than imported because `http` is not below this
|
|
364
|
+
* package on the tier table.
|
|
365
|
+
*/
|
|
366
|
+
const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
367
|
+
|
|
244
368
|
export function registeredJobs(): readonly AnyJobHandle[] {
|
|
245
|
-
return [...registry.values()].sort((a, b) => a.name
|
|
369
|
+
return [...registry.values()].sort((a, b) => byName(a.name, b.name));
|
|
246
370
|
}
|
|
247
371
|
|
|
248
372
|
export function resetJobs(): void {
|
package/src/leases.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Fleet-wide slot counting: the seam that makes `job.concurrency` true. `limits.ts` counts slots
|
|
2
|
+
// in ONE process's heap, so `perTenant: 2` on twenty pods is forty concurrent runs — the number a
|
|
3
|
+
// downstream API rate-limits you for. A lease is a row somewhere every replica can see, held for
|
|
4
|
+
// a TTL and renewed by the same heartbeat that renews the visibility lease, so a killed worker
|
|
5
|
+
// gives its slot back by expiry rather than by cleanup nobody runs.
|
|
6
|
+
|
|
7
|
+
import type { Clock } from '@ultimat3/core';
|
|
8
|
+
import { systemClock } from '@ultimat3/core';
|
|
9
|
+
import { nowMs } from './clock';
|
|
10
|
+
|
|
11
|
+
/** A granted slot. `slot` plus `holder` is what renew and release are addressed by. */
|
|
12
|
+
export interface HeldLease {
|
|
13
|
+
readonly key: string;
|
|
14
|
+
readonly slot: number;
|
|
15
|
+
readonly holder: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LeaseStore {
|
|
19
|
+
/**
|
|
20
|
+
* Take a slot under `limit` for `key`, or answer `undefined`. Never over-grants; under
|
|
21
|
+
* contention it may refuse a slot that is genuinely free, which costs one poll interval.
|
|
22
|
+
*/
|
|
23
|
+
acquire(
|
|
24
|
+
key: string,
|
|
25
|
+
limit: number,
|
|
26
|
+
ttlMs: number,
|
|
27
|
+
holder: string,
|
|
28
|
+
): Promise<HeldLease | undefined>;
|
|
29
|
+
/** Push the expiry out. `false` means the slot is no longer this holder's. */
|
|
30
|
+
renew(lease: HeldLease, ttlMs: number): Promise<boolean>;
|
|
31
|
+
release(lease: HeldLease): Promise<void>;
|
|
32
|
+
/** Live slots for `key`. Diagnostics only — never the acquire decision, which must be atomic. */
|
|
33
|
+
held(key: string): Promise<number>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MemoryLeaseStoreOptions {
|
|
37
|
+
readonly clock?: Clock;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The `x dev` / test implementation. One heap, so it is not a fleet gate — it exists so the
|
|
42
|
+
* memory driver enforces `concurrency` with the same code path the pg driver does, and so the
|
|
43
|
+
* "a second worker is refused" test is a real test rather than a pg-only one.
|
|
44
|
+
*/
|
|
45
|
+
export function createMemoryLeaseStore(options: MemoryLeaseStoreOptions = {}): LeaseStore {
|
|
46
|
+
const clock = options.clock ?? systemClock;
|
|
47
|
+
const slots = new Map<string, Map<number, { holder: string; expiresAt: number }>>();
|
|
48
|
+
|
|
49
|
+
const live = (key: string): Map<number, { holder: string; expiresAt: number }> => {
|
|
50
|
+
const at = nowMs(clock);
|
|
51
|
+
const held = slots.get(key) ?? new Map();
|
|
52
|
+
for (const [slot, entry] of held) if (entry.expiresAt <= at) held.delete(slot);
|
|
53
|
+
slots.set(key, held);
|
|
54
|
+
return held;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
acquire(key, limit, ttlMs, holder) {
|
|
59
|
+
const held = live(key);
|
|
60
|
+
for (let slot = 0; slot < limit; slot += 1) {
|
|
61
|
+
if (held.has(slot)) continue;
|
|
62
|
+
held.set(slot, { holder, expiresAt: nowMs(clock) + ttlMs });
|
|
63
|
+
return Promise.resolve({ key, slot, holder });
|
|
64
|
+
}
|
|
65
|
+
return Promise.resolve(undefined);
|
|
66
|
+
},
|
|
67
|
+
renew(lease, ttlMs) {
|
|
68
|
+
const entry = live(lease.key).get(lease.slot);
|
|
69
|
+
if (entry === undefined || entry.holder !== lease.holder) return Promise.resolve(false);
|
|
70
|
+
entry.expiresAt = nowMs(clock) + ttlMs;
|
|
71
|
+
return Promise.resolve(true);
|
|
72
|
+
},
|
|
73
|
+
release(lease) {
|
|
74
|
+
const held = slots.get(lease.key);
|
|
75
|
+
const entry = held?.get(lease.slot);
|
|
76
|
+
// Only the holder gives it back: releasing a slot the TTL already handed to someone else
|
|
77
|
+
// would let two runs share it, which is the whole failure this store exists to prevent.
|
|
78
|
+
if (entry !== undefined && entry.holder === lease.holder) held?.delete(lease.slot);
|
|
79
|
+
return Promise.resolve();
|
|
80
|
+
},
|
|
81
|
+
held(key) {
|
|
82
|
+
return Promise.resolve(live(key).size);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The lease key for a job's own fleet-wide cap. One shape, so pg and memory agree on it. */
|
|
88
|
+
export function jobLeaseKey(jobName: string): string {
|
|
89
|
+
return `job:${jobName}`;
|
|
90
|
+
}
|
package/src/limits.ts
CHANGED
|
Binary file
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The two queue gauges an alert can actually be written against. `queue_depth` and `jobs_total`
|
|
2
|
+
// live in `@ultimat3/core`'s `runtime-metrics.ts` because the deploy chart scales on them; these
|
|
3
|
+
// two are not scaling signals, they are the ones every queue team pages on, and neither existed:
|
|
4
|
+
//
|
|
5
|
+
// "page if the oldest job in payments is older than 5 minutes" — no series carried
|
|
6
|
+
// `oldestReadyMs`, which `QueueStats` computes, `inspectQueues` renders and nothing published.
|
|
7
|
+
// `queue_depth` cannot tell 10 jobs stuck for an hour from 10 enqueued a second ago.
|
|
8
|
+
// "page if the dead-letter queue is not empty" — `jobs_total{outcome="dead"}` is a COUNTER, so
|
|
9
|
+
// a DLQ that filled overnight and stopped growing has a flat rate and alerts on nothing.
|
|
10
|
+
//
|
|
11
|
+
// Declared here rather than in core because they are this package's facts and core is another
|
|
12
|
+
// package's file; if they move next to `queueDepth` later, these two functions are the only
|
|
13
|
+
// callers to redirect.
|
|
14
|
+
|
|
15
|
+
import type { Gauge } from '@ultimat3/core';
|
|
16
|
+
import { gauge } from '@ultimat3/core';
|
|
17
|
+
|
|
18
|
+
/** Seconds and not milliseconds: every Prometheus duration is seconds, and the alert is `> 300`. */
|
|
19
|
+
export const queueOldestReady: Gauge = gauge('queue_oldest_ready_seconds', {
|
|
20
|
+
unit: 's',
|
|
21
|
+
description: 'Age of the oldest claimable job, by queue — 0 when the queue is empty',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const queueDeadJobs: Gauge = gauge('queue_dead_jobs', {
|
|
25
|
+
unit: '{job}',
|
|
26
|
+
description: 'Jobs sitting in the dead-letter state, by queue',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export function recordQueueOldestReady(queue: string, oldestReadyMs: number): void {
|
|
30
|
+
queueOldestReady.record(oldestReadyMs / 1000, { queue });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function recordQueueDeadJobs(queue: string, dead: number): void {
|
|
34
|
+
queueDeadJobs.record(dead, { queue });
|
|
35
|
+
}
|