@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.
- package/CLAUDE.md +68 -89
- package/README.md +304 -10
- package/package.json +18 -6
- package/src/backfill-definition.ts +184 -0
- package/src/backfill-errors.ts +18 -2
- package/src/backfill-pass.ts +43 -40
- package/src/backfill-pending.ts +40 -17
- package/src/backfill-reader.ts +94 -0
- package/src/backfill-registry.ts +5 -0
- package/src/backfill-scope.ts +1 -2
- package/src/backfill-source.ts +113 -0
- package/src/backfill.ts +54 -135
- package/src/consumer-errors.ts +42 -0
- package/src/consumer-guard.ts +19 -0
- package/src/consumer-loop.ts +171 -0
- package/src/consumer-registry.ts +103 -0
- package/src/consumer-types.ts +81 -0
- package/src/consumer.ts +147 -0
- package/src/driver-memory-operator.ts +25 -2
- package/src/driver-memory.ts +35 -1
- package/src/driver-pg-claim-sql.ts +128 -0
- package/src/driver-pg-ddl.ts +11 -2
- package/src/driver-pg-demand-sql.ts +27 -0
- package/src/driver-pg-enqueue-many.ts +153 -0
- package/src/driver-pg-event-wake-sql.ts +51 -0
- package/src/driver-pg-operator-sql.ts +34 -6
- package/src/driver-pg-operator.ts +36 -2
- package/src/driver-pg-outbox-sql.ts +34 -0
- package/src/driver-pg-sql.ts +24 -91
- package/src/driver-pg.ts +17 -1
- package/src/driver-redis-refusal.ts +25 -0
- package/src/driver-redis-scripts.ts +79 -30
- package/src/driver-redis.ts +70 -42
- package/src/driver.ts +38 -1
- package/src/enqueue-record.ts +137 -0
- package/src/error-codes.ts +233 -0
- package/src/errors-concurrency.ts +2 -2
- package/src/errors-declaration.ts +15 -2
- package/src/errors-operator.ts +1 -1
- package/src/errors-requeue.ts +1 -1
- package/src/errors-saga.ts +87 -0
- package/src/errors-steps.ts +35 -0
- package/src/errors-tenant.ts +1 -1
- package/src/errors.ts +25 -165
- package/src/events-pg.ts +20 -8
- package/src/events.ts +14 -3
- package/src/execute-ctx.ts +51 -0
- package/src/execute-unwind.ts +191 -0
- package/src/execute.ts +50 -51
- package/src/export-errors.ts +1 -1
- package/src/failure-row.ts +43 -0
- package/src/import-errors.ts +102 -0
- package/src/import-format.ts +232 -0
- package/src/import-pass.ts +255 -0
- package/src/import-source.ts +49 -0
- package/src/import.ts +175 -0
- package/src/index.ts +163 -7
- package/src/inspect-operator.ts +24 -3
- package/src/inspect.ts +17 -4
- package/src/introspection.ts +24 -1
- package/src/job-enqueue-many.ts +108 -0
- package/src/job-types.ts +229 -0
- package/src/job.ts +19 -208
- package/src/metrics.ts +10 -0
- package/src/outbox-pg.ts +35 -0
- package/src/outbox-relay.ts +58 -19
- package/src/outbox.ts +65 -73
- package/src/purge-entities.ts +92 -0
- package/src/purge.ts +10 -2
- package/src/queue-wake.ts +1 -1
- package/src/refresh-view.ts +76 -0
- package/src/retry-classification.ts +27 -8
- package/src/retry.ts +26 -0
- package/src/rollup-errors.ts +27 -0
- package/src/rollup.ts +190 -0
- package/src/run-deadline.ts +11 -7
- package/src/scheduler.ts +2 -1
- package/src/settled.ts +56 -15
- package/src/step-resolve.ts +37 -0
- package/src/steps-api.ts +111 -0
- package/src/steps-core.ts +26 -0
- package/src/steps-output.ts +142 -0
- package/src/steps-store.ts +68 -0
- package/src/steps-suspension.ts +51 -0
- package/src/steps-timeout.ts +10 -5
- package/src/steps-unwind.ts +94 -0
- package/src/steps-wait.ts +163 -0
- package/src/steps.ts +174 -210
- package/src/subject-erase-pass.ts +134 -0
- package/src/subject-errors.ts +48 -0
- package/src/subject-export-pass.ts +228 -0
- package/src/subject-jobs.ts +121 -0
- package/src/subject-plan.ts +287 -0
- package/src/subject-select.ts +106 -0
- package/src/subject.ts +119 -0
- package/src/tenant.ts +69 -1
- package/src/type-pins.ts +90 -0
- package/src/webhook-errors.ts +1 -1
- package/src/webhook-receiver-errors.ts +59 -0
- package/src/webhook-receiver-route.ts +127 -0
- package/src/webhook-receiver-types.ts +97 -0
- package/src/webhook-receiver.ts +132 -0
- package/src/webhook-target.ts +20 -43
- package/src/webhook.ts +7 -4
- package/src/worker-queue-demand.ts +57 -0
- package/src/worker-queue-depth.ts +15 -3
- package/src/worker-registry.ts +4 -1
- package/src/worker-types.ts +2 -0
- 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
|
+
}
|
package/src/webhook-target.ts
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
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
|
|
8
|
-
import {
|
|
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 =
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
89
|
-
//
|
|
90
|
-
|
|
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 {
|
|
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 `
|
|
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
|
-
|
|
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 ??
|
|
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
|
|
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
|
-
}):
|
|
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
|
-
|
|
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
|
}
|
package/src/worker-registry.ts
CHANGED
|
@@ -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-
|
|
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
|
)
|
package/src/worker-types.ts
CHANGED
|
@@ -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(),
|