@ultimat3/jobs 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +142 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +90 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. package/src/worker.ts +2 -2
@@ -0,0 +1,97 @@
1
+ // The shapes `webhookReceiver()` speaks: what `verify` is handed and answers, what the queue row
2
+ // holds, and what `handle` receives. Apart from the factory so each file keeps one job.
3
+
4
+ import type { Ctx } from '@ultimat3/core';
5
+ import type { Bucket, Route } from '@ultimat3/http';
6
+ import type { JsonValue } from '@ultimat3/schema';
7
+ import type { JobHandle } from './job';
8
+ import type { RetryPolicy } from './retry';
9
+ import type { StepApi } from './steps-api';
10
+
11
+ /** What `verify` and `handshake` are handed: the request as bytes, nothing parsed. */
12
+ export interface WebhookInbound {
13
+ /** The body exactly as sent, read under `maxBytes`. Empty for the GET handshake. */
14
+ readonly bytes: Uint8Array;
15
+ readonly headers: Headers;
16
+ /** The `:param` segments of `path` — a per-credential token, an account id. */
17
+ readonly params: Readonly<Record<string, string>>;
18
+ readonly url: URL;
19
+ /** A fresh `Request` over the same bytes, for `verifyWebhookSignature` or a mail receiver. */
20
+ request(): Request;
21
+ }
22
+
23
+ /** Who the event is for, as `verify` read it — what `tenant` decides from. */
24
+ export interface ReceivedEventFacts {
25
+ /**
26
+ * The dedupe key, and therefore the replay key: read it out of VERIFIED data — the signed bytes
27
+ * `verifyBodyHmac` answers (`.text`), or a header the mac covers — after the signature check,
28
+ * never from an unsigned header. An unsigned id lets one captured signature replay forever under
29
+ * fresh ids. 1-256 printable ASCII characters, else `X_WEBHOOK_EVENT_ID_INVALID` (422).
30
+ */
31
+ readonly eventId: string;
32
+ readonly orgId?: string | undefined;
33
+ readonly topic?: string | undefined;
34
+ }
35
+
36
+ /**
37
+ * `verify` accepted the bytes. `payload` is what it read out of them AFTER the signature check —
38
+ * the job stores it and `handle` receives it, so no retry re-verifies against a window that has
39
+ * closed. JSON, because it is a queue row.
40
+ */
41
+ export interface ReceivedEvent<P extends JsonValue = JsonValue> extends ReceivedEventFacts {
42
+ readonly payload: P;
43
+ }
44
+
45
+ /** `verify` answered the request itself — a subscription confirm, a challenge. Nothing queues. */
46
+ export interface WebhookAnswer {
47
+ readonly answer: Response;
48
+ }
49
+
50
+ /** The queue row. `payload` is `.sensitive()`, so an operator never reads it. */
51
+ export interface WebhookReceiverInput {
52
+ readonly eventId: string;
53
+ readonly orgId?: string | undefined;
54
+ readonly topic?: string | undefined;
55
+ readonly payload: JsonValue;
56
+ }
57
+
58
+ export interface WebhookHandleArgs<P extends JsonValue = JsonValue> {
59
+ readonly eventId: string;
60
+ readonly orgId: string | undefined;
61
+ readonly topic: string | undefined;
62
+ /** What `verify` answered, already verified. Never re-verify here: the vendor's window closes. */
63
+ readonly payload: P;
64
+ readonly ctx: Ctx;
65
+ readonly step: StepApi;
66
+ readonly attempt: number;
67
+ }
68
+
69
+ /** Who a receiver's run acts as: `'none'`, or the org `verify` named. */
70
+ export type WebhookReceiverTenant = 'none' | ((event: ReceivedEventFacts) => string | undefined);
71
+
72
+ export interface WebhookReceiverDefinition<P extends JsonValue = JsonValue> {
73
+ /** The job's durable name and the route's `webhook.<name>`. Required: it is a queue key. */
74
+ readonly name: string;
75
+ /** Absolute, may hold `:param`; never under `/api` or `/_x`, which the framework serves. */
76
+ readonly path: string;
77
+ readonly tenant: WebhookReceiverTenant;
78
+ /** Throws to refuse (`X_WEBHOOK_SIGNATURE_INVALID`); runs before anything is parsed. */
79
+ verify(
80
+ inbound: WebhookInbound,
81
+ ): Promise<ReceivedEvent<P> | WebhookAnswer> | ReceivedEvent<P> | WebhookAnswer;
82
+ /** The job's body. At least once per `eventId`. */
83
+ handle(args: WebhookHandleArgs<P>): Promise<unknown>;
84
+ /** Mounts `GET path` answering what this returns. Unset, no GET route exists. */
85
+ handshake?(inbound: WebhookInbound): Promise<Response> | Response;
86
+ readonly retry?: RetryPolicy;
87
+ readonly queue?: string;
88
+ /** Defaults to `DEFAULT_WEBHOOK_BODY_LIMIT` (1 MiB), enforced while the body streams. */
89
+ readonly maxBytes?: number;
90
+ /** A bucket name from `rateLimit.buckets`, or the route's own numbers. */
91
+ readonly rateLimit?: string | Bucket;
92
+ }
93
+
94
+ /** The job handle, plus its routes — mounted by the boot for every receiver the app declares. */
95
+ export interface WebhookReceiver extends JobHandle<WebhookReceiverInput> {
96
+ routes(): readonly Route[];
97
+ }
@@ -0,0 +1,132 @@
1
+ // `webhookReceiver()` — one inbound webhook, declared as a `job` plus the plain route that feeds
2
+ // it: the twin of `webhook()`, and a factory over `job()`, never a ninth primitive. The route reads
3
+ // the exact bytes, asks the app's `verify`, enqueues durably and only then acknowledges; the job's
4
+ // body is the app's `handle`. Which vendors exist and what a payload means stay the app's (axiom 8).
5
+ //
6
+ // DEDUPE is the queue's live window, keyed by `eventId`: a redelivery while the job is live answers
7
+ // `deduped: true`. After it settles, `handle` is AT LEAST ONCE and receives `eventId` — the seen-set
8
+ // is the app's table. `verify` answers the payload it read AFTER the signature check; the row
9
+ // stores it `.sensitive()` (`x jobs show` masks it) and `handle` receives it, so no retry
10
+ // re-verifies against a vendor's window that has since closed.
11
+
12
+ import { ACTION_PATH_PREFIX } from '@ultimat3/core';
13
+ import type { Route } from '@ultimat3/http';
14
+ import { DEFAULT_WEBHOOK_BODY_LIMIT } from '@ultimat3/http';
15
+ import type { JsonValue } from '@ultimat3/schema';
16
+ import { t } from '@ultimat3/schema';
17
+ import { JobTenantMismatchError } from './errors-tenant';
18
+ import { job, registeredJobs } from './job';
19
+ import { DEFAULT_RETRY } from './retry';
20
+ import type { JobTenant } from './tenant';
21
+ import { WebhookReceiverInvalidError } from './webhook-receiver-errors';
22
+ import { receiverRoutes } from './webhook-receiver-route';
23
+ import type {
24
+ WebhookReceiver,
25
+ WebhookReceiverDefinition,
26
+ WebhookReceiverInput,
27
+ } from './webhook-receiver-types';
28
+
29
+ const input = t.object({
30
+ eventId: t.string,
31
+ orgId: t.optional(t.string),
32
+ topic: t.optional(t.string),
33
+ payload: t.json().sensitive(),
34
+ });
35
+
36
+ /** Every handle `webhookReceiver()` built — so the boot can mount them without a second list. */
37
+ const receivers = new WeakSet<object>();
38
+
39
+ export function webhookReceiver<P extends JsonValue = JsonValue>(
40
+ definition: WebhookReceiverDefinition<P>,
41
+ ): WebhookReceiver {
42
+ const maxBytes = assertReceiver(definition);
43
+ const handle = job<WebhookReceiverInput>({
44
+ name: definition.name,
45
+ input,
46
+ // `eventId` alone: the namespace is already (name, tenant, key), so one vendor event is one row.
47
+ idempotencyKey: ({ eventId }) => eventId,
48
+ tenant: tenantOf(definition),
49
+ retry: definition.retry ?? DEFAULT_RETRY,
50
+ ...(definition.queue === undefined ? {} : { queue: definition.queue }),
51
+ run: ({ input: row, ctx, step, attempt }) =>
52
+ definition.handle({
53
+ eventId: row.eventId,
54
+ orgId: row.orgId,
55
+ topic: row.topic,
56
+ // `verify` answered a `P` and the row holds exactly that JSON: the type it was stored as.
57
+ payload: row.payload as P,
58
+ ctx,
59
+ step,
60
+ attempt,
61
+ }),
62
+ });
63
+ const routes = receiverRoutes({ definition, maxBytes, handle });
64
+ // In place, never a copy: `isJobHandle` proves the object `job()` built, so a spread would stop
65
+ // registering.
66
+ const receiver = Object.assign(handle, { routes: () => routes });
67
+ receivers.add(receiver);
68
+ return receiver;
69
+ }
70
+
71
+ /**
72
+ * The routes of every receiver declared in this process, in job-name order. The boot mounts these
73
+ * beside `runtime.routes`: declaring a receiver is the one registration, never a second list.
74
+ */
75
+ export function webhookReceiverRoutes(): readonly Route[] {
76
+ return registeredJobs().flatMap((handle) =>
77
+ receivers.has(handle) ? (handle as WebhookReceiver).routes() : [],
78
+ );
79
+ }
80
+
81
+ function tenantOf<P extends JsonValue>(
82
+ definition: WebhookReceiverDefinition<P>,
83
+ ): JobTenant<WebhookReceiverInput> {
84
+ const declared = definition.tenant;
85
+ if (typeof declared !== 'function') return declared;
86
+ return (row) => {
87
+ const orgId = declared(row);
88
+ if (typeof orgId === 'string' && orgId.length > 0) return orgId;
89
+ throw new JobTenantMismatchError({
90
+ job: definition.name,
91
+ reason: 'declares an org tenant, and verify named no org for this delivery',
92
+ fix: `webhookReceiver({ name: '${definition.name}', verify: (inbound) => ({ eventId, orgId }) }) — answer the org the delivery belongs to from verify`,
93
+ });
94
+ };
95
+ }
96
+
97
+ const RESERVED = [ACTION_PATH_PREFIX, '/_x'];
98
+
99
+ /** Every declaration refusal, at `webhookReceiver()`. Answers the resolved `maxBytes`. */
100
+ function assertReceiver<P extends JsonValue>(definition: WebhookReceiverDefinition<P>): number {
101
+ const name = typeof definition.name === 'string' ? definition.name : '';
102
+ const refuse = (field: string, reason: string, fix: string): never => {
103
+ throw new WebhookReceiverInvalidError({ receiver: name, field, reason, fix });
104
+ };
105
+ if (name === '') refuse('name', 'is empty', "name: 'billing.events'");
106
+ const path: unknown = definition.path;
107
+ if (typeof path !== 'string' || !path.startsWith('/') || path.length < 2 || path.endsWith('/')) {
108
+ refuse('path', 'is not an absolute path like /hooks/billing', "path: '/hooks/<vendor>'");
109
+ }
110
+ for (const reserved of RESERVED) {
111
+ if (path === reserved || String(path).startsWith(`${reserved}/`)) {
112
+ refuse(
113
+ 'path',
114
+ `sits under ${reserved}, which the framework serves`,
115
+ "path: '/hooks/<vendor>'",
116
+ );
117
+ }
118
+ }
119
+ for (const field of ['verify', 'handle'] as const) {
120
+ if (typeof definition[field] !== 'function') {
121
+ refuse(field, 'is not a function', `${field}: (…) => …`);
122
+ }
123
+ }
124
+ if (definition.handshake !== undefined && typeof definition.handshake !== 'function') {
125
+ refuse('handshake', 'is set and is not a function', 'handshake: (inbound) => new Response(…)');
126
+ }
127
+ const maxBytes = definition.maxBytes ?? DEFAULT_WEBHOOK_BODY_LIMIT;
128
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
129
+ refuse('maxBytes', 'is not a whole number of bytes, at least 1', 'maxBytes: 1_048_576');
130
+ }
131
+ return maxBytes;
132
+ }
@@ -1,19 +1,16 @@
1
1
  // Single responsibility: where a webhook delivery is allowed to connect, decided BEFORE the first
