@ultimat3/jobs 20.2.1 → 22.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 +198 -927
- package/README.md +56 -6
- package/package.json +6 -6
- package/src/driver-memory.ts +31 -6
- package/src/driver-pg-jobs-sql.ts +34 -3
- package/src/driver-pg-sql.ts +5 -4
- package/src/driver-pg.ts +25 -13
- package/src/driver.ts +16 -1
- package/src/errors-declaration.ts +34 -0
- package/src/errors-requeue.ts +34 -0
- package/src/errors.ts +18 -4
- package/src/index.ts +2 -0
- package/src/job.ts +22 -3
- package/src/scheduler.ts +59 -38
- package/src/steps-memory.ts +5 -1
- package/src/steps.ts +21 -2
- package/src/task.ts +6 -1
- package/src/webhook-errors.ts +5 -2
- package/src/webhook-target.ts +98 -0
- package/src/webhook.ts +57 -17
- package/src/worker-hand-back.ts +30 -0
- package/src/worker-queue-depth.ts +61 -0
- package/src/worker.ts +47 -62
package/src/scheduler.ts
CHANGED
|
@@ -24,7 +24,8 @@ import type { Clock } from '@ultimat3/core';
|
|
|
24
24
|
import { finiteOption, isUltimateError, logger, onShutdown, renderThrowable } from '@ultimat3/core';
|
|
25
25
|
import { instant, nextCronOccurrence } from '@ultimat3/time';
|
|
26
26
|
import { nowMs } from './clock';
|
|
27
|
-
import {
|
|
27
|
+
import type { DrainBudget } from './drain-wait';
|
|
28
|
+
import { createDrainBudget, settleAllBy } from './drain-wait';
|
|
28
29
|
import type { JobDriver } from './driver';
|
|
29
30
|
import type { TaskHandle, TaskJobResult } from './task';
|
|
30
31
|
import { registeredTasks } from './task';
|
|
@@ -258,43 +259,54 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
258
259
|
// on a wall clock in the middle of this walk, not between rounds. See the file header for
|
|
259
260
|
// what a second dispatcher costs — the occurrence key does NOT absorb it in general.
|
|
260
261
|
if (!(await stillLeading())) break;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
262
|
+
// One task's failure is that task's: its `enqueue` callback, its state read or its dispatch
|
|
263
|
+
// threw, and the round used to abort there — every task after it stopped firing, in every
|
|
264
|
+
// round, until a deploy. Logged and skipped; its watermark is untouched, so it retries next
|
|
265
|
+
// round.
|
|
266
|
+
try {
|
|
267
|
+
const last = await schedulerState.lastFiredAt(handle.name);
|
|
268
|
+
if (last === undefined) {
|
|
269
|
+
// First sight of this task: arm it, never fire retroactively for all of history.
|
|
270
|
+
await schedulerState.markFired(
|
|
271
|
+
handle.name,
|
|
272
|
+
nextRunFor(handle, new Date(at)).getTime() - 1,
|
|
273
|
+
);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
267
276
|
|
|
268
|
-
|
|
269
|
-
|
|
277
|
+
const due = occurrencesSince(handle, last, at);
|
|
278
|
+
if (due.length === 0) continue;
|
|
270
279
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
if (handle.catchUp === 'run-once') {
|
|
282
|
-
const first = due[0];
|
|
283
|
-
if (first !== undefined) {
|
|
284
|
-
dispatched.push(await dispatch(handle, first, due.length > 1));
|
|
285
|
-
// `dispatch` leaves the watermark on the occurrence it RAN — the earliest missed one
|
|
286
|
-
// here — so the next round found occurrences 2..n still due and fired the second, then
|
|
287
|
-
// the third, one per tick until the backlog drained: 24 nightly digests a second apart
|
|
288
|
-
// after a day down. "One catch-up" means the rest are DROPPED, and dropping an
|
|
289
|
-
// occurrence is moving the watermark past it. `at` rather than the last element of
|
|
290
|
-
// `due`, which `maxCatchUp` truncates: every occurrence at or before `at` is missed by
|
|
291
|
-
// definition, and this policy fires none of them.
|
|
292
|
-
if (first !== at) await schedulerState.markFired(handle.name, at);
|
|
280
|
+
if (handle.catchUp === 'skip') {
|
|
281
|
+
// The real latest occurrence, never `due`'s last element: that one is `maxCatchUp` steps
|
|
282
|
+
// past the watermark, and dispatching it leaves the watermark there — so the next tick
|
|
283
|
+
// found the next ten still due and fired again, until the walk reached `at`. The
|
|
284
|
+
// occurrence key stays honest (this IS the occurrence the payload is for), and the
|
|
285
|
+
// watermark `dispatch` leaves is that occurrence — nothing at or before `at` is due past it.
|
|
286
|
+
const latest = latestOccurrenceBy(handle, last, at);
|
|
287
|
+
dispatched.push(await dispatch(handle, latest, due.length > 1));
|
|
288
|
+
continue;
|
|
293
289
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
290
|
+
if (handle.catchUp === 'run-once') {
|
|
291
|
+
const first = due[0];
|
|
292
|
+
if (first !== undefined) {
|
|
293
|
+
dispatched.push(await dispatch(handle, first, due.length > 1));
|
|
294
|
+
// `dispatch` leaves the watermark on the occurrence it RAN — the earliest missed one
|
|
295
|
+
// here — so the next round found occurrences 2..n still due and fired the second, then
|
|
296
|
+
// the third, one per tick until the backlog drained: 24 nightly digests a second apart
|
|
297
|
+
// after a day down. "One catch-up" means the rest are DROPPED, and dropping an
|
|
298
|
+
// occurrence is moving the watermark past it. `at` rather than the last element of
|
|
299
|
+
// `due`, which `maxCatchUp` truncates: every occurrence at or before `at` is missed by
|
|
300
|
+
// definition, and this policy fires none of them.
|
|
301
|
+
if (first !== at) await schedulerState.markFired(handle.name, at);
|
|
302
|
+
}
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
for (const occurrence of due) {
|
|
306
|
+
dispatched.push(await dispatch(handle, occurrence, occurrence !== due[due.length - 1]));
|
|
307
|
+
}
|
|
308
|
+
} catch (error) {
|
|
309
|
+
logger.error('jobs.task.round_failed', { task: handle.name, ...failureFields(error) });
|
|
298
310
|
}
|
|
299
311
|
}
|
|
300
312
|
|
|
@@ -346,7 +358,7 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
346
358
|
timer = undefined;
|
|
347
359
|
};
|
|
348
360
|
|
|
349
|
-
const teardown = async (reason: string,
|
|
361
|
+
const teardown = async (reason: string, budget: DrainBudget): Promise<void> => {
|
|
350
362
|
stopDispatching();
|
|
351
363
|
logger.info('jobs.scheduler.draining', { reason, dispatching: round !== undefined });
|
|
352
364
|
try {
|
|
@@ -360,7 +372,7 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
360
372
|
// a queue that is not answering cannot be cancelled from here, and an unbounded wait is a
|
|
361
373
|
// teardown that never ends — hooks never handed back, `state` never past 'draining', and the
|
|
362
374
|
// memoised `stopping` every later `stop()` joins never settling.
|
|
363
|
-
const dispatched = await settleAllBy(round === undefined ? [] : [round],
|
|
375
|
+
const dispatched = await settleAllBy(round === undefined ? [] : [round], budget);
|
|
364
376
|
// A round we ABANDONED is a round still enqueueing, so the lock is deliberately NOT handed
|
|
365
377
|
// back: promoting a standby onto an occurrence this process is mid-dispatch for is the
|
|
366
378
|
// double-fire above, delivered by the shutdown. A lease row expires on its own, which is
|
|
@@ -385,16 +397,25 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
385
397
|
}
|
|
386
398
|
};
|
|
387
399
|
|
|
400
|
+
/** The wait a teardown may spend, tightened by every shutdown that reaches it. */
|
|
401
|
+
let budget = createDrainBudget();
|
|
402
|
+
|
|
388
403
|
const stop = async (reason = 'stop', deadlineAt?: number): Promise<void> => {
|
|
389
404
|
// Answered immediately once this scheduler is done: the teardown always REACHES 'stopped', so
|
|
390
405
|
// a caller landing after an abandoned drain gets an answer rather than joining a promise that
|
|
391
406
|
// never settles.
|
|
392
407
|
if (state === 'stopped') return;
|
|
408
|
+
// Bound on EVERY call, before the join — `createDrainBudget`, the worker's rule. A manual
|
|
409
|
+
// stop waits with no deadline; a SIGTERM that lands on it joined that teardown without its
|
|
410
|
+
// own, so a round parked on a queue that is not answering held the close hook past the whole
|
|
411
|
+
// process budget. Reproduced in `scheduler-drain-budget.test.ts`.
|
|
412
|
+
if (deadlineAt !== undefined) budget.bind(deadlineAt);
|
|
393
413
|
// One teardown, joined rather than repeated — the worker's rule, for the same reason: a
|
|
394
414
|
// SIGTERM landing on a manual stop must wait out the same round, not release the lock a
|
|
395
415
|
// second time behind it. Cleared as it settles, so a scheduler started again stops again.
|
|
396
|
-
stopping ??= teardown(reason,
|
|
416
|
+
stopping ??= teardown(reason, budget).finally(() => {
|
|
397
417
|
stopping = undefined;
|
|
418
|
+
budget = createDrainBudget();
|
|
398
419
|
});
|
|
399
420
|
await stopping;
|
|
400
421
|
};
|
package/src/steps-memory.ts
CHANGED
|
@@ -20,7 +20,11 @@ export function createMemoryStepStore(): StepStore {
|
|
|
20
20
|
return Promise.resolve(byRun.get(runId)?.get(name));
|
|
21
21
|
},
|
|
22
22
|
put(record) {
|
|
23
|
-
|
|
23
|
+
// Through JSON, exactly as the pg store persists it (`JSON.stringify(output ?? null)`): a
|
|
24
|
+
// live reference replayed a `Date` here and a string in production, so a step that worked
|
|
25
|
+
// in every test broke on the first retry against Postgres.
|
|
26
|
+
const output: unknown = JSON.parse(JSON.stringify(record.output ?? null));
|
|
27
|
+
runOf(record.runId).set(record.name, { ...record, output });
|
|
24
28
|
return Promise.resolve();
|
|
25
29
|
},
|
|
26
30
|
list(runId) {
|
package/src/steps.ts
CHANGED
|
@@ -110,6 +110,25 @@ export class StepSuspension extends Error {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* What a timed-out `waitForEvent` persists, and maps back to `undefined` on replay. `undefined`
|
|
115
|
+
* itself cannot survive a store — the pg driver writes `JSON.stringify(output ?? null)` — so the
|
|
116
|
+
* timeout replayed as `null` and `evt === undefined` read as "an event arrived". A namespaced key
|
|
117
|
+
* rather than a bare `{ timedOut: true }`, so no event payload can be mistaken for the marker.
|
|
118
|
+
*/
|
|
119
|
+
const WAIT_TIMED_OUT = Object.freeze<Record<string, true>>({
|
|
120
|
+
'~ultimate.waitTimedOut': true,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
function isWaitTimedOut(output: unknown): boolean {
|
|
124
|
+
return (
|
|
125
|
+
typeof output === 'object' &&
|
|
126
|
+
output !== null &&
|
|
127
|
+
Object.keys(output).length === 1 &&
|
|
128
|
+
(output as Record<string, unknown>)['~ultimate.waitTimedOut'] === true
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
113
132
|
export function isStepSuspension(error: unknown): error is StepSuspension {
|
|
114
133
|
return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
|
|
115
134
|
}
|
|
@@ -366,7 +385,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
366
385
|
const existing = await load(name);
|
|
367
386
|
if (existing?.status === 'completed') {
|
|
368
387
|
trace(replayed, name);
|
|
369
|
-
return existing.output as T | undefined;
|
|
388
|
+
return isWaitTimedOut(existing.output) ? undefined : (existing.output as T | undefined);
|
|
370
389
|
}
|
|
371
390
|
|
|
372
391
|
const at = now();
|
|
@@ -409,7 +428,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
409
428
|
runId,
|
|
410
429
|
name,
|
|
411
430
|
status: 'completed',
|
|
412
|
-
output:
|
|
431
|
+
output: WAIT_TIMED_OUT,
|
|
413
432
|
startedAt,
|
|
414
433
|
completedAt: at,
|
|
415
434
|
event,
|
package/src/task.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// late, and `catchUp` cannot recover an occurrence that was never an occurrence.
|
|
20
20
|
|
|
21
21
|
import { assert } from '@ultimat3/core';
|
|
22
|
-
import { isValidTimeZone } from '@ultimat3/time';
|
|
22
|
+
import { isValidTimeZone, parseCron } from '@ultimat3/time';
|
|
23
23
|
import { nowMs } from './clock';
|
|
24
24
|
import type { EnqueueResult } from './driver';
|
|
25
25
|
import { JobNameTakenError } from './errors';
|
|
@@ -131,6 +131,11 @@ export function task(definition: TaskDefinition): TaskHandle {
|
|
|
131
131
|
`use the full zone id on task("${name}"), e.g. tz: 'America/Bogota' — list the valid ones with: bun -e "console.log(Intl.supportedValuesOf('timeZone').join('\\n'))"`,
|
|
132
132
|
);
|
|
133
133
|
|
|
134
|
+
// The cron is parsed HERE, at declaration, beside the tz check. Nothing read it until the first
|
|
135
|
+
// round, where one bad expression threw inside `runRound` and every task in the app stopped.
|
|
136
|
+
// `parseCron` throws `X_CRON_INVALID` naming the field that is wrong.
|
|
137
|
+
parseCron(definition.cron);
|
|
138
|
+
|
|
134
139
|
// `maxCatchUp: 0` is not "no ceiling" — `occurrencesSince` walks
|
|
135
140
|
// `for (let i = 0; i < handle.maxCatchUp; i += 1)`, so zero (and any negative, and any fraction
|
|
136
141
|
// below one) returns an empty list on every round and the task NEVER fires: no error, no log
|
package/src/webhook-errors.ts
CHANGED
|
@@ -40,11 +40,14 @@ export class WebhookEndpointUnknownError extends UltimateError {
|
|
|
40
40
|
|
|
41
41
|
/** The endpoint exists and cannot be delivered to as declared. */
|
|
42
42
|
export class WebhookEndpointInvalidError extends UltimateError {
|
|
43
|
-
|
|
43
|
+
/** `fix` for a refusal the url-and-secret instruction does not answer — an address screen's. */
|
|
44
|
+
constructor(input: { webhook: string; endpointId: string; reason: string; fix?: string }) {
|
|
44
45
|
super({
|
|
45
46
|
code: 'X_WEBHOOK_ENDPOINT_INVALID',
|
|
46
47
|
cause: `webhook "${input.webhook}" endpoint ${input.endpointId} ${input.reason}`,
|
|
47
|
-
fix:
|
|
48
|
+
fix:
|
|
49
|
+
input.fix ??
|
|
50
|
+
`give the endpoint an https:// url and a non-empty secret before webhook("${input.webhook}").endpoint returns it — a delivery with neither is an unsigned POST to wherever the row points`,
|
|
48
51
|
meta: { endpointId: input.endpointId },
|
|
49
52
|
});
|
|
50
53
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Single responsibility: where a webhook delivery is allowed to connect, decided BEFORE the first
|
|
2
|
+
// byte leaves. A tenant-supplied URL was opened as-is: loopback, RFC 1918, link-local — the cloud
|
|
3
|
+
// metadata service at 169.254.169.254 — so anyone who could register an endpoint could make the
|
|
4
|
+
// worker POST into the cluster. The host is resolved, every address it answers is classified by
|
|
5
|
+
// core's one classifier, and the connection is PINNED to the address that was approved.
|
|
6
|
+
|
|
7
|
+
import type { AddressClass } from '@ultimat3/core';
|
|
8
|
+
import { classifyAddress, isLocal, renderThrowable } from '@ultimat3/core';
|
|
9
|
+
import { WebhookEndpointInvalidError } from './webhook-errors';
|
|
10
|
+
|
|
11
|
+
/** Every address a hostname answers. Injected in tests: the network is sealed in this repo. */
|
|
12
|
+
export type WebhookResolve = (hostname: string) => Promise<readonly string[]>;
|
|
13
|
+
|
|
14
|
+
/** `Bun.dns.lookup` — the runtime's own resolver, every family, every address. */
|
|
15
|
+
export const resolveWebhookHost: WebhookResolve = async (hostname) =>
|
|
16
|
+
(await Bun.dns.lookup(hostname)).map((entry) => entry.address);
|
|
17
|
+
|
|
18
|
+
/** The connection a delivery makes: an address the screen approved, and the name it proves. */
|
|
19
|
+
export interface WebhookTarget {
|
|
20
|
+
/** The endpoint url with its host replaced by the approved address. */
|
|
21
|
+
readonly url: string;
|
|
22
|
+
/** The original `host[:port]`, sent as the `Host` header. */
|
|
23
|
+
readonly host: string;
|
|
24
|
+
/** For `https:` — the certificate is verified against the NAME, not the address. */
|
|
25
|
+
readonly serverName?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WebhookTargetInput {
|
|
29
|
+
readonly webhook: string;
|
|
30
|
+
readonly endpointId: string;
|
|
31
|
+
readonly url: URL;
|
|
32
|
+
readonly allowPrivate: boolean;
|
|
33
|
+
readonly env: Readonly<Record<string, string | undefined>> | undefined;
|
|
34
|
+
readonly resolve: WebhookResolve;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const refuse = (input: WebhookTargetInput, reason: string, fix: string): never => {
|
|
38
|
+
throw new WebhookEndpointInvalidError({
|
|
39
|
+
webhook: input.webhook,
|
|
40
|
+
endpointId: input.endpointId,
|
|
41
|
+
reason,
|
|
42
|
+
fix,
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The approved target, or `{ unresolved }` when the name did not resolve — a TRANSIENT failure
|
|
48
|
+
* the queue retries, never a refusal. A refusal throws `X_WEBHOOK_ENDPOINT_INVALID`, and its
|
|
49
|
+
* reason names the address CLASS, never the address: it reaches a durable dead-letter row.
|
|
50
|
+
*/
|
|
51
|
+
export async function webhookTarget(
|
|
52
|
+
input: WebhookTargetInput,
|
|
53
|
+
): Promise<WebhookTarget | { readonly unresolved: string }> {
|
|
54
|
+
// `http:` only where a dev receiver lives. A process that names no environment is production —
|
|
55
|
+
// `fallback: 'production'`, the reading every dev-secret guard in the framework takes.
|
|
56
|
+
if (input.url.protocol === 'http:' && !isLocal({ env: input.env, fallback: 'production' })) {
|
|
57
|
+
refuse(
|
|
58
|
+
input,
|
|
59
|
+
'has an http: url outside a local environment, so the signed body would cross the network in clear',
|
|
60
|
+
`give the endpoint an https:// url before webhook("${input.webhook}").endpoint returns it`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const hostname = input.url.hostname.replace(/^\[|\]$/g, '');
|
|
64
|
+
let addresses: readonly string[];
|
|
65
|
+
if (classifyAddress(hostname) !== undefined) {
|
|
66
|
+
addresses = [hostname];
|
|
67
|
+
} else {
|
|
68
|
+
try {
|
|
69
|
+
addresses = await input.resolve(hostname);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { unresolved: `could not resolve the host: ${renderThrowable(error)}` };
|
|
72
|
+
}
|
|
73
|
+
if (addresses.length === 0) return { unresolved: 'the host resolved to no address' };
|
|
74
|
+
}
|
|
75
|
+
if (!input.allowPrivate) {
|
|
76
|
+
const classes = addresses.map((address): AddressClass | 'unclassifiable' => {
|
|
77
|
+
return classifyAddress(address) ?? 'unclassifiable';
|
|
78
|
+
});
|
|
79
|
+
const inside = classes.find((kind) => kind !== 'public');
|
|
80
|
+
if (inside !== undefined) {
|
|
81
|
+
refuse(
|
|
82
|
+
input,
|
|
83
|
+
`resolves to a ${inside} address, and a delivery only opens a public one`,
|
|
84
|
+
`point the endpoint at a public https:// receiver — or, for a receiver inside your own network, pass allowPrivate: true to webhook("${input.webhook}")`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Pinned: the screen approved THESE addresses, and connecting by name would resolve again — a
|
|
89
|
+
// name that answered public to the screen and private to the socket (DNS rebinding) walks past.
|
|
90
|
+
const pinned = addresses[0] ?? hostname;
|
|
91
|
+
const target = new URL(input.url.href);
|
|
92
|
+
target.hostname = pinned.includes(':') ? `[${pinned}]` : pinned;
|
|
93
|
+
return {
|
|
94
|
+
url: target.href,
|
|
95
|
+
host: input.url.host,
|
|
96
|
+
...(input.url.protocol === 'https:' ? { serverName: hostname } : {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/webhook.ts
CHANGED
|
@@ -47,9 +47,18 @@ import {
|
|
|
47
47
|
WebhookEventUnknownError,
|
|
48
48
|
} from './webhook-errors';
|
|
49
49
|
import type { WebhookLedger } from './webhook-ledger';
|
|
50
|
+
import type { WebhookResolve, WebhookTarget } from './webhook-target';
|
|
51
|
+
import { resolveWebhookHost, webhookTarget } from './webhook-target';
|
|
50
52
|
|
|
51
53
|
/** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
|
|
52
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The transport. `init.tls.serverName` carries the hostname a PINNED connection proves, because
|
|
56
|
+
* the url it is handed names the approved address (`webhook-target.ts`); Bun's `fetch` honours it.
|
|
57
|
+
*/
|
|
58
|
+
export type WebhookFetch = (
|
|
59
|
+
url: string,
|
|
60
|
+
init: RequestInit & { readonly tls?: { readonly serverName?: string } },
|
|
61
|
+
) => Promise<Response>;
|
|
53
62
|
|
|
54
63
|
/**
|
|
55
64
|
* Consecutive failures before an endpoint stops taking deliveries. Ten is roughly a day of a
|
|
@@ -66,7 +75,11 @@ const RETRY_AFTER_SECONDS = /^\d{1,7}$/;
|
|
|
66
75
|
|
|
67
76
|
export interface WebhookEndpoint {
|
|
68
77
|
readonly id: string;
|
|
69
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* `https://…` in production, `http://…` only in a local environment. A host that resolves to a
|
|
80
|
+
* loopback, private, link-local, ULA, CGNAT or unspecified address is refused unless the
|
|
81
|
+
* definition sets `allowPrivate` (`webhook-target.ts`). Nothing else is opened.
|
|
82
|
+
*/
|
|
70
83
|
readonly url: string;
|
|
71
84
|
/** The shared secret. Never logged, never in a `cause`, never on the ledger row. */
|
|
72
85
|
readonly secret: string;
|
|
@@ -151,6 +164,16 @@ export interface WebhookDefinition {
|
|
|
151
164
|
readonly clock?: Clock;
|
|
152
165
|
/** Injected so a test can drive the transport. The network is sealed in this repo's suites. */
|
|
153
166
|
readonly fetch?: WebhookFetch;
|
|
167
|
+
/**
|
|
168
|
+
* The explicit opt-out from the address screen, for a receiver inside your own network — a dev
|
|
169
|
+
* receiver on `localhost`, a service in the same cluster. Off by default: an endpoint URL is
|
|
170
|
+
* tenant-supplied, and the screen is what stops it reaching the metadata service.
|
|
171
|
+
*/
|
|
172
|
+
readonly allowPrivate?: boolean;
|
|
173
|
+
/** Which environment decides whether `http:` is allowed. Defaults to `process.env`. */
|
|
174
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
175
|
+
/** How a hostname is resolved. Defaults to `Bun.dns.lookup`; injected in tests. */
|
|
176
|
+
readonly resolve?: WebhookResolve;
|
|
154
177
|
readonly queue?: string;
|
|
155
178
|
readonly retry?: RetryPolicy;
|
|
156
179
|
/**
|
|
@@ -203,7 +226,16 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
203
226
|
endpointId: endpoint.id,
|
|
204
227
|
});
|
|
205
228
|
}
|
|
206
|
-
assertDeliverable(definition.name, endpoint);
|
|
229
|
+
const url = assertDeliverable(definition.name, endpoint);
|
|
230
|
+
// Screened and resolved before the event is read, let alone sent — see `webhook-target.ts`.
|
|
231
|
+
const target = await webhookTarget({
|
|
232
|
+
webhook: definition.name,
|
|
233
|
+
endpointId: endpoint.id,
|
|
234
|
+
url,
|
|
235
|
+
allowPrivate: definition.allowPrivate === true,
|
|
236
|
+
env: definition.env,
|
|
237
|
+
resolve: definition.resolve ?? resolveWebhookHost,
|
|
238
|
+
});
|
|
207
239
|
|
|
208
240
|
const event = await definition.event({ eventId: input.eventId, ctx });
|
|
209
241
|
if (event === null) {
|
|
@@ -229,13 +261,15 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
229
261
|
// than the age of the fact behind it.
|
|
230
262
|
const timestampSeconds = Math.floor(nowMs(clock) / 1_000);
|
|
231
263
|
const startedAt = clock.monotonic();
|
|
232
|
-
const outcome = await
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
264
|
+
const outcome: Outcome = await ('unresolved' in target
|
|
265
|
+
? Promise.resolve({ ok: false as const, status: null, detail: target.unresolved })
|
|
266
|
+
: attemptDelivery(send, endpoint, target, {
|
|
267
|
+
secret: endpoint.secret,
|
|
268
|
+
timestampSeconds,
|
|
269
|
+
eventId: input.eventId,
|
|
270
|
+
topic: event.topic,
|
|
271
|
+
body: event.body,
|
|
272
|
+
}));
|
|
239
273
|
const durationMs = Math.max(0, clock.monotonic() - startedAt);
|
|
240
274
|
|
|
241
275
|
// Recorded whatever happened, and BEFORE the throw: a failure that is not on the ledger is a
|
|
@@ -279,8 +313,8 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
279
313
|
});
|
|
280
314
|
}
|
|
281
315
|
|
|
282
|
-
/** A URL no delivery may open, or a secret that would make the POST unsigned. */
|
|
283
|
-
function assertDeliverable(name: string, endpoint: WebhookEndpoint):
|
|
316
|
+
/** A URL no delivery may open, or a secret that would make the POST unsigned. The parsed url. */
|
|
317
|
+
function assertDeliverable(name: string, endpoint: WebhookEndpoint): URL {
|
|
284
318
|
if (endpoint.secret.length === 0) {
|
|
285
319
|
throw new WebhookEndpointInvalidError({
|
|
286
320
|
webhook: name,
|
|
@@ -288,9 +322,9 @@ function assertDeliverable(name: string, endpoint: WebhookEndpoint): void {
|
|
|
288
322
|
reason: 'has an empty secret, so the delivery would carry no proof of who sent it',
|
|
289
323
|
});
|
|
290
324
|
}
|
|
291
|
-
let
|
|
325
|
+
let parsed: URL;
|
|
292
326
|
try {
|
|
293
|
-
|
|
327
|
+
parsed = new URL(endpoint.url);
|
|
294
328
|
} catch {
|
|
295
329
|
// Never the caught value and never the url: an unparseable value is exactly the one whose
|
|
296
330
|
// shape is unknown, and this reason reaches a durable dead-letter row.
|
|
@@ -302,13 +336,14 @@ function assertDeliverable(name: string, endpoint: WebhookEndpoint): void {
|
|
|
302
336
|
}
|
|
303
337
|
// `file:`, `data:` and the rest are refused by NAME rather than by a blocklist: a delivery opens
|
|
304
338
|
// an HTTP conversation, and anything else is a row in a table reaching the worker's filesystem.
|
|
305
|
-
if (protocol !== 'https:' && protocol !== 'http:') {
|
|
339
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
306
340
|
throw new WebhookEndpointInvalidError({
|
|
307
341
|
webhook: name,
|
|
308
342
|
endpointId: endpoint.id,
|
|
309
|
-
reason: `has a ${protocol} url, and a delivery only ever opens http: or https:`,
|
|
343
|
+
reason: `has a ${parsed.protocol} url, and a delivery only ever opens http: or https:`,
|
|
310
344
|
});
|
|
311
345
|
}
|
|
346
|
+
return parsed;
|
|
312
347
|
}
|
|
313
348
|
|
|
314
349
|
type Outcome =
|
|
@@ -323,18 +358,23 @@ type Outcome =
|
|
|
323
358
|
async function attemptDelivery(
|
|
324
359
|
send: WebhookFetch,
|
|
325
360
|
endpoint: WebhookEndpoint,
|
|
361
|
+
target: WebhookTarget,
|
|
326
362
|
signing: Parameters<typeof webhookHeaders>[0],
|
|
327
363
|
): Promise<Outcome> {
|
|
328
364
|
let response: Response;
|
|
329
365
|
try {
|
|
330
|
-
response = await send(
|
|
366
|
+
response = await send(target.url, {
|
|
331
367
|
method: 'POST',
|
|
332
368
|
headers: {
|
|
333
369
|
...endpoint.headers,
|
|
370
|
+
// The name the pinned address is reached AS — after the row's own headers, so an endpoint
|
|
371
|
+
// cannot redirect the request to a different virtual host than the one it registered.
|
|
372
|
+
host: target.host,
|
|
334
373
|
'content-type': WEBHOOK_CONTENT_TYPE,
|
|
335
374
|
// LAST, so an endpoint row's own headers can never overwrite the signature it is proved by.
|
|
336
375
|
...webhookHeaders(signing),
|
|
337
376
|
},
|
|
377
|
+
...(target.serverName === undefined ? {} : { tls: { serverName: target.serverName } }),
|
|
338
378
|
body: signing.body,
|
|
339
379
|
// Never followed: a 3xx would re-POST a body signed for one host to whatever the receiver
|
|
340
380
|
// named, and the signature would travel with it.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Single responsibility: returning the rest of a claimed batch a round cannot start. Split from
|
|
2
|
+
// `worker.ts` at the file-size ceiling. A fleet-slot write that rejected mid-batch rethrew out of
|
|
3
|
+
// `claimRound` and stranded every job behind it in `running`, each with an attempt burned.
|
|
4
|
+
|
|
5
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import type { ClaimedJob, JobDriver } from './driver';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Each job returned exactly as a shed returns one — no attempt burned, no `lastError` — so
|
|
10
|
+
* another worker takes it at once. Best-effort per job: the failure that got us here is usually
|
|
11
|
+
* the database, one nack failing must not stop the rest being tried, and a job whose nack also
|
|
12
|
+
* fails comes back when its visibility timeout lapses.
|
|
13
|
+
*/
|
|
14
|
+
export async function handBack(
|
|
15
|
+
driver: JobDriver,
|
|
16
|
+
jobs: readonly ClaimedJob[],
|
|
17
|
+
options: { readonly delayMs: number; readonly workerId: string },
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
for (const claimed of jobs) {
|
|
20
|
+
await driver
|
|
21
|
+
.nack(claimed.id, { delayMs: options.delayMs, countsAsAttempt: false })
|
|
22
|
+
.catch((error: unknown) => {
|
|
23
|
+
logger.debug('jobs.worker.hand_back_failed', {
|
|
24
|
+
workerId: options.workerId,
|
|
25
|
+
jobId: claimed.id,
|
|
26
|
+
error: renderThrowable(error),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Single responsibility: the worker's queue metrics — `queue_depth` and the two gauges beside it —
|
|
2
|
+
// republished on their own interval. Split from `worker.ts` at the file-size ceiling; nothing here
|
|
3
|
+
// closes over the claim loop, only over the driver, the clock and the worker's id.
|
|
4
|
+
|
|
5
|
+
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import { logger, recordQueueDepth, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import { nowMs } from './clock';
|
|
8
|
+
import type { JobDriver } from './driver';
|
|
9
|
+
import { recordQueueDeadJobs, recordQueueOldestReady } from './metrics';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How often the claim loop republishes `queue_depth`. Its own interval, not `pollIntervalMs`:
|
|
13
|
+
* `driver.stats()` is an aggregate over the whole jobs table and a scrape reads the gauge every
|
|
14
|
+
* ~15s, so publishing at the poll rate would multiply the queue's read load by sixty to write the
|
|
15
|
+
* same number sixty times.
|
|
16
|
+
*/
|
|
17
|
+
const QUEUE_DEPTH_INTERVAL_MS = 15_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* This package's ONE metrics call site: the `queue_depth` series `docker/helm`'s worker HPA
|
|
21
|
+
* scales on. `ready` and not `ready + delayed` — the gauge means "waiting to be picked up", and
|
|
22
|
+
* a job parked until Tuesday is not backlog no matter how many workers are added. `ready` counts
|
|
23
|
+
* every DUE row, a delayed one past its `run_at` included (`SQL_STATS`). Every queue the driver
|
|
24
|
+
* reports, not only the ones this process serves, because depth is the queue's fact and a queue
|
|
25
|
+
* no pod published is a queue no autoscaler can see.
|
|
26
|
+
*
|
|
27
|
+
* So every worker pod publishes the SAME number, and a reader must treat it as one series for
|
|
28
|
+
* the whole fleet: deduplicate with `max`, never `sum`. The chart's worker HPA reads it as an
|
|
29
|
+
* `External` metric with an `AverageValue` target, which divides it by the replica count; read
|
|
30
|
+
* as a `Pods` metric it was averaged as if each pod held the whole backlog, and the autoscaler
|
|
31
|
+
* asked for N times the workers the queue needed until it hit `maxReplicas`.
|
|
32
|
+
*/
|
|
33
|
+
export function createQueueDepthPublisher(input: {
|
|
34
|
+
readonly driver: JobDriver;
|
|
35
|
+
readonly clock: Clock | undefined;
|
|
36
|
+
readonly workerId: string;
|
|
37
|
+
}): () => Promise<void> {
|
|
38
|
+
let publishedAt = Number.NEGATIVE_INFINITY;
|
|
39
|
+
return async (): Promise<void> => {
|
|
40
|
+
const now = nowMs(input.clock);
|
|
41
|
+
if (now - publishedAt < QUEUE_DEPTH_INTERVAL_MS) return;
|
|
42
|
+
publishedAt = now;
|
|
43
|
+
try {
|
|
44
|
+
for (const stat of await input.driver.stats()) {
|
|
45
|
+
recordQueueDepth(stat.queue, stat.ready);
|
|
46
|
+
// Depth alone is not alertable: it cannot tell "10 jobs stuck for an hour" from "10 jobs
|
|
47
|
+
// enqueued a second ago", and `jobs_total{outcome="dead"}` is a rate, so a dead-letter
|
|
48
|
+
// queue that filled overnight and stopped growing pages nobody. Both numbers are already
|
|
49
|
+
// in `stats()` — this queries nothing new.
|
|
50
|
+
recordQueueOldestReady(stat.queue, stat.oldestReadyMs);
|
|
51
|
+
recordQueueDeadJobs(stat.queue, stat.dead);
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// Instrumentation never costs a tick: a queue that cannot be measured must still be worked.
|
|
55
|
+
logger.warn('jobs.worker.depth-failed', {
|
|
56
|
+
workerId: input.workerId,
|
|
57
|
+
error: renderThrowable(error),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|