@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
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 { JobAbortedError, JobTimeoutError } from './errors';
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 = 'completed' | 'suspended' | 'retried' | 'dead-lettered' | 'interrupted';
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
- const work = runWithContext(ctx, () =>
160
- withChildContext({ actor: runActor }, () =>
161
- handle.run({
162
- input,
163
- step: runner.step,
164
- // The child itself, never a rebuilt sibling: the ctx a body is HANDED and the ctx the
165
- // entity guard READS have to be one object, which is what `tenancy-cross-surface` pins.
166
- ctx: useContext(),
167
- attempt: claimed.attempt,
168
- jobId: claimed.id,
169
- runId: claimed.runId,
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
- await driver.nack(claimed.id, { delayMs, countsAsAttempt: false, park: true });
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
- await driver.nack(claimed.id, { delayMs: 0, error: message, countsAsAttempt: false });
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
- await driver.nack(claimed.id, {
232
- delayMs: decision.delayMs,
233
- error: recordedFailure(message, decision),
234
- countsAsAttempt: true,
235
- deadLetter: !decision.retry && decision.deadLetter,
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 driver.ack(claimed.id);
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
- /** Where the parts land. A `StorageDriver` satisfies this by having the method it already has. */
97
- readonly sink: ExportSink;
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: ExportDefinition<Row, I>;
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>({ definition, size, maxPartBytes, pace, nowMs: () => nowMs(clock) }, args),
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, { visibilityTimeoutMs, workerId });
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
+ }