2
2
  // byte leaves. A tenant-supplied URL was opened as-is: loopback, RFC 1918, link-local — the cloud
3
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.
4
+ // worker POST into the cluster. The screen is core's `screenDial` — resolve, classify every address,
5
+ // pin — and this file keeps only what is a webhook's: the `http:` rule and its own refusal code.
6
6
 
7
- import type { AddressClass } from '@ultimat3/core';
8
- import { classifyAddress, isLocal, renderThrowable } from '@ultimat3/core';
7
+ import { isLocal } from '@ultimat3/core';
8
+ import type { DialResolve } from '@ultimat3/core/outbound';
9
+ import { screenDial } from '@ultimat3/core/outbound';
9
10
  import { WebhookEndpointInvalidError } from './webhook-errors';
10
11
 
11
12
  /** 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);
13
+ export type WebhookResolve = DialResolve;
17
14
 
18
15
  /** The connection a delivery makes: an address the screen approved, and the name it proves. */
19
16
  export interface WebhookTarget {
@@ -60,39 +57,19 @@ export async function webhookTarget(
60
57
  `give the endpoint an https:// url before webhook("${input.webhook}").endpoint returns it`,
61
58
  );
62
59
  }
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
- }
60
+ const verdict = await screenDial(input.url, {
61
+ resolve: input.resolve,
62
+ allowPrivate: input.allowPrivate,
63
+ });
64
+ if ('unresolved' in verdict) return { unresolved: verdict.unresolved };
65
+ if ('refused' in verdict) {
66
+ return refuse(
67
+ input,
68
+ `resolves to a ${verdict.refused} address, and a delivery only opens a public one`,
69
+ `point the endpoint at a public https:// receiver — or, for a receiver inside your own network, pass allowPrivate: true to webhook("${input.webhook}")`,
70
+ );
87
71
  }
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
- };
72
+ // Pinned by the screen: connecting by name would resolve again, and a name that answered public
73
+ // to the screen and private to the socket (DNS rebinding) would walk past.
74
+ return verdict.pin;
98
75
  }
