@ultimat3/jobs 24.0.0 → 25.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 (54) hide show
  1. package/CLAUDE.md +25 -27
  2. package/README.md +50 -34
  3. package/package.json +7 -7
  4. package/src/backfill-errors.ts +3 -2
  5. package/src/backfill-ledger.ts +1 -1
  6. package/src/backfill-rate.ts +2 -2
  7. package/src/backfill.ts +2 -2
  8. package/src/counters.ts +1 -1
  9. package/src/driver-memory-operator.ts +3 -3
  10. package/src/driver-memory.ts +14 -17
  11. package/src/driver-pg-settle-sql.ts +2 -3
  12. package/src/driver-pg-sql.ts +1 -1
  13. package/src/driver-pg.ts +18 -18
  14. package/src/driver.ts +9 -15
  15. package/src/errors-concurrency.ts +1 -1
  16. package/src/errors.ts +5 -5
  17. package/src/events-pg.ts +4 -4
  18. package/src/events.ts +6 -6
  19. package/src/execute.ts +46 -5
  20. package/src/export.ts +2 -2
  21. package/src/heartbeat.ts +7 -2
  22. package/src/index.ts +37 -97
  23. package/src/inspect-operator.ts +1 -1
  24. package/src/inspect.ts +1 -1
  25. package/src/job.ts +1 -1
  26. package/src/leases.ts +1 -1
  27. package/src/limits.ts +13 -8
  28. package/src/outbox-lease.ts +1 -1
  29. package/src/outbox-pg.ts +5 -5
  30. package/src/outbox-relay.ts +6 -6
  31. package/src/outbox.ts +13 -13
  32. package/src/renewal-timer.ts +1 -1
  33. package/src/retry-classification.ts +52 -15
  34. package/src/scheduler-leader.ts +2 -2
  35. package/src/scheduler-pg.ts +16 -14
  36. package/src/scheduler-state.ts +2 -2
  37. package/src/scheduler.ts +5 -5
  38. package/src/steps-memory.ts +2 -2
  39. package/src/steps.ts +35 -7
  40. package/src/task.ts +1 -1
  41. package/src/webhook-attempt.ts +206 -0
  42. package/src/webhook-ledger.ts +15 -4
  43. package/src/webhook.ts +64 -118
  44. package/src/worker-admit.ts +7 -3
  45. package/src/worker-drain-cutoff.ts +110 -0
  46. package/src/worker-fleet-slots.ts +5 -2
  47. package/src/worker-held.ts +100 -0
  48. package/src/worker-options.ts +19 -15
  49. package/src/worker-run.ts +38 -8
  50. package/src/worker-tally.ts +53 -0
  51. package/src/worker-types.ts +7 -4
  52. package/src/worker.ts +85 -77
  53. package/src/driver-nats.ts +0 -89
  54. package/src/driver-redis.ts +0 -92
@@ -15,8 +15,8 @@ export interface LeaderElection {
15
15
  readonly renewEveryMs: number;
16
16
  }
17
17
 
