@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
package/src/execute.ts
CHANGED
|
@@ -13,17 +13,23 @@ import {
|
|
|
13
13
|
renderThrowable,
|
|
14
14
|
reportError,
|
|
15
15
|
runWithContext,
|
|
16
|
+
stringField,
|
|
16
17
|
useContext,
|
|
17
18
|
withChildContext,
|
|
18
19
|
} from '@ultimat3/core';
|
|
19
20
|
import { nowMs } from './clock';
|
|
20
|
-
import type { ClaimedJob, JobDriver } from './driver';
|
|
21
|
-
import {
|
|
21
|
+
import type { ClaimedJob, JobDriver, SettleBy } from './driver';
|
|
22
|
+
import { claimOf } from './driver';
|
|
23
|
+
import { JobAbortedError } from './errors';
|
|
22
24
|
import { eventBus } from './events';
|
|
23
25
|
import type { AnyJobHandle } from './job';
|
|
26
|
+
import { createProgressReporter } from './progress';
|
|
27
|
+
import { isFinalAttempt } from './retry';
|
|
24
28
|
import type { JobStopReason } from './retry-classification';
|
|
25
|
-
import { nextRetryForError, recordedFailure } from './retry-classification';
|
|
29
|
+
import { failureForRow, nextRetryForError, recordedFailure } from './retry-classification';
|
|
30
|
+
import { raceTimeout } from './run-deadline';
|
|
26
31
|
import { createRunSignal } from './run-signal';
|
|
32
|
+
import { announceSettled, settledCode } from './settled';
|
|
27
33
|
import type { EventLookup, StepRecord } from './steps';
|
|
28
34
|
import { createStepRunner, isStepSuspension } from './steps';
|
|
29
35
|
import { jobRunActor } from './tenant';
|
|
@@ -32,9 +38,20 @@ import { jobRunActor } from './tenant';
|
|
|
32
38
|
* How one attempt ended. `interrupted` is the worker's drain cutting the attempt short: the job is
|
|
33
39
|
* back in the ready bucket with the attempt UNCOUNTED, because the process ended it and not the
|
|
34
40
|
* job — filed as `retried`, a deploy would burn an attempt per job it held, and with
|
|
35
|
-
* `attempts: 1` dead-letter it.
|
|
41
|
+
* `attempts: 1` dead-letter it. `dropped` is a run that failed for good on a job declaring
|
|
42
|
+
* `retry.deadLetter: false`: the row is `failed`, where it used to go back `ready` and run
|
|
43
|
+
* forever. `refused` is `whenBusy: 'fail'` over a busy concurrency key: the
|
|
44
|
+
* row is `failed`, the body never ran, and it is neither a retry nor a dead letter
|
|
45
|
+
* (`worker-key-busy.ts`).
|
|
36
46
|
*/
|
|
37
|
-
export type JobOutcome =
|
|
47
|
+
export type JobOutcome =
|
|
48
|
+
| 'completed'
|
|
49
|
+
| 'suspended'
|
|
50
|
+
| 'retried'
|
|
51
|
+
| 'dead-lettered'
|
|
52
|
+
| 'dropped'
|
|
53
|
+
| 'interrupted'
|
|
54
|
+
| 'refused';
|
|
38
55
|
|
|
39
56
|
/** Stands in for a caller with nothing to cancel, so the composition below has one shape. */
|
|
40
57
|
const NEVER_ABORTED = new AbortController().signal;
|
|
@@ -78,10 +95,13 @@ export interface JobExecution {
|
|
|
78
95
|
readonly job: string;
|
|
79
96
|
readonly attempt: number;
|
|
80
97
|
readonly durationMs: number;
|
|
98
|
+
/** Epoch ms the row is claimable again: a suspension's wake, or a retry's backoff ending. */
|
|
81
99
|
readonly resumeAt?: number;
|
|
82
100
|
readonly error?: string;
|
|
83
101
|
/** Why this attempt was the last. Absent while the job is still being retried. */
|
|
84
102
|
readonly stopReason?: JobStopReason;
|
|
103
|
+
/** What the body returned — present exactly on `completed`, for a caller driving a run by hand. */
|
|
104
|
+
readonly result?: unknown;
|
|
85
105
|
readonly steps: readonly StepRecord[];
|
|
86
106
|
readonly replayed: readonly string[];
|
|
87
107
|
}
|
|
@@ -93,6 +113,12 @@ export interface ExecuteJobOptions {
|
|
|
93
113
|
readonly ctx: Ctx;
|
|
94
114
|
readonly clock?: Clock;
|
|
95
115
|
readonly events?: EventLookup;
|
|
116
|
+
/**
|
|
117
|
+
* Why this run may not start, decided before the body: the worker could not derive the run's
|
|
118
|
+
* concurrency key (its `key` function threw, or the row no longer parses), so it holds no slot
|
|
119
|
+
* and running it would be a run outside its own cap. Failed as an ordinary attempt.
|
|
120
|
+
*/
|
|
121
|
+
readonly refusal?: unknown;
|
|
96
122
|
}
|
|
97
123
|
|
|
98
124
|
/**
|
|
@@ -135,13 +161,59 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
135
161
|
events: options.events ?? eventBus(),
|
|
136
162
|
});
|
|
137
163
|
|
|
164
|
+
// Who settles, and how long the attempt took — the fence and the counter's duration, in one
|
|
165
|
+
// value both settles are handed.
|
|
166
|
+
const by = (): SettleBy => ({
|
|
167
|
+
...claimOf(claimed),
|
|
168
|
+
durationMs: nowMs(options.clock) - startedAt,
|
|
169
|
+
});
|
|
170
|
+
const introspect = driver.introspect;
|
|
171
|
+
const progress = createProgressReporter({
|
|
172
|
+
job: handle.name,
|
|
173
|
+
jobId: claimed.id,
|
|
174
|
+
write:
|
|
175
|
+
introspect === undefined
|
|
176
|
+
? undefined
|
|
177
|
+
: (value) => introspect.recordProgress(claimed.id, claimOf(claimed), value),
|
|
178
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
179
|
+
});
|
|
180
|
+
/**
|
|
181
|
+
* A settle that matched nothing: the row is another worker's now — this one's lease lapsed and
|
|
182
|
+
* the queue re-delivered — or it was cancelled. Logged, never thrown: theirs is the verdict.
|
|
183
|
+
*/
|
|
184
|
+
const landed = (did: boolean, settling: string): boolean => {
|
|
185
|
+
if (!did) {
|
|
186
|
+
logger.warn('jobs.settle.unowned', {
|
|
187
|
+
job: handle.name,
|
|
188
|
+
jobId: claimed.id,
|
|
189
|
+
workerId: claimed.claimedBy,
|
|
190
|
+
settling,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return did;
|
|
194
|
+
};
|
|
195
|
+
/** The parsed payload, once it has parsed — what `onSettled` is handed. */
|
|
196
|
+
let parsed: { readonly value: unknown } | undefined;
|
|
197
|
+
/** What the body returned, for `onSettled` and for the execution this answers. */
|
|
198
|
+
let result: unknown;
|
|
199
|
+
/**
|
|
200
|
+
* Runs `fn` as the body runs: `ctx` ambient, the job's DECLARED tenant on the actor. Until the
|
|
201
|
+
* payload has parsed there is no tenant to derive, so the worker's own context stands in — and
|
|
202
|
+
* a tenant-scoped read under it fails closed, which is right for a payload nobody could read.
|
|
203
|
+
*/
|
|
204
|
+
let inRunScope = <T>(fn: () => T): T => runWithContext(ctx, fn);
|
|
205
|
+
|
|
138
206
|
const settle = async (outcome: JobExecution): Promise<JobExecution> => {
|
|
139
207
|
const steps = await driver.steps.list(claimed.runId);
|
|
140
208
|
return { ...outcome, steps, replayed: runner.replayedNames() };
|
|
141
209
|
};
|
|
142
210
|
|
|
143
211
|
try {
|
|
212
|
+
// Raised INSIDE the try so it takes the one failure path below — classified, nacked, logged
|
|
213
|
+
// and reported exactly as if the body had thrown it — and the body never starts.
|
|
214
|
+
if (options.refusal !== undefined) throw options.refusal;
|
|
144
215
|
const input = handle.parse(claimed.input);
|
|
216
|
+
parsed = { value: input };
|
|
145
217
|
// The job's DECLARED tenant, on the actor the body runs as. `tenant: 'none'` strips the org
|
|
146
218
|
// rather than inheriting the worker's, so a tenant-scoped read inside such a job fails closed.
|
|
147
219
|
const runActor = jobRunActor(callerActor(ctx), handle.tenantFor(input));
|
|
@@ -156,31 +228,39 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
156
228
|
// still answer the worker's org while every ambient repository call answered the job's — one
|
|
157
229
|
// run acting as two tenants, which is the same hole one layer up. It rebuilds every managed
|
|
158
230
|
// factory against `runActor` and carries only the services no factory owns.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
231
|
+
inRunScope = (fn) => runWithContext(ctx, () => withChildContext({ actor: runActor }, fn));
|
|
232
|
+
const work = inRunScope(() =>
|
|
233
|
+
handle.run({
|
|
234
|
+
input,
|
|
235
|
+
step: runner.step,
|
|
236
|
+
// The child itself, never a rebuilt sibling: the ctx a body is HANDED and the ctx the
|
|
237
|
+
// entity guard READS have to be one object, which is what `tenancy-cross-surface` pins.
|
|
238
|
+
ctx: useContext(),
|
|
239
|
+
attempt: claimed.attempt,
|
|
240
|
+
// The comparison `nextRetry` stops on, over the same two operands — never a second one.
|
|
241
|
+
finalAttempt: isFinalAttempt(handle.retry, claimed.attempt),
|
|
242
|
+
progress: progress.report,
|
|
243
|
+
jobId: claimed.id,
|
|
244
|
+
runId: claimed.runId,
|
|
245
|
+
}),
|
|
172
246
|
);
|
|
173
247
|
|
|
174
|
-
await (handle.timeoutMs === undefined
|
|
248
|
+
result = await (handle.timeoutMs === undefined
|
|
175
249
|
? work
|
|
176
250
|
: raceTimeout(work, handle.timeoutMs, handle.name, cancel));
|
|
177
251
|
} catch (error) {
|
|
252
|
+
// Before ANY settle: the last value a body reported is written while the row is still this
|
|
253
|
+
// worker's, which is the only time `recordProgress`'s fence lets it land.
|
|
254
|
+
await progress.flush();
|
|
178
255
|
if (isStepSuspension(error)) {
|
|
179
256
|
const delayMs = Math.max(0, error.resumeAt - nowMs(options.clock));
|
|
180
257
|
// `park: true` is the suspension itself — the row leaves the ready bucket — and
|
|
181
258
|
// `countsAsAttempt: false` only says not to burn an attempt on it. A limiter shed passes the
|
|
182
259
|
// second and not the first: it is a job still waiting, and it belongs in `queue_depth`.
|
|
183
|
-
|
|
260
|
+
landed(
|
|
261
|
+
await driver.nack(claimed.id, { ...by(), delayMs, countsAsAttempt: false, park: true }),
|
|
262
|
+
'suspended',
|
|
263
|
+
);
|
|
184
264
|
return settle({
|
|
185
265
|
outcome: 'suspended',
|
|
186
266
|
jobId: claimed.id,
|
|
@@ -203,7 +283,15 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
203
283
|
// not the error, because the body's error is not always the signal's reason, and an
|
|
204
284
|
// attempt burned per deploy is the "always twice" draining exists to prevent. The `error`
|
|
205
285
|
// is still recorded on the row: `x jobs show` should say why the last attempt ended.
|
|
206
|
-
|
|
286
|
+
landed(
|
|
287
|
+
await driver.nack(claimed.id, {
|
|
288
|
+
...by(),
|
|
289
|
+
delayMs: 0,
|
|
290
|
+
error: failureForRow(error),
|
|
291
|
+
countsAsAttempt: false,
|
|
292
|
+
}),
|
|
293
|
+
'interrupted',
|
|
294
|
+
);
|
|
207
295
|
logger.info('jobs.attempt.interrupted', {
|
|
208
296
|
job: handle.name,
|
|
209
297
|
jobId: claimed.id,
|
|
@@ -228,12 +316,24 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
228
316
|
// classified keeps the attempt-count path exactly as it was.
|
|
229
317
|
const decision = nextRetryForError(handle.retry, claimed.attempt, error);
|
|
230
318
|
const stop = decision.stoppedBy;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
319
|
+
// Failed for good with `deadLetter: false`: the row is DROPPED — settled `failed`. It was
|
|
320
|
+
// nacked with neither flag, which both drivers file `ready`, so a job its author declared
|
|
321
|
+
// "do not keep" was re-claimed and re-run forever with its attempt counter climbing.
|
|
322
|
+
const dropped = !decision.retry && !decision.deadLetter;
|
|
323
|
+
const recorded = recordedFailure(failureForRow(error), decision);
|
|
324
|
+
const stack = stringField(error, 'stack');
|
|
325
|
+
const settled = landed(
|
|
326
|
+
await driver.nack(claimed.id, {
|
|
327
|
+
...by(),
|
|
328
|
+
delayMs: decision.delayMs,
|
|
329
|
+
error: recorded,
|
|
330
|
+
...(stack === undefined ? {} : { stack }),
|
|
331
|
+
countsAsAttempt: true,
|
|
332
|
+
deadLetter: !decision.retry && decision.deadLetter,
|
|
333
|
+
fail: dropped,
|
|
334
|
+
}),
|
|
335
|
+
decision.retry ? 'retried' : dropped ? 'dropped' : 'dead-lettered',
|
|
336
|
+
);
|
|
237
337
|
logger.warn('jobs.attempt.failed', {
|
|
238
338
|
job: handle.name,
|
|
239
339
|
jobId: claimed.id,
|
|
@@ -263,13 +363,32 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
263
363
|
},
|
|
264
364
|
},
|
|
265
365
|
});
|
|
366
|
+
// After the row is settled, and only by the worker whose settle landed: a run this worker no
|
|
367
|
+
// longer owns is not this worker's ending to announce. Under the body's own scope, so the
|
|
368
|
+
// hook's tenant-scoped writes — "mark this connection broken" — are the job's tenant's.
|
|
369
|
+
if (!decision.retry && settled) {
|
|
370
|
+
await announceSettled({
|
|
371
|
+
handle,
|
|
372
|
+
claimed,
|
|
373
|
+
ctx,
|
|
374
|
+
run: { scope: inRunScope, input: parsed },
|
|
375
|
+
settlement: {
|
|
376
|
+
outcome: dropped ? 'dropped' : 'dead-lettered',
|
|
377
|
+
error: recorded,
|
|
378
|
+
code: settledCode(error),
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
266
382
|
return settle({
|
|
267
|
-
outcome: decision.retry ? 'retried' : 'dead-lettered',
|
|
383
|
+
outcome: decision.retry ? 'retried' : dropped ? 'dropped' : 'dead-lettered',
|
|
268
384
|
jobId: claimed.id,
|
|
269
385
|
job: handle.name,
|
|
270
386
|
attempt: claimed.attempt,
|
|
271
387
|
durationMs: nowMs(options.clock) - startedAt,
|
|
272
388
|
error: message,
|
|
389
|
+
// When the retry falls due, on this process's clock — what lets the worker that failed it
|
|
390
|
+
// pass again at that moment instead of finding out on a backed-off poll.
|
|
391
|
+
...(decision.retry ? { resumeAt: nowMs(options.clock) + decision.delayMs } : {}),
|
|
273
392
|
...(stop === undefined ? {} : { stopReason: stop }),
|
|
274
393
|
steps: [],
|
|
275
394
|
replayed: [],
|
|
@@ -291,65 +410,30 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
291
410
|
// the run as `retried`, so `jobs_total{outcome}` would count a failure that never happened.
|
|
292
411
|
// Let it reach the worker instead, which logs `jobs.worker.settle-failed`; the lease then
|
|
293
412
|
// lapses and the queue re-delivers, which is the honest outcome for "we could not say it ended".
|
|
294
|
-
await
|
|
413
|
+
await progress.flush();
|
|
414
|
+
// Announced only when the ack LANDED: a row cancelled or re-delivered under this body is
|
|
415
|
+
// somebody else's to settle, and `completed` would be a claim about a row that is not `done`.
|
|
416
|
+
if (landed(await driver.ack(claimed.id, by()), 'completed')) {
|
|
417
|
+
await announceSettled({
|
|
418
|
+
handle,
|
|
419
|
+
claimed,
|
|
420
|
+
ctx,
|
|
421
|
+
run: { scope: inRunScope, input: parsed },
|
|
422
|
+
settlement: { outcome: 'completed', result },
|
|
423
|
+
});
|
|
424
|
+
}
|
|
295
425
|
return settle({
|
|
296
426
|
outcome: 'completed',
|
|
297
427
|
jobId: claimed.id,
|
|
298
428
|
job: handle.name,
|
|
299
429
|
attempt: claimed.attempt,
|
|
300
430
|
durationMs: nowMs(options.clock) - startedAt,
|
|
431
|
+
result,
|
|
301
432
|
steps: [],
|
|
302
433
|
replayed: [],
|
|
303
434
|
});
|
|
304
435
|
}
|
|
305
436
|
|
|
306
|
-
/**
|
|
307
|
-
* The run's deadline. It CANCELS before it rejects, and the order is the whole point: the caller
|
|
308
|
-
* nacks on this rejection and the queue hands the job straight to another worker, so the body has
|
|
309
|
-
* to have been told to stop before that becomes possible.
|
|
310
|
-
*
|
|
311
|
-
* Nothing in JS can kill a body that ignores the signal, so what is left is to say so: a run that
|
|
312
|
-
* settles after its deadline logs `jobs.timeout.abandoned`, which is how an app finds the handler
|
|
313
|
-
* that never reads `ctx.signal`. A body that stopped BECAUSE it was cancelled is the intended end
|
|
314
|
-
* and stays quiet.
|
|
315
|
-
*/
|
|
316
|
-
function raceTimeout(
|
|
317
|
-
work: Promise<unknown>,
|
|
318
|
-
timeoutMs: number,
|
|
319
|
-
job: string,
|
|
320
|
-
cancel: AbortController,
|
|
321
|
-
): Promise<unknown> {
|
|
322
|
-
return new Promise((resolve, reject) => {
|
|
323
|
-
let expired = false;
|
|
324
|
-
const timer = setTimeout(() => {
|
|
325
|
-
expired = true;
|
|
326
|
-
const failure = new JobTimeoutError({ job, timeoutMs });
|
|
327
|
-
cancel.abort(failure);
|
|
328
|
-
reject(failure);
|
|
329
|
-
}, timeoutMs);
|
|
330
|
-
const abandoned = (ended: 'resolved' | 'rejected', error?: unknown): void => {
|
|
331
|
-
logger.warn('jobs.timeout.abandoned', {
|
|
332
|
-
job,
|
|
333
|
-
timeoutMs,
|
|
334
|
-
ended,
|
|
335
|
-
...(error === undefined ? {} : { error: renderThrowable(error) }),
|
|
336
|
-
});
|
|
337
|
-
};
|
|
338
|
-
work.then(
|
|
339
|
-
(value) => {
|
|
340
|
-
clearTimeout(timer);
|
|
341
|
-
if (expired) abandoned('resolved');
|
|
342
|
-
else resolve(value);
|
|
343
|
-
},
|
|
344
|
-
(error) => {
|
|
345
|
-
clearTimeout(timer);
|
|
346
|
-
if (!expired) reject(error);
|
|
347
|
-
else if (!isCancellation(error, cancel.signal.reason)) abandoned('rejected', error);
|
|
348
|
-
},
|
|
349
|
-
);
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
|
|
353
437
|
/**
|
|
354
438
|
* The run was cancelled by the worker's drain: the code `JobDrainedError` carries, read off the
|
|
355
439
|
* signal. The FIRST reason wins on a controller, so a timeout that fired before the drain still
|
|
@@ -358,8 +442,3 @@ function raceTimeout(
|
|
|
358
442
|
function drainedBy(signal: AbortSignal): boolean {
|
|
359
443
|
return signal.aborted && isUltimateError(signal.reason) && signal.reason.code === 'X_DRAINING';
|
|
360
444
|
}
|
|
361
|
-
|
|
362
|
-
/** The body stopped because we cancelled it: our own reason back, or a fenced step write. */
|
|
363
|
-
function isCancellation(error: unknown, reason: unknown): boolean {
|
|
364
|
-
return error === reason || (isUltimateError(error) && error.code === 'X_ABORTED');
|
|
365
|
-
}
|
package/src/export.ts
CHANGED
|
@@ -93,8 +93,13 @@ export interface ExportDefinition<Row, I> {
|
|
|
93
93
|
readonly columns: readonly string[];
|
|
94
94
|
/** One row -> its cells. Where a date gets its explicit zone and a Money its currency. */
|
|
95
95
|
row(record: Row): ExportRecord;
|
|
96
|
-
/**
|
|
97
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Where the parts land — a THUNK, read per write: `sink: () => disk('exports')`. An export is
|
|
98
|
+
* declared at module evaluation and the app's disks exist after `defineStorage()`, so a value
|
|
99
|
+
* here (`disk()` called at import) failed before boot. A `StorageDriver` is an `ExportSink` by
|
|
100
|
+
* having the method it already has.
|
|
101
|
+
*/
|
|
102
|
+
readonly sink: () => ExportSink;
|
|
98
103
|
/** Rows per statement, per step and per part. Defaults to `DEFAULT_EXPORT_BATCH`. */
|
|
99
104
|
readonly batch?: number;
|
|
100
105
|
/** Defaults to `DEFAULT_EXPORT_MAX_PART_BYTES`. */
|
|
@@ -124,9 +129,14 @@ export interface ExportReport {
|
|
|
124
129
|
readonly bytes: number;
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
/** The declaration as the pass reads it: the sink a value whose every `put` reads the thunk. */
|
|
133
|
+
export type PlannedExport<Row, I> = Omit<ExportDefinition<Row, I>, 'sink'> & {
|
|
134
|
+
readonly sink: ExportSink;
|
|
135
|
+
};
|
|
136
|
+
|
|
127
137
|
/** Everything fixed at declaration, validated and built once. Read by `exportPass`. */
|
|
128
138
|
export interface ExportPlan<Row, I> {
|
|
129
|
-
readonly definition:
|
|
139
|
+
readonly definition: PlannedExport<Row, I>;
|
|
130
140
|
readonly size: number;
|
|
131
141
|
readonly maxPartBytes: number;
|
|
132
142
|
/** Absent unless a `rate` was declared — see `ExportDefinition.rate`. */
|
|
@@ -169,6 +179,12 @@ export function exportRows<Row, I>(definition: ExportDefinition<Row, I>): JobHan
|
|
|
169
179
|
? undefined
|
|
170
180
|
: createPacer({ rate: definition.rate, job: definition.name, clock });
|
|
171
181
|
|
|
182
|
+
// Resolved per write, never here: this line runs at the app's import, before its storage exists.
|
|
183
|
+
const planned: PlannedExport<Row, I> = {
|
|
184
|
+
...definition,
|
|
185
|
+
sink: { put: (key, body) => definition.sink().put(key, body) },
|
|
186
|
+
};
|
|
187
|
+
|
|
172
188
|
return job<I>({
|
|
173
189
|
name: definition.name,
|
|
174
190
|
input: definition.input,
|
|
@@ -182,6 +198,9 @@ export function exportRows<Row, I>(definition: ExportDefinition<Row, I>): JobHan
|
|
|
182
198
|
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
183
199
|
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
184
200
|
run: (args) =>
|
|
185
|
-
exportPass<Row, I>(
|
|
201
|
+
exportPass<Row, I>(
|
|
202
|
+
{ definition: planned, size, maxPartBytes, pace, nowMs: () => nowMs(clock) },
|
|
203
|
+
args,
|
|
204
|
+
),
|
|
186
205
|
});
|
|
187
206
|
}
|
package/src/heartbeat.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { logger, recordLeaseLost, renderThrowable } from '@ultimat3/core';
|
|
|
8
8
|
import { nowMs } from './clock';
|
|
9
9
|
import type { ClaimedJob, JobDriver } from './driver';
|
|
10
10
|
import { LeaseLostError } from './errors';
|
|
11
|
-
import { startRenewalTimer } from './renewal-timer';
|
|
11
|
+
import { type IntervalScheduler, startRenewalTimer } from './renewal-timer';
|
|
12
12
|
|
|
13
13
|
export interface LeaseHeartbeatOptions {
|
|
14
14
|
/** Only `heartbeat` is used — a lease renews itself and settles nothing. */
|
|
@@ -18,6 +18,8 @@ export interface LeaseHeartbeatOptions {
|
|
|
18
18
|
readonly intervalMs: number;
|
|
19
19
|
readonly workerId: string;
|
|
20
20
|
readonly clock?: Clock;
|
|
21
|
+
/** What every renewal runs on (`renewal-timer.ts`). Default: a real, unrefed interval. */
|
|
22
|
+
readonly schedule?: IntervalScheduler;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface LeaseHeartbeat {
|
|
@@ -97,7 +99,11 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
97
99
|
if (renewing) return;
|
|
98
100
|
renewing = true;
|
|
99
101
|
try {
|
|
100
|
-
const held = await options.driver.heartbeat(claimed.id, {
|
|
102
|
+
const held = await options.driver.heartbeat(claimed.id, {
|
|
103
|
+
visibilityTimeoutMs,
|
|
104
|
+
workerId,
|
|
105
|
+
claim: claimed.claim,
|
|
106
|
+
});
|
|
101
107
|
// Re-read AFTER the await, never only before it: the whole point of the flag is the answer
|
|
102
108
|
// that lands past `stop()`. Every branch below decides something about a lease this process
|
|
103
109
|
// may no longer be running under.
|
|
@@ -140,7 +146,7 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
140
146
|
}
|
|
141
147
|
};
|
|
142
148
|
|
|
143
|
-
const timer = startRenewalTimer(options.intervalMs, renew);
|
|
149
|
+
const timer = startRenewalTimer(options.intervalMs, renew, options.schedule);
|
|
144
150
|
|
|
145
151
|
return { renew, lost: () => lost, signal: gone.signal, stop: () => timer.stop() };
|
|
146
152
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// How long a polling loop waits before its next pass: the floor while there is work, doubling to
|
|
2
|
+
// a declared ceiling while there is none. The worker and the outbox relay both polled at a fixed
|
|
3
|
+
// interval — 250 ms and 200 ms — so a queue with nothing in it cost a statement every pass, for
|
|
4
|
+
// ever: 9 to 13 queries a second from an idle pod. One definition, so both loops back off alike.
|
|
5
|
+
|
|
6
|
+
import { backoffDelay, finiteOption } from '@ultimat3/core';
|
|
7
|
+
import { wakeIsLive } from './enqueue-signal';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The longest an idle loop waits between passes when nothing else can wake it — and so the worst
|
|
11
|
+
* case added to the pickup of a job ANOTHER process committed. It is the guarantee: a wake is an
|
|
12
|
+
* optimisation that a pooling proxy, a lost session or a missed notification takes away.
|
|
13
|
+
*/
|
|
14
|
+
export const IDLE_POLL_CEILING_MS = 2_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The ceiling once a cross-process wake is PROVEN (`wakeIsLive()`): work committed anywhere
|
|
18
|
+
* announces itself, so the poll is only the backstop — for a notification lost with a session,
|
|
19
|
+
* and for a row that becomes due by the clock rather than by a commit.
|
|
20
|
+
*/
|
|
21
|
+
export const WOKEN_IDLE_POLL_CEILING_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
/** The first step when the floor is 0 (a loop that re-arms at once while there is work). */
|
|
24
|
+
const MIN_STEP_MS = 25;
|
|
25
|
+
|
|
26
|
+
export interface IdleBackoff {
|
|
27
|
+
/** The delay before the next pass, given whether the last one found work. */
|
|
28
|
+
next(foundWork: boolean): number;
|
|
29
|
+
/** Work arrived from outside a pass: the next delay is the floor again. */
|
|
30
|
+
reset(): void;
|
|
31
|
+
/** True once a pass has come back empty — the loop is idling. */
|
|
32
|
+
idle(): boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createIdleBackoff(options: {
|
|
36
|
+
readonly subject: string;
|
|
37
|
+
readonly floorMs: number;
|
|
38
|
+
readonly ceilingMs?: number;
|
|
39
|
+
}): IdleBackoff {
|
|
40
|
+
const floor = finiteOption(options.subject, 'pollIntervalMs', options.floorMs);
|
|
41
|
+
const declared =
|
|
42
|
+
options.ceilingMs === undefined
|
|
43
|
+
? undefined
|
|
44
|
+
: finiteOption(options.subject, 'idlePollMaxMs', options.ceilingMs);
|
|
45
|
+
// Read per pass, never once: the wake is proven after the loop starts and can be lost again.
|
|
46
|
+
const ceiling = (): number =>
|
|
47
|
+
Math.max(floor, declared ?? (wakeIsLive() ? WOKEN_IDLE_POLL_CEILING_MS : IDLE_POLL_CEILING_MS));
|
|
48
|
+
/** Empty passes in a row. */
|
|
49
|
+
let empty = 0;
|
|
50
|
+
return {
|
|
51
|
+
next(foundWork) {
|
|
52
|
+
if (foundWork) {
|
|
53
|
+
empty = 0;
|
|
54
|
+
return floor;
|
|
55
|
+
}
|
|
56
|
+
empty += 1;
|
|
57
|
+
// floor, 2x, 4x, … — the FIRST empty pass waits the floor, so one quiet poll costs nothing.
|
|
58
|
+
// The doubling is core's curve, never a second one: no jitter, because every loop here polls
|
|
59
|
+
// its own queue and a herd of idle workers is the cheap case.
|
|
60
|
+
if (empty === 1) return floor;
|
|
61
|
+
return backoffDelay({
|
|
62
|
+
attempt: empty,
|
|
63
|
+
base: Math.max(floor, MIN_STEP_MS),
|
|
64
|
+
max: ceiling(),
|
|
65
|
+
curve: 'exponential',
|
|
66
|
+
jitter: 'none',
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
reset() {
|
|
70
|
+
empty = 0;
|
|
71
|
+
},
|
|
72
|
+
idle: () => empty > 0,
|
|
73
|
+
};
|
|
74
|
+
}
|