package/src/webhook.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  throwIfAborted,
25
25
  WEBHOOK_FIELD_MAX,
26
26
  } from '@ultimat3/core';
27
+ import { resolveDialHost } from '@ultimat3/core/outbound';
27
28
  import { t } from '@ultimat3/schema';
28
29
  import type { DurationInput } from './clock';
29
30
  import { nowMs } from './clock';
@@ -44,7 +45,7 @@ import {
44
45
  } from './webhook-errors';
45
46
  import type { WebhookLedger } from './webhook-ledger';
46
47
  import type { WebhookResolve } from './webhook-target';
47
- import { resolveWebhookHost, webhookTarget } from './webhook-target';
48
+ import { webhookTarget } from './webhook-target';
48
49
 
49
50
  /**
50
51
  * Consecutive failures before an endpoint stops taking deliveries. Ten is roughly a day of a
@@ -175,7 +176,7 @@ export interface WebhookDefinition {
175
176
  readonly allowPrivate?: boolean;
176
177
  /** Which environment decides whether `http:` is allowed. Defaults to `process.env`. */
177
178
  readonly env?: Readonly<Record<string, string | undefined>>;
178
- /** How a hostname is resolved. Defaults to `Bun.dns.lookup`; injected in tests. */
179
+ /** How a hostname is resolved. Defaults to core's `resolveDialHost`; injected in tests. */
179
180
  readonly resolve?: WebhookResolve;