18
- /** Single-node default: always the leader. Multi-node uses `createPgLeaseLeader()` — never
19
- * `createPgLeader()`, whose advisory lock is owned by a pooled session this process cannot name. */
18
+ /** Single-node default: always the leader. Multi-node uses `postgresLeaseLeader()` — never
19
+ * `postgresLeader()`, whose advisory lock is owned by a pooled session this process cannot name. */
20
20
  export function soleLeader(): LeaderElection {
21
21
  return {
22
22
  acquire: () => Promise.resolve(true),
@@ -4,7 +4,7 @@
4
4
  // pod it replaced dropped, and two pods in a rolling update both dispatch every task.
5
5
 
6
6
  import type { Clock, PgExecutor } from '@ultimat3/core';
7
- import { finiteOption, uuid } from '@ultimat3/core';
7
+ import { finiteOption, uuidV7 } from '@ultimat3/core';
8
8
  import { nowMs } from './clock';
9
9
  import { DEFAULT_QUEUE } from './driver';
10
10
  import { SQL_SCHEDULER_FIRE } from './driver-pg-operator-sql';
@@ -24,7 +24,7 @@ import { fireThroughDriver } from './scheduler-state';
24
24
  * whose watermark dies with its process has none of them — it takes the arming branch on every
25
25
  * boot and drops every occurrence between the two processes with nothing logged.
26
26
  */
27
- export function pgSchedulerState(executor: PgExecutor): SchedulerState {
27
+ export function postgresSchedulerState(executor: PgExecutor): SchedulerState {
28
28
  const state: SchedulerState = {
29
29
  async lastFiredAt(taskName) {
30
30
  const rows = await executor.query<{ last_fired_at: number | string | null }>(
@@ -41,12 +41,11 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
41
41
  // One statement only when the queue IS this database. A host that swapped the driver keeps
42
42
  // the watermark here and its jobs elsewhere, and no statement spans the two.
43
43
  if (driver.name !== 'pg') return fireThroughDriver(state, driver, fire);
44
- const ids = fire.jobs.map(() => uuid());
44
+ const ids = fire.jobs.map(() => uuidV7());
45
45
  const rows = await executor.query<{
46
46
  fired: number | string;
47
47
  id: string | null;
48
48
  run_id: string | null;
49
- idempotency_key: string | null;
50
49
  }>(SQL_SCHEDULER_FIRE, [
51
50
  fire.task,
52
51
  fire.occurrenceMs,
@@ -57,7 +56,7 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
57
56
  queue: request.queue || DEFAULT_QUEUE,
58
57
  input: request.input ?? null,
59
58
  idempotency_key: request.idempotencyKey,
60
- run_id: request.runId ?? uuid(),
59
+ run_id: request.runId ?? uuidV7(),
61
60
  max_attempts: request.maxAttempts,
62
61
  })),
63
62
  ),
@@ -65,17 +64,20 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
65
64
  fire.watermarkMs ?? null,
66
65
  ]);
67
66
  if (Number(rows[0]?.fired ?? 0) === 0) return undefined;
67
+ // Keyed on the id THIS statement minted (`ids[index]`), never on the idempotency key: the
68
+ // key is unique per (name, tenant, key), so two jobs of one occurrence may share it, and a
69
+ // map by key told both callers the second one's id — where the memory store tells each its own.
68
70
  const queued = new Map(
69
71
  rows.flatMap((row) =>
70
- row.id === null || row.run_id === null || row.idempotency_key === null
72
+ row.id === null || row.run_id === null
71
73
  ? []
72
- : [[row.idempotency_key, { id: row.id, runId: row.run_id }] as const],
74
+ : [[row.id.toLowerCase(), { id: row.id, runId: row.run_id }] as const],
73
75
  ),
74
76
  );
75
77
  // A job absent from the answer met a LIVE row of its key — a fire from before this
76
78
  // statement existed, still in flight. It is that row's work, so it is reported deduped.
77
- return fire.jobs.map((request) => {
78
- const inserted = queued.get(request.idempotencyKey);
79
+ return fire.jobs.map((_, index) => {
80
+ const inserted = queued.get(String(ids[index]).toLowerCase());
79
81
  return inserted === undefined
80
82
  ? { id: '', runId: '', deduped: true }
81
83
  : { ...inserted, deduped: false };
@@ -85,7 +87,7 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
85
87
  return state;
86
88
  }
87
89
 
88
- export interface PgLeaseLeaderOptions {
90
+ export interface PostgresLeaseLeaderOptions {
89
91
  readonly executor: PgExecutor;
90
92
  /** One key per elected role. Default `'scheduler'`. */
91
93
  readonly lockKey?: string;
@@ -107,9 +109,9 @@ export const LEASE_RENEWALS_PER_TTL = 3;
107
109
 
108
110
  /**
109
111
  * Leader election as an expiring row, which is what makes it correct on the executor this package
110
- * is actually handed: a POOL. `createPgLeader`'s `pg_try_advisory_lock` is session-scoped, and a
112
+ * is actually handed: a POOL. `postgresLeader`'s `pg_try_advisory_lock` is session-scoped, and a
111
113
  * session on a pool ends the moment the connection goes back — so every node reads itself as
112
- * leader and a rolling update double-fires every task. `@ultimat3/realtime`'s `PgAdvisoryLock`
114
+ * leader and a rolling update double-fires every task. `@ultimat3/realtime`'s `postgresAdvisoryLock()`
113
115
  * solves the same problem by owning its connection; this package holds no wire protocol, so it
114
116
  * solves it with a row instead.
115
117
  *
@@ -118,9 +120,9 @@ export const LEASE_RENEWALS_PER_TTL = 3;
118
120
  * with nothing to clean up, which is the one property the advisory lock had and a plain
119
121
  * `insert ... on conflict do nothing` would not.
120
122
  */
121
- export function createPgLeaseLeader(options: PgLeaseLeaderOptions): LeaderElection {
123
+ export function postgresLeaseLeader(options: PostgresLeaseLeaderOptions): LeaderElection {
122
124
  const lockKey = options.lockKey ?? 'scheduler';
123
- const holder = options.holder ?? `scheduler-${uuid()}`;
125
+ const holder = options.holder ?? `scheduler-${uuidV7()}`;
124
126
  const ttlMs = finiteOption(
125
127
  'the pg scheduler lease',
126
128
  'ttlMs',
@@ -39,7 +39,7 @@ export interface SchedulerState {
39
39
  /**
40
40
  * The fire for a store that cannot share a statement with the queue: the watermark is checked,
41
41
  * the jobs go through `driver.enqueue`, and the watermark moves last. Not atomic across a crash —
42
- * which is why `pgSchedulerState` does not use it against the pg driver — but exact for the
42
+ * which is why `postgresSchedulerState` does not use it against the pg driver — but exact for the
43
43
  * memory pair, where nothing can fail between the steps and no second process exists.
44
44
  */
45
45
  export async function fireThroughDriver(
@@ -58,7 +58,7 @@ export async function fireThroughDriver(
58
58
  return results;
59
59
  }
60
60
 
61
- export function createMemorySchedulerState(): SchedulerState {
61
+ export function memorySchedulerState(): SchedulerState {
62
62
  const fired = new Map<string, number>();
63
63
  const state: SchedulerState = {
64
64
  lastFiredAt: (taskName) => Promise.resolve(fired.get(taskName)),
package/src/scheduler.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // it owes and enqueues their jobs. The `task` primitive it reads lives in `task.ts`.
3
3
  //
4
4
  // Exactly one node dispatches per tick, enforced by leader election. Multi-node that is an
5
- // EXPIRING LEASE ROW in `x_scheduler_leader` (`createPgLeaseLeader`), never an advisory lock: an
5
+ // EXPIRING LEASE ROW in `x_scheduler_leader` (`postgresLeaseLeader`), never an advisory lock: an
6
6
  // advisory lock is held by the SESSION, not by this process — it outlives every transaction and is
7
7
  // released only by an explicit unlock, the pool's reset on release, or the connection dying, and
8
8
  // the next round may run on a different connection. So a node can neither renew it nor prove it
@@ -31,7 +31,7 @@ import type { LeaderElection } from './scheduler-leader';
31
31
  import { soleLeader } from './scheduler-leader';
32
32
  import { defaultCronResolver, latestOccurrence, occurrencesIn } from './scheduler-occurrences';
33
33
  import type { SchedulerState } from './scheduler-state';
34
- import { createMemorySchedulerState } from './scheduler-state';
34
+ import { memorySchedulerState } from './scheduler-state';
35
35
  import type { TaskHandle, TaskJobResult } from './task';
36
36
  import { registeredTasks } from './task';
37
37
 
@@ -89,11 +89,11 @@ export interface Scheduler {
89
89
  nextRunFor(handle: TaskHandle, from?: Date): Date;
90
90
  }
91
91
 
92
- export function createScheduler(options: SchedulerOptions): Scheduler {
93
- const schedulerState = options.state ?? createMemorySchedulerState();
92
+ export function jobScheduler(options: SchedulerOptions): Scheduler {
93
+ const schedulerState = options.state ?? memorySchedulerState();
94
94
  const resolveCron = options.cron ?? defaultCronResolver;
95
95
  const tickIntervalMs = finiteOption(
96
- 'createScheduler',
96
+ 'jobScheduler',
97
97
  'tickIntervalMs',
98
98
  options.tickIntervalMs ?? 1_000,
99
99
  );
@@ -1,11 +1,11 @@
1
- // The in-memory `StepStore`: what `createMemoryDriver` and every runner test persist steps into.
1
+ // The in-memory `StepStore`: what `memoryJobDriver` and every runner test persist steps into.
2
2
  // Split from `steps.ts` at the file-size ceiling, along the seam the pg driver already draws —
3
3
  // `driver-pg.ts` holds the Postgres store, this file the map-backed one, `steps.ts` the runner
4
4
  // both are handed to. Same contract, and `steps.test.ts` is where the contract is pinned.
5
5
 
6
6
  import type { StepRecord, StepStore } from './steps';
7
7
 
8
- export function createMemoryStepStore(): StepStore {
8
+ export function memoryStepStore(): StepStore {
9
9
  const byRun = new Map<string, Map<string, StepRecord>>();
10
10
  const runOf = (runId: string): Map<string, StepRecord> => {
11
11
  let run = byRun.get(runId);
package/src/steps.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // catches it and re-queues the job for `resumeAt` instead of holding a process for three days.
8
8
 
9
9
  import type { Clock } from '@ultimat3/core';
10
- import { finiteOption, logger, renderThrowable } from '@ultimat3/core';
10
+ import { finiteOption, isUltimateError, logger, renderThrowable } from '@ultimat3/core';
11
11
  import { expectedQueryLoop } from '@ultimat3/db';
12
12
  import type { DurationInput } from './clock';
13
13
  import { finiteDurationMs, nowMs } from './clock';
@@ -138,6 +138,13 @@ export interface StepRunnerOptions {
138
138
  * store belongs to whichever attempt has it now.
139
139
  */
140
140
  readonly signal?: AbortSignal;
141
+ /**
142
+ * Aborted the moment the ATTEMPT is over — settled, or past its job deadline. `signal` alone
143
+ * cannot say so once the drain has aborted it first (a controller keeps its first reason), and
144
+ * the drain's reason writes through `put` (`writesThrough`): this is what still refuses a step
145
+ * left in flight by a body that returned without awaiting it. `executeJob` passes it.
146
+ */
147
+ readonly ended?: AbortSignal;
141
148
  }
142
149
 
143
150
  export interface StepRunner {
@@ -168,7 +175,7 @@ function trace(into: string[], name: string): void {
168
175
  if (into.length > MAX_TRACE_NAMES) into.shift();
169
176
  }
170
177
 
171
- export function createStepRunner(options: StepRunnerOptions): StepRunner {
178
+ export function stepRunner(options: StepRunnerOptions): StepRunner {
172
179
  const { runId, jobName, store } = options;
173
180
  /** Every name this attempt has claimed. Membership only — the trace is `used`. */
174
181
  const claimed = new Set<string>();
@@ -176,6 +183,13 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
176
183
  const replayed: string[] = [];
177
184
  const clock = options.clock;
178
185
  const pollMs = finiteOption('step.waitForEvent', 'eventPollMs', options.eventPollMs ?? 30_000);
186
+ // `job()` refuses a non-finite `stepTimeout` at declaration (`job.ts`); screened again here, where
187
+ // it is read, because this runner is also built directly and `withStepTimeout` reads a `NaN`
188
+ // ceiling as none at all — the backstop `pollMs` above already is.
189
+ const stepTimeoutMs =
190
+ options.stepTimeoutMs === undefined
191
+ ? undefined
192
+ : finiteOption('step.run', 'stepTimeoutMs', options.stepTimeoutMs);
179
193
  const runSignal = options.signal ?? NEVER_ABORTED;
180
194
 
181
195
  /**
@@ -233,6 +247,19 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
233
247
 
234
248
  const cancelled = (): boolean => runSignal.aborted;
235
249
 
250
+ /**
251
+ * A cancelled run whose step result is still this attempt's to record: the worker's drain cut
252
+ * it short (`X_DRAINING`, `worker-drain-cutoff.ts`), the write is fenced on the claim, and the
253
+ * attempt has not ended. The side effect behind the record HAPPENED — refused, the next worker
254
+ * runs it again. The fence is what makes this safe and not the in-process signal: a row the
255
+ * drain has already handed back answers the write `X_JOB_LEASE_LOST` in the store.
256
+ */
257
+ const writesThrough = (): boolean =>
258
+ options.fence !== undefined &&
259
+ options.ended?.aborted !== true &&
260
+ isUltimateError(runSignal.reason) &&
261
+ runSignal.reason.code === 'X_DRAINING';
262
+
236
263
  /**
237
264
  * The statement itself, declared deliberate to the N+1 detector. One write per step IS the
238
265
  * design this file's header states — each step completes at its own instant and its output has
@@ -255,7 +282,9 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
255
282
  * never ran — or overwrite one it did. The write is refused, and refusing it unwinds the body.
256
283
  */
257
284
  const put = async (record: StepRecord): Promise<void> => {
258
- if (cancelled()) throw new JobAbortedError({ job: jobName, step: record.name });
285
+ if (cancelled() && !writesThrough()) {
286
+ throw new JobAbortedError({ job: jobName, step: record.name });
287
+ }
259
288
  await persist(record);
260
289
  remember(record);
261
290
  };
@@ -283,15 +312,14 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
283
312
  // to compose, and DISPOSED in the `finally` below, which is the whole reason `run-signal.ts`
284
313
  // exists — `worker-run.ts` disposes the run's own the same way.
285
314
  const composed =
286
- options.stepTimeoutMs === undefined ? null : createRunSignal([runSignal, deadline.signal]);
315
+ stepTimeoutMs === undefined ? null : createRunSignal([runSignal, deadline.signal]);
287
316
  const signal = composed?.signal ?? runSignal;
288
317
  try {
289
318
  const output = await withStepTimeout(
290
319
  fn(signal),
291
- options.stepTimeoutMs,
320
+ stepTimeoutMs,
292
321
  deadline,
293
- () =>
294
- new JobTimeoutError({ job: jobName, step: name, timeoutMs: options.stepTimeoutMs ?? 0 }),
322
+ () => new JobTimeoutError({ job: jobName, step: name, timeoutMs: stepTimeoutMs ?? 0 }),
295
323
  );
296
324
  // Persist BEFORE returning: a crash one line later must not re-run this step.
297
325
  await put({
package/src/task.ts CHANGED
@@ -141,7 +141,7 @@ export function task(definition: TaskDefinition): TaskHandle {
141
141
  // `for (let i = 0; i < handle.maxCatchUp; i += 1)`, so zero (and any negative, and any fraction
142
142
  // below one) returns an empty list on every round and the task NEVER fires: no error, no log
143
143
  // line, no queue row, forever. Refused where it is written, exactly as `job()` refuses
144
- // `concurrency: 0` and `createPacer` refuses `rate: 0`. `Number.isInteger` covers `NaN` and
144
+ // `concurrency: 0` and `backfillPacer` refuses `rate: 0`. `Number.isInteger` covers `NaN` and
145
145
  // `Infinity` in the same predicate — an unbounded catch-up is a burst nobody declared.
146
146
  assert(
147
147
  definition.maxCatchUp === undefined ||
@@ -0,0 +1,206 @@
1
+ // One delivery attempt on the wire: the headers it carries, the POST, what the answer means, and
2
+ // which of the three failure codes it becomes. Everything BEFORE the socket — the endpoint, the
3
+ // screen, the event, the gates — and everything AFTER it — the ledger, disable-after-N — is
4
+ // `webhook.ts`'s; this file only ever sees an approved target and a signed body.
5
+
6
+ import {
7
+ // Core's ONE table, never a fifth copy of it. The copy that lived here omitted 409, so a
8
+ // receiver saying "a concurrent writer won this round" dead-lettered on attempt 1 as a refusal
9
+ // no retry could change — the exact divergence `retryable-status.ts` was extracted to end.
10
+ isRetryableStatus,
11
+ isUltimateError,
12
+ renderThrowable,
13
+ retryAfterSecondsOf,
14
+ webhookHeaders,
15
+ } from '@ultimat3/core';
16
+ import { JobTimeoutError } from './errors';
17
+ import {
18
+ WebhookDeliveryFailedError,
19
+ WebhookDeliveryRejectedError,
20
+ WebhookDeliveryThrottledError,
21
+ } from './webhook-errors';
22
+ import type { WebhookTarget } from './webhook-target';
23
+
24
+ /**
25
+ * The transport — just the call: `typeof fetch` also carries `preconnect`, which no test double
26
+ * should have to. `init.tls.serverName` carries the hostname a PINNED connection proves, because
27
+ * the url it is handed names the approved address (`webhook-target.ts`); Bun's `fetch` honours it.
28
+ */
29
+ export type WebhookFetch = (
30
+ url: string,
31
+ init: RequestInit & { readonly tls?: { readonly serverName?: string } },
32
+ ) => Promise<Response>;
33
+
34
+ /** The one content type a delivery announces. The BODY is the app's; how it is framed is not. */
35
+ export const WEBHOOK_CONTENT_TYPE = 'application/json';
36
+
37
+ /** What of a `WebhookEndpoint` an attempt reads. Never the secret: that is inside `signing`. */
38
+ export interface DeliveryEndpoint {
39
+ readonly id: string;
40
+ readonly url: string;
41
+ readonly headers?: Readonly<Record<string, string>>;
42
+ }
43
+
44
+ export type Outcome =
45
+ | { readonly ok: true; readonly status: number }
46
+ | {
47
+ readonly ok: false;
48
+ readonly status: number | null;
49
+ readonly detail: string;
50
+ readonly retryAfterSeconds?: number;
51
+ };
52
+
53
+ /**
54
+ * The receiver's `Retry-After`, through core's one reader: delta-seconds as before, and now the
55
+ * HTTP-date form too, measured against the SAME response's `Date` — the receiver's clock against
56
+ * the receiver's clock, which is what made the date safe to read without trusting ours. Capped at
57
+ * a day, and the nack clamps it to the policy's `maxDelay` on top.
58
+ */
59
+ const retryAfterSeconds = (response: Response): number | undefined =>
60
+ retryAfterSecondsOf(response.headers.get('retry-after'), response.headers.get('date'));
61
+
62
+ /**
63
+ * The row's own headers, then the framework's — each SET, case-insensitively, over whatever the row
64
+ * said. An object spread cannot do this: `{ Host: 'evil.test', host: <pinned> }` keeps both keys and
65
+ * `fetch` joins them, so a row would reach another virtual host, change the content type, and ship
66
+ * a forged value inside the signature list. Going through `Headers` collapses every spelling of a
67
+ * name to one entry before the framework's value replaces it. Returned as a plain lowercase record,
68
+ * because an injected `WebhookFetch` has always been handed one.
69
+ *
70
+ * Inside the caller's `try`: a row header the platform refuses (a newline in a value) is the same
71
+ * failed attempt it always was, never a bare `TypeError` out of the job.
72
+ */
73
+ function deliveryHeaders(
74
+ endpoint: DeliveryEndpoint,
75
+ target: WebhookTarget,
76
+ signing: Parameters<typeof webhookHeaders>[0],
77
+ ): Record<string, string> {
78
+ const headers = new Headers(endpoint.headers);
79
+ // The name the pinned address is reached AS, so an endpoint cannot redirect the request to a
80
+ // different virtual host than the one it registered.
81
+ headers.set('host', target.host);
82
+ headers.set('content-type', WEBHOOK_CONTENT_TYPE);
83
+ // The signature and the event identity it covers: a row can never overwrite what it is proved by.
84
+ for (const [name, value] of Object.entries(webhookHeaders(signing))) headers.set(name, value);
85
+ return Object.fromEntries(headers.entries());
86
+ }
87
+
88
+ /** The reason `JobDrainedError` carries — the one cancellation a request on the wire outlives. */
89
+ const DRAINING = 'X_DRAINING';
90
+
91
+ const isDrain = (reason: unknown): boolean => isUltimateError(reason) && reason.code === DRAINING;
92
+
93
+ /** The request's own signal, and the listener it hangs on the attempt's, handed back after. */
94
+ export interface DeliverySignal {
95
+ readonly signal: AbortSignal;
96
+ dispose(): void;
97
+ }
98
+
99
+ /** Where this attempt's own deadline falls, on `performance.now()`'s clock. */
100
+ export interface AttemptDeadline {
101
+ readonly atMs: number;
102
+ readonly job: string;
103
+ readonly timeoutMs: number;
104
+ }
105
+
106
+ /**
107
+ * The attempt's signal for every reason but the DRAIN. A deadline or a lost lease is followed by a
108
+ * nack that hands the job to another worker at once, so a request still open is the receiver
109
+ * getting the event twice: those tear it down. The drain is the opposite case — tearing a POST
110
+ * already on the wire down hands the job to the next pod, which re-POSTs the same event on every
111
+ * deploy — so the request finishes inside the drain budget instead, and its outcome is recorded.
112
+ *
113
+ * Not past the attempt's DEADLINE, though. The attempt's signal keeps its FIRST reason, so once the
114
+ * drain has aborted it the deadline behind it never arrives there — while `executeJob` still hands
115
+ * the job on when the deadline passes. So a drained request arms the deadline itself.
116
+ */
117
+ export function deliverySignal(attempt: AbortSignal, deadline?: AttemptDeadline): DeliverySignal {
118
+ const request = new AbortController();
119
+ let timer: ReturnType<typeof setTimeout> | undefined;
120
+ const follow = (): void => {
121
+ if (!isDrain(attempt.reason)) {
122
+ request.abort(attempt.reason);
123
+ return;
124
+ }
125
+ if (deadline === undefined) return;
126
+ timer = setTimeout(
127
+ () =>
128
+ request.abort(new JobTimeoutError({ job: deadline.job, timeoutMs: deadline.timeoutMs })),
129
+ Math.max(0, deadline.atMs - performance.now()),
130
+ );
131
+ };
132
+ if (attempt.aborted) follow();
133
+ else attempt.addEventListener('abort', follow, { once: true });
134
+ return {
135
+ signal: request.signal,
136
+ dispose: () => {
137
+ attempt.removeEventListener('abort', follow);
138
+ if (timer !== undefined) clearTimeout(timer);
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * One POST, on `deliverySignal`'s signal: torn down by the deadline or a lost lease, never by the
145
+ * drain.
146
+ */
147
+ export async function attemptDelivery(
148
+ send: WebhookFetch,
149
+ endpoint: DeliveryEndpoint,
150
+ target: WebhookTarget,
151
+ signing: Parameters<typeof webhookHeaders>[0],
152
+ signal: AbortSignal,
153
+ ): Promise<Outcome> {
154
+ let response: Response;
155
+ try {
156
+ response = await send(target.url, {
157
+ method: 'POST',
158
+ headers: deliveryHeaders(endpoint, target, signing),
159
+ ...(target.serverName === undefined ? {} : { tls: { serverName: target.serverName } }),
160
+ body: signing.body,
161
+ // Never followed: a 3xx would re-POST a body signed for one host to whatever the receiver
162
+ // named, and the signature would travel with it.
163
+ redirect: 'manual',
164
+ signal,
165
+ });
166
+ } catch (error) {
167
+ // `renderThrowable`, never `String(error)` or `${error}`: this string lands in a `cause` and on
168
+ // a durable ledger row, and a null-prototype throwable makes both of those a `TypeError`.
169
+ return { ok: false, status: null, detail: renderThrowable(error) };
170
+ }
171
+ if (response.ok) return { ok: true, status: response.status };
172
+ const stated = retryAfterSeconds(response);
173
+ return {
174
+ ok: false,
175
+ status: response.status,
176
+ detail: `status ${response.status}`,
177
+ ...(stated === undefined ? {} : { retryAfterSeconds: stated }),
178
+ };
179
+ }
180
+
181
+ /** Which of the three failure codes this outcome is. The split is "can the same request land?". */
182
+ export function deliveryError(
183
+ name: string,
184
+ endpoint: DeliveryEndpoint,
185
+ outcome: Extract<Outcome, { ok: false }>,
186
+ ): Error {
187
+ const base = { webhook: name, endpointId: endpoint.id, url: endpoint.url };
188
+ if (outcome.status === null) {
189
+ return new WebhookDeliveryFailedError({ ...base, status: null, detail: outcome.detail });
190
+ }
191
+ if (!isRetryableStatus(outcome.status)) {
192
+ return new WebhookDeliveryRejectedError({ ...base, status: outcome.status });
193
+ }
194
+ if (outcome.retryAfterSeconds !== undefined) {
195
+ return new WebhookDeliveryThrottledError({
196
+ ...base,
197
+ status: outcome.status,
198
+ retryAfterSeconds: outcome.retryAfterSeconds,
199
+ });
200
+ }
201
+ return new WebhookDeliveryFailedError({
202
+ ...base,
203
+ status: outcome.status,
204
+ detail: outcome.detail,
205
+ });
206
+ }
@@ -39,11 +39,19 @@ export interface WebhookLedger {
39
39
  */
40
40
  record(attempt: WebhookAttempt): Promise<number>;
41
41
  /**
42
- * Stop delivering to this endpoint. Called once, on the attempt whose count reached
43
- * `disableAfter`. Re-enabling is the app's — a dead endpoint that heals itself is a retry loop
44
- * with no end.
42
+ * Stop delivering to this endpoint. Called by the attempt whose count reached `disableAfter` —
43
+ * and, rarely, by a second attempt that was already in flight when the first one crossed it, so
44
+ * an implementation is IDEMPOTENT: disabling a disabled endpoint changes nothing. Re-enabling is
45
+ * the app's — a dead endpoint that heals itself is a retry loop with no end.
45
46
  */
46
47
  disable(endpointId: string, reason: string): Promise<void>;
48
+ /**
49
+ * Whether `disable` has switched this endpoint off. Asked before every attempt opens a socket,
50
+ * because `disable` is the only thing the mechanism writes: a ledger that recorded the verdict
51
+ * and was never asked for it again is an endpoint that keeps receiving POSTs. An app whose
52
+ * `disable` sets its endpoint row's `disabled` column answers from that same column.
53
+ */
54
+ isDisabled(endpointId: string): Promise<boolean>;
47
55
  }
48
56
 
49
57
  /** Attempts one process keeps before the oldest go. A dev ledger that grows forever is a leak. */
@@ -88,9 +96,12 @@ export function memoryWebhookLedger(
88
96
  return Promise.resolve(failures);
89
97
  },
90
98
  disable(endpointId: string, reason: string): Promise<void> {
91
- off.set(endpointId, reason);
99
+ // The FIRST reason stays: it names the run that switched the endpoint off, and a late
100
+ // in-flight attempt repeating the verdict must not overwrite it.
101
+ if (!off.has(endpointId)) off.set(endpointId, reason);
92
102
  return Promise.resolve();
93
103
  },
104
+ isDisabled: (endpointId: string): Promise<boolean> => Promise.resolve(off.has(endpointId)),
94
105
  attempts: (): readonly WebhookAttempt[] => [...attempts],
95
106
  disabled: (): ReadonlyMap<string, string> => new Map(off),
96
107
  reset(): void {