@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.
- package/CLAUDE.md +25 -27
- package/README.md +50 -34
- package/package.json +7 -7
- package/src/backfill-errors.ts +3 -2
- package/src/backfill-ledger.ts +1 -1
- package/src/backfill-rate.ts +2 -2
- package/src/backfill.ts +2 -2
- package/src/counters.ts +1 -1
- package/src/driver-memory-operator.ts +3 -3
- package/src/driver-memory.ts +14 -17
- package/src/driver-pg-settle-sql.ts +2 -3
- package/src/driver-pg-sql.ts +1 -1
- package/src/driver-pg.ts +18 -18
- package/src/driver.ts +9 -15
- package/src/errors-concurrency.ts +1 -1
- package/src/errors.ts +5 -5
- package/src/events-pg.ts +4 -4
- package/src/events.ts +6 -6
- package/src/execute.ts +46 -5
- package/src/export.ts +2 -2
- package/src/heartbeat.ts +7 -2
- package/src/index.ts +37 -97
- package/src/inspect-operator.ts +1 -1
- package/src/inspect.ts +1 -1
- package/src/job.ts +1 -1
- package/src/leases.ts +1 -1
- package/src/limits.ts +13 -8
- package/src/outbox-lease.ts +1 -1
- package/src/outbox-pg.ts +5 -5
- package/src/outbox-relay.ts +6 -6
- package/src/outbox.ts +13 -13
- package/src/renewal-timer.ts +1 -1
- package/src/retry-classification.ts +52 -15
- package/src/scheduler-leader.ts +2 -2
- package/src/scheduler-pg.ts +16 -14
- package/src/scheduler-state.ts +2 -2
- package/src/scheduler.ts +5 -5
- package/src/steps-memory.ts +2 -2
- package/src/steps.ts +35 -7
- package/src/task.ts +1 -1
- package/src/webhook-attempt.ts +206 -0
- package/src/webhook-ledger.ts +15 -4
- package/src/webhook.ts +64 -118
- package/src/worker-admit.ts +7 -3
- package/src/worker-drain-cutoff.ts +110 -0
- package/src/worker-fleet-slots.ts +5 -2
- package/src/worker-held.ts +100 -0
- package/src/worker-options.ts +19 -15
- package/src/worker-run.ts +38 -8
- package/src/worker-tally.ts +53 -0
- package/src/worker-types.ts +7 -4
- package/src/worker.ts +85 -77
- package/src/driver-nats.ts +0 -89
- package/src/driver-redis.ts +0 -92
package/src/scheduler-leader.ts
CHANGED
|
@@ -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 `
|
|
19
|
-
* `
|
|
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),
|
package/src/scheduler-pg.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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(() =>
|
|
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 ??
|
|
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
|
|
72
|
+
row.id === null || row.run_id === null
|
|
71
73
|
? []
|
|
72
|
-
: [[row.
|
|
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((
|
|
78
|
-
const inserted = queued.get(
|
|
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
|
|
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. `
|
|
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 `
|
|
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
|
|
123
|
+
export function postgresLeaseLeader(options: PostgresLeaseLeaderOptions): LeaderElection {
|
|
122
124
|
const lockKey = options.lockKey ?? 'scheduler';
|
|
123
|
-
const holder = options.holder ?? `scheduler-${
|
|
125
|
+
const holder = options.holder ?? `scheduler-${uuidV7()}`;
|
|
124
126
|
const ttlMs = finiteOption(
|
|
125
127
|
'the pg scheduler lease',
|
|
126
128
|
'ttlMs',
|
package/src/scheduler-state.ts
CHANGED
|
@@ -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 `
|
|
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
|
|
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` (`
|
|
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 {
|
|
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
|
|
93
|
-
const schedulerState = options.state ??
|
|
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
|
-
'
|
|
96
|
+
'jobScheduler',
|
|
97
97
|
'tickIntervalMs',
|
|
98
98
|
options.tickIntervalMs ?? 1_000,
|
|
99
99
|
);
|
package/src/steps-memory.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// The in-memory `StepStore`: what `
|
|
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
|
|
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
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
+
}
|
package/src/webhook-ledger.ts
CHANGED
|
@@ -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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
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 {
|