180
181
  readonly queue?: string;
181
182
  readonly retry?: RetryPolicy;
@@ -194,7 +195,9 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
194
195
  'disableAfter',
195
196
  definition.disableAfter ?? DEFAULT_WEBHOOK_DISABLE_AFTER,
196
197
  );
197
- const send = definition.fetch ?? ((url, init) => fetch(url, init));
198
+ // The attempt's signal, spelled: `webhook-attempt.ts` always sets it (the job's `ctx.signal`).
199
+ const send =
200
+ definition.fetch ?? ((url, init) => fetch(url, { ...init, signal: init.signal ?? null }));
198
201
 
199
202
  // Named so `run` can read the deadline `job()` resolved — one parse of `timeout`, never two.
200
203
  const handle: JobHandle<WebhookDeliveryInput> = job<WebhookDeliveryInput>({
@@ -249,7 +252,7 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
249
252
  url,
250
253
  allowPrivate: definition.allowPrivate === true,
251
254
  env: definition.env,
252
- resolve: definition.resolve ?? resolveWebhookHost,
255
+ resolve: definition.resolve ?? resolveDialHost,
253
256
  });
254
257
 
255
258
  const event = await definition.event({ eventId: input.eventId, orgId: input.orgId, ctx });
@@ -0,0 +1,57 @@
1
+ // Single responsibility: the `queue_demand` gauge one worker process publishes. Split from
2
+ // `worker-queue-depth.ts` because this gauge alone asks the driver a second question, and alone must
3
+ // go quiet when the worker drains.
4
+
5
+ import type { JobDriver, QueueDemand, QueueStats } from './driver';
6
+ import { recordQueueDemand } from './metrics';
7
+
8
+ export interface DemandGauge {
9
+ /** Taken BEFORE the round's `stats()`: a drain landing during that read moves it too. */
10
+ fence(): number;
11
+ /** One round, off the `stats()` it read under `fence`. A failed read rejects, zeroing nothing. */
12
+ publish(stats: readonly QueueStats[], fence: number): Promise<void>;
13
+ retract(): void;
14
+ }
15
+
16
+ /**
17
+ * `stats()` bounds the answer, so an idle fleet is not asked: a queue with nothing ready, running or
18
+ * suspended has no demand, and `idle-cost.test.ts` holds what an idle worker may cost.
19
+ */
20
+ function readDemand(
21
+ driver: JobDriver,
22
+ stats: readonly QueueStats[],
23
+ ): Promise<readonly QueueDemand[]> {
24
+ const live = stats.some((stat) => stat.ready + stat.running + stat.suspended > 0);
25
+ return live ? driver.demand() : Promise.resolve([]);
26
+ }
27
+
28
+ /**
29
+ * Every worker publishes the same fleet-wide number, so a reader takes `max by (queue)`. That is why
30
+ * `retract` exists: the claim loop is what publishes, and it stops at the drain, so a retiring pod's
31
+ * last value would otherwise stand for as long as its job runs, up to the retire, and `max` would
32
+ * read it over every live worker's. Zero is honest: the pods still serving carry the true number.
33
+ */
34
+ export function createDemandGauge(driver: JobDriver): DemandGauge {
35
+ // The queues the last round listed: a gauge keeps its last value, so one that drops out is zeroed.
36
+ let published: ReadonlySet<string> = new Set();
37
+ // Moved by `retract`, so a round whose read was in flight at the drain records nothing.
38
+ let generation = 0;
39
+
40
+ return {
41
+ fence: () => generation,
42
+ async publish(stats, fence) {
43
+ if (fence !== generation) return;
44
+ const rows = await readDemand(driver, stats);
45
+ if (fence !== generation) return;
46
+ const listed = new Set(rows.map((row) => row.queue));
47
+ for (const queue of published) if (!listed.has(queue)) recordQueueDemand(queue, 0);
48
+ published = listed;
49
+ for (const row of rows) recordQueueDemand(row.queue, row.demand);
50
+ },
51
+ retract() {
52
+ generation += 1;
53
+ for (const queue of published) recordQueueDemand(queue, 0);
54
+ published = new Set();
55
+ },
56
+ };
57
+ }
@@ -1,4 +1,4 @@
1
- // Single responsibility: the worker's queue metrics — `queue_depth` and the two gauges beside it —
1
+ // Single responsibility: the worker's queue metrics — `queue_depth` and the gauges beside it —
2
2
  // republished on their own interval. Split from `worker.ts` at the file-size ceiling; nothing here
