@ultimat3/jobs 21.0.0 → 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/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 { settleAllBy } from './drain-wait';
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
- const last = await schedulerState.lastFiredAt(handle.name);
262
- if (last === undefined) {
263
- // First sight of this task: arm it, never fire retroactively for all of history.
264
- await schedulerState.markFired(handle.name, nextRunFor(handle, new Date(at)).getTime() - 1);
265
- continue;
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
- const due = occurrencesSince(handle, last, at);
269
- if (due.length === 0) continue;
277
+ const due = occurrencesSince(handle, last, at);
278
+ if (due.length === 0) continue;
270
279
 
271
- if (handle.catchUp === 'skip') {
272
- // The real latest occurrence, never `due`'s last element: that one is `maxCatchUp` steps
273
- // past the watermark, and dispatching it leaves the watermark there — so the next tick
274
- // found the next ten still due and fired again, until the walk reached `at`. The
275
- // occurrence key stays honest (this IS the occurrence the payload is for), and the
276
- // watermark `dispatch` leaves is that occurrence — nothing at or before `at` is due past it.
277
- const latest = latestOccurrenceBy(handle, last, at);
278
- dispatched.push(await dispatch(handle, latest, due.length > 1));
279
- continue;
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
- continue;
295
- }
296
- for (const occurrence of due) {
297
- dispatched.push(await dispatch(handle, occurrence, occurrence !== due[due.length - 1]));
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, deadlineAt?: number): Promise<void> => {
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], deadlineAt);
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, deadlineAt).finally(() => {
416
+ stopping ??= teardown(reason, budget).finally(() => {
397
417
  stopping = undefined;
418
+ budget = createDrainBudget();
398
419
  });
399
420
  await stopping;
400
421
  };
@@ -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
- runOf(record.runId).set(record.name, record);
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: undefined,
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
@@ -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
- constructor(input: { webhook: string; endpointId: string; reason: string }) {
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: `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
+ 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
- export type WebhookFetch = (url: string, init: RequestInit) => Promise<Response>;
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
- /** `https://…` in production, `http://…` for a dev receiver. Nothing else is opened. */
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 attemptDelivery(send, endpoint, {
233
- secret: endpoint.secret,
234
- timestampSeconds,
235
- eventId: input.eventId,
236
- topic: event.topic,
237
- body: event.body,
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): void {
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 protocol: string;
325
+ let parsed: URL;
292
326
  try {
293
- protocol = new URL(endpoint.url).protocol;
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(endpoint.url, {
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
+ }