3
3
  // closes over the claim loop, only over the driver, the clock and the worker's id.
4
4
 
@@ -7,6 +7,7 @@ import { logger, recordQueueDepth, renderThrowable } from '@ultimat3/core';
7
7
  import { nowMs } from './clock';
8
8
  import type { JobDriver } from './driver';
9
9
  import { recordQueueDeadJobs, recordQueueOldestReady } from './metrics';
10
+ import { createDemandGauge } from './worker-queue-demand';
10
11
 
11
12
  /**
12
13
  * How often the claim loop republishes `queue_depth`. Its own interval, not `pollIntervalMs`:
@@ -16,6 +17,13 @@ import { recordQueueDeadJobs, recordQueueOldestReady } from './metrics';
16
17
  */
17
18
  const QUEUE_DEPTH_INTERVAL_MS = 15_000;
18
19
 
20
+ /** One publishing round, and what a worker calls the moment it stops serving. */
21
+ export interface QueueDepthPublisher {
22
+ (): Promise<void>;
23
+ /** A draining worker's `queue_demand` goes to zero (`worker-queue-demand.ts`). */
24
+ retractDemand(): void;
25
+ }
26
+
19
27
  /**
20
28
  * This package's ONE metrics call site: the `queue_depth` series `docker/helm`'s worker HPA
21
29
  * scales on. `ready` and not `ready + delayed` — the gauge means "waiting to be picked up", and
@@ -34,18 +42,20 @@ export function createQueueDepthPublisher(input: {
34
42
  readonly driver: JobDriver;
35
43
  readonly clock: Clock | undefined;
36
44
  readonly workerId: string;
37
- }): () => Promise<void> {
45
+ }): QueueDepthPublisher {
46
+ const demand = createDemandGauge(input.driver);
38
47
  let publishedAt = Number.NEGATIVE_INFINITY;
39
48
  // The queues the last successful round published. A gauge keeps the last value it was given and
40
49
  // `stats()` lists a queue only while it holds a row it counts, so a queue that drained to `done`
41
50
  // would read its final backlog forever — and hold the HPA scaled out — unless it is zeroed on the
42
51
  // round it drops out. Bounded by the queue names, and replaced each round.
43
52
  let published: ReadonlySet<string> = new Set();
44
- return async (): Promise<void> => {
53
+ const publish = async (): Promise<void> => {
45
54
  const now = nowMs(input.clock);
46
55
  if (now - publishedAt < QUEUE_DEPTH_INTERVAL_MS) return;
47
56
  publishedAt = now;
48
57
  try {
58
+ const fence = demand.fence();
49
59
  const stats = await input.driver.stats();
50
60
  const reported = new Set(stats.map((stat) => stat.queue));
51
61
  for (const queue of published) {
@@ -64,6 +74,7 @@ export function createQueueDepthPublisher(input: {
64
74
  recordQueueOldestReady(stat.queue, stat.oldestReadyMs);
65
75
  recordQueueDeadJobs(stat.queue, stat.dead);
66
76
  }
77
+ await demand.publish(stats, fence);
67
78
  } catch (error) {
68
79
  // Instrumentation never costs a tick: a queue that cannot be measured must still be worked.
69
80
  logger.warn('jobs.worker.depth-failed', {
@@ -72,4 +83,5 @@ export function createQueueDepthPublisher(input: {
72
83
  });
73
84
  }
74
85
  };
86
+ return Object.assign(publish, { retractDemand: demand.retract });
75
87
  }
@@ -16,6 +16,8 @@ export interface WorkerRegistryOptions {
16
16
  readonly workerId: string;
17
17
  /** Where this worker runs. A pod name under an orchestrator; `HOSTNAME` when nothing says. */
18
18
  readonly host: string | undefined;
19
+ /** The build this process runs, stamped on its row. Absent: the row names none. */
20
+ readonly build?: string | undefined;
19
21
  readonly queues: readonly string[];
20
22
  /** Slots across every queue served. */
21
23
  readonly concurrency: number;
@@ -32,7 +34,7 @@ export interface WorkerRegistration {
32
34
  /**
33
35
  * The worker stopped claiming and only finishes what it holds: every announce from now on says
34
36
  * `draining`, and one goes out NOW rather than at the next heartbeat (a third of the visibility
35
- * window later) — the row is what a queue-depth autoscaler leaves held jobs out by. Idempotent in
37
+ * window later) — the row is what a queue-demand autoscaler leaves held jobs out by. Idempotent in
36
38
  * effect (the row keeps its first `draining_at`). Never rejects.
37
39
  */
38
40
  draining(): Promise<void>;
@@ -69,6 +71,7 @@ export function startWorkerRegistry(options: WorkerRegistryOptions): WorkerRegis
69
71
  concurrency: options.concurrency,
70
72
  inFlight: options.inFlight().slice(0, MAX_WORKER_IN_FLIGHT),
71
73
  ...(draining ? { draining: true } : {}),
74
+ ...(options.build === undefined ? {} : { build: options.build }),
72
75
  },
73
76
  options.ttlMs,
74
77
  )
@@ -50,6 +50,8 @@ export interface WorkerOptions {
50
50
  readonly workerId?: string;
51
51
  /** Where this worker runs, for the registry. Default `HOSTNAME`, which a pod sets to its name. */
52
52
  readonly host?: string;
53
+ /** The build this process runs, for the registry: what `x deploy verify` grades a queue by. */
54
+ readonly build?: string;
53
55
  /** Default true. Registers a SIGTERM drain via `onShutdown`. */
54
56
  readonly drainOnShutdown?: boolean;
55
57
  }
package/src/worker.ts CHANGED
@@ -108,7 +108,6 @@ export function jobWorker(options: WorkerOptions): Worker {
108
108
  let stopping: Promise<void> | undefined;
109
109
  const tally = createWorkerTally();
110
110
  let registration: WorkerRegistration | undefined;
111
- /** `queue_depth` and its two siblings, republished on their own interval (`worker-queue-depth.ts`). */
112
111
  const publishQueueDepth = createQueueDepthPublisher({
113
112
  driver: options.driver,
114
113
  clock: options.clock,
@@ -133,7 +132,6 @@ export function jobWorker(options: WorkerOptions): Worker {
133
132
  ...(options.events === undefined ? {} : { events: options.events }),
134
133
  });
135
134
 
136
- /** The drain's one question: may this worker still take work off the queue? */
137
135
  const claiming = (): boolean => state !== 'draining' && state !== 'stopped';
138
136
 
139
137
  /**
@@ -333,6 +331,7 @@ export function jobWorker(options: WorkerOptions): Worker {
333
331
  state = 'draining';
334
332
  loop.stop();
335
333
  void registration?.draining(); // the row says so NOW: a scaler stops counting what it holds
334
+ publishQueueDepth.retractDemand(); // the loop just stopped: nothing would refresh this gauge
336
335
  if (shutdown === undefined) return;
337
336
  budget.bind(shutdown.deadlineAt);
338
337
  if (cutoff !== undefined) return;
@@ -450,6 +449,7 @@ export function jobWorker(options: WorkerOptions): Worker {
450
449
  driver: options.driver,
451
450
  workerId,
452
451
  host: options.host,
452
+ build: options.build,
453
453
  queues,
454
454
  concurrency: Math.min(slots, cap.max ?? slots),
455
455
  inFlight: () => inFlight.ids(),