@ultimat3/jobs 24.0.0 → 25.1.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 +27 -27
- package/README.md +72 -44
- package/package.json +7 -7
- package/src/backfill-errors.ts +3 -2
- package/src/backfill-ledger.ts +1 -1
- package/src/backfill-rate.ts +2 -2
- package/src/backfill.ts +2 -2
- package/src/counters.ts +1 -1
- package/src/driver-memory-operator.ts +3 -3
- package/src/driver-memory.ts +14 -17
- package/src/driver-pg-settle-sql.ts +2 -3
- package/src/driver-pg-sql.ts +1 -1
- package/src/driver-pg.ts +18 -18
- package/src/driver.ts +9 -15
- package/src/errors-concurrency.ts +1 -1
- package/src/errors-tenant.ts +22 -0
- package/src/errors.ts +10 -5
- package/src/events-pg.ts +4 -4
- package/src/events.ts +6 -6
- package/src/execute.ts +46 -5
- package/src/export.ts +2 -2
- package/src/heartbeat.ts +7 -2
- package/src/index.ts +39 -97
- package/src/inspect-operator.ts +1 -1
- package/src/inspect.ts +1 -1
- package/src/job.ts +1 -1
- package/src/leases.ts +1 -1
- package/src/limits.ts +13 -8
- package/src/outbox-lease.ts +1 -1
- package/src/outbox-pg.ts +5 -5
- package/src/outbox-relay.ts +6 -6
- package/src/outbox.ts +13 -13
- package/src/renewal-timer.ts +1 -1
- package/src/retry-classification.ts +52 -15
- package/src/scheduler-leader.ts +2 -2
- package/src/scheduler-pg.ts +16 -14
- package/src/scheduler-state.ts +2 -2
- package/src/scheduler.ts +5 -5
- package/src/steps-memory.ts +2 -2
- package/src/steps.ts +35 -7
- package/src/task.ts +1 -1
- package/src/webhook-attempt.ts +206 -0
- package/src/webhook-ledger.ts +15 -4
- package/src/webhook.ts +141 -127
- package/src/worker-admit.ts +7 -3
- package/src/worker-drain-cutoff.ts +110 -0
- package/src/worker-fleet-slots.ts +5 -2
- package/src/worker-held.ts +100 -0
- package/src/worker-options.ts +19 -15
- package/src/worker-run.ts +38 -8
- package/src/worker-tally.ts +53 -0
- package/src/worker-types.ts +7 -4
- package/src/worker.ts +85 -77
- package/src/driver-nats.ts +0 -89
- package/src/driver-redis.ts +0 -92
package/src/webhook.ts
CHANGED
|
@@ -19,27 +19,23 @@ import type { Clock, Ctx } from '@ultimat3/core';
|
|
|
19
19
|
import {
|
|
20
20
|
finiteOption,
|
|
21
21
|
isCanonicalWebhookField,
|
|
22
|
-
|
|
23
|
-
// receiver saying "a concurrent writer won this round" dead-lettered on attempt 1 as a refusal
|
|
24
|
-
// no retry could change — the exact divergence `retryable-status.ts` was extracted to end.
|
|
25
|
-
isRetryableStatus,
|
|
26
|
-
renderThrowable,
|
|
22
|
+
isUltimateError,
|
|
27
23
|
systemClock,
|
|
24
|
+
throwIfAborted,
|
|
28
25
|
WEBHOOK_FIELD_MAX,
|
|
29
|
-
webhookHeaders,
|
|
30
26
|
} from '@ultimat3/core';
|
|
31
27
|
import { t } from '@ultimat3/schema';
|
|
32
28
|
import type { DurationInput } from './clock';
|
|
33
29
|
import { nowMs } from './clock';
|
|
30
|
+
import { JobTenantMismatchError } from './errors-tenant';
|
|
34
31
|
import type { JobHandle } from './job';
|
|
35
32
|
import { job } from './job';
|
|
36
33
|
import type { RetryPolicy } from './retry';
|
|
37
34
|
import { DEFAULT_RETRY } from './retry';
|
|
38
35
|
import type { JobTenant } from './tenant';
|
|
36
|
+
import type { Outcome, WebhookFetch } from './webhook-attempt';
|
|
37
|
+
import { attemptDelivery, deliveryError, deliverySignal } from './webhook-attempt';
|
|
39
38
|
import {
|
|
40
|
-
WebhookDeliveryFailedError,
|
|
41
|
-
WebhookDeliveryRejectedError,
|
|
42
|
-
WebhookDeliveryThrottledError,
|
|
43
39
|
WebhookEndpointDisabledError,
|
|
44
40
|
WebhookEndpointInvalidError,
|
|
45
41
|
WebhookEndpointUnknownError,
|
|
@@ -47,19 +43,9 @@ import {
|
|
|
47
43
|
WebhookEventUnknownError,
|
|
48
44
|
} from './webhook-errors';
|
|
49
45
|
import type { WebhookLedger } from './webhook-ledger';
|
|
50
|
-
import type { WebhookResolve
|
|
46
|
+
import type { WebhookResolve } from './webhook-target';
|
|
51
47
|
import { resolveWebhookHost, webhookTarget } from './webhook-target';
|
|
52
48
|
|
|
53
|
-
/** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
|
|
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>;
|
|
62
|
-
|
|
63
49
|
/**
|
|
64
50
|
* Consecutive failures before an endpoint stops taking deliveries. Ten is roughly a day of a
|
|
65
51
|
* retrying queue against a dead receiver, which is long enough for an outage and short enough that
|
|
@@ -67,12 +53,6 @@ export type WebhookFetch = (
|
|
|
67
53
|
*/
|
|
68
54
|
export const DEFAULT_WEBHOOK_DISABLE_AFTER = 10;
|
|
69
55
|
|
|
70
|
-
/** The one content type a delivery announces. The BODY is the app's; how it is framed is not. */
|
|
71
|
-
export const WEBHOOK_CONTENT_TYPE = 'application/json';
|
|
72
|
-
|
|
73
|
-
/** `Retry-After` in seconds. The HTTP-date form is ignored on purpose — see `retryAfterSeconds`. */
|
|
74
|
-
const RETRY_AFTER_SECONDS = /^\d{1,7}$/;
|
|
75
|
-
|
|
76
56
|
export interface WebhookEndpoint {
|
|
77
57
|
readonly id: string;
|
|
78
58
|
/**
|
|
@@ -115,8 +95,23 @@ export interface WebhookDeliveryInput {
|
|
|
115
95
|
readonly endpointId: string;
|
|
116
96
|
/** Also the id the receiver dedupes on — it is signed, so it cannot be moved in transit. */
|
|
117
97
|
readonly eventId: string;
|
|
98
|
+
/**
|
|
99
|
+
* The org that owns the endpoint, for a delivery declared `tenant: ({ orgId }) => orgId`. On the
|
|
100
|
+
* input because `tenant` is a synchronous function of the input alone — derived from the
|
|
101
|
+
* endpoint id it would be a read before the run has an org to read under. Neither signed nor
|
|
102
|
+
* sent: the receiver learns nothing it did not already know.
|
|
103
|
+
*/
|
|
104
|
+
readonly orgId?: string | undefined;
|
|
118
105
|
}
|
|
119
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Who a delivery runs as: `'none'`, or the org derived from its payload. May answer `undefined` —
|
|
109
|
+
* `({ orgId }) => orgId` over a row that carries none — and that answer is refused, TERMINALLY,
|
|
110
|
+
* before either seam reads (`X_JOB_TENANT_MISMATCH`). One signature for both shapes on purpose: a
|
|
111
|
+
* 25.0.0 `({ endpointId }) => …` that never reads `orgId` keeps enqueuing without one.
|
|
112
|
+
*/
|
|
113
|
+
export type WebhookTenant = 'none' | ((input: WebhookDeliveryInput) => string | undefined);
|
|
114
|
+
|
|
120
115
|
/** What one landed delivery reports. Bounded, so `x jobs show` can print it. */
|
|
121
116
|
export interface WebhookReport {
|
|
122
117
|
readonly endpointId: string;
|
|
@@ -132,12 +127,13 @@ export interface WebhookDefinition {
|
|
|
132
127
|
*/
|
|
133
128
|
readonly name: string;
|
|
134
129
|
/**
|
|
135
|
-
* REQUIRED, exactly as on `job()`: `tenant: ({
|
|
136
|
-
* org that owns the endpoint
|
|
137
|
-
*
|
|
138
|
-
*
|
|
130
|
+
* REQUIRED, exactly as on `job()`: `tenant: ({ orgId }) => orgId` for a delivery scoped to the
|
|
131
|
+
* org that owns the endpoint — every enqueue then carries `orgId` — or the explicit
|
|
132
|
+
* `tenant: 'none'`. A delivery reads the app's own endpoint and event rows
|
|
133
|
+
* through the seams below, so the org those reads run under is a fact about the WORK and is
|
|
134
|
+
* declared here rather than inherited from whichever worker claimed it.
|
|
139
135
|
*/
|
|
140
|
-
readonly tenant:
|
|
136
|
+
readonly tenant: WebhookTenant;
|
|
141
137
|
/**
|
|
142
138
|
* The endpoint this delivery is for. Read once PER ATTEMPT and never checkpointed: it carries a
|
|
143
139
|
* secret, and a `step.run` output is written to `x_job_steps` — a credential in a durable table
|
|
@@ -145,11 +141,18 @@ export interface WebhookDefinition {
|
|
|
145
141
|
*/
|
|
146
142
|
endpoint(args: {
|
|
147
143
|
readonly endpointId: string;
|
|
144
|
+
/**
|
|
145
|
+
* The delivery's own `orgId`, off the input — by the time a seam runs, the org the run is
|
|
146
|
+
* under, or absent. Handed over so a seam names its tenant from the work rather than reading it
|
|
147
|
+
* back off `ctx.actor`.
|
|
148
|
+
*/
|
|
149
|
+
readonly orgId: string | undefined;
|
|
148
150
|
readonly ctx: Ctx;
|
|
149
151
|
}): Promise<WebhookEndpoint | null> | WebhookEndpoint | null;
|
|
150
152
|
/** The event's bytes. Read per attempt as well, out of the app's own table. */
|
|
151
153
|
event(args: {
|
|
152
154
|
readonly eventId: string;
|
|
155
|
+
readonly orgId: string | undefined;
|
|
153
156
|
readonly ctx: Ctx;
|
|
154
157
|
}): Promise<WebhookEvent | null> | WebhookEvent | null;
|
|
155
158
|
/** Where every attempt is recorded, and where the consecutive-failure count comes from. */
|
|
@@ -184,13 +187,6 @@ export interface WebhookDefinition {
|
|
|
184
187
|
readonly timeout?: DurationInput;
|
|
185
188
|
}
|
|
186
189
|
|
|
187
|
-
/** `Retry-After: 120`. The HTTP-date form needs the receiver's clock, which is what we do not trust. */
|
|
188
|
-
const retryAfterSeconds = (response: Response): number | undefined => {
|
|
189
|
-
const header = response.headers.get('retry-after');
|
|
190
|
-
if (header === null || !RETRY_AFTER_SECONDS.test(header.trim())) return undefined;
|
|
191
|
-
return Number(header.trim());
|
|
192
|
-
};
|
|
193
|
-
|
|
194
190
|
export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliveryInput> {
|
|
195
191
|
const clock = definition.clock ?? systemClock;
|
|
196
192
|
const disableAfter = finiteOption(
|
|
@@ -200,18 +196,35 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
200
196
|
);
|
|
201
197
|
const send = definition.fetch ?? ((url, init) => fetch(url, init));
|
|
202
198
|
|
|
203
|
-
|
|
199
|
+
// Named so `run` can read the deadline `job()` resolved — one parse of `timeout`, never two.
|
|
200
|
+
const handle: JobHandle<WebhookDeliveryInput> = job<WebhookDeliveryInput>({
|
|
204
201
|
name: definition.name,
|
|
205
|
-
input: t.object({ endpointId: t.string, eventId: t.string }),
|
|
202
|
+
input: t.object({ endpointId: t.string, eventId: t.string, orgId: t.optional(t.string) }),
|
|
206
203
|
// Endpoint AND event: the same event fans out to every subscribed endpoint, so a key on the
|
|
207
204
|
// event alone would dedupe every one of those deliveries into the first endpoint's row.
|
|
208
205
|
idempotencyKey: ({ endpointId, eventId }) => `${definition.name}:${endpointId}:${eventId}`,
|
|
209
|
-
tenant: definition
|
|
206
|
+
tenant: tenantOf(definition),
|
|
210
207
|
retry: definition.retry ?? DEFAULT_RETRY,
|
|
211
208
|
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
212
209
|
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
213
210
|
async run({ input, ctx, attempt }): Promise<WebhookReport> {
|
|
214
|
-
|
|
211
|
+
// On the clock `executeJob`'s deadline timer runs on, read as the body starts — which is
|
|
212
|
+
// when that timer is armed.
|
|
213
|
+
const deadline =
|
|
214
|
+
handle.timeoutMs === undefined
|
|
215
|
+
? undefined
|
|
216
|
+
: {
|
|
217
|
+
atMs: performance.now() + handle.timeoutMs,
|
|
218
|
+
job: definition.name,
|
|
219
|
+
timeoutMs: handle.timeoutMs,
|
|
220
|
+
};
|
|
221
|
+
// Before either seam: the org they are handed must be the org every read runs under.
|
|
222
|
+
assertInputOrg(definition.name, input.orgId, ctx);
|
|
223
|
+
const endpoint = await definition.endpoint({
|
|
224
|
+
endpointId: input.endpointId,
|
|
225
|
+
orgId: input.orgId,
|
|
226
|
+
ctx,
|
|
227
|
+
});
|
|
215
228
|
if (endpoint === null) {
|
|
216
229
|
throw new WebhookEndpointUnknownError({
|
|
217
230
|
webhook: definition.name,
|
|
@@ -220,7 +233,9 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
220
233
|
}
|
|
221
234
|
// Before the ledger and before the socket: a disabled endpoint costs nothing, which is the
|
|
222
235
|
// whole point of disabling one.
|
|
223
|
-
|
|
236
|
+
// Asked of BOTH sources, because `disableAfter` writes only the ledger: an endpoint the
|
|
237
|
+
// mechanism switched off must stop here whether or not the app mirrored it onto its row.
|
|
238
|
+
if (endpoint.disabled === true || (await definition.ledger.isDisabled(endpoint.id))) {
|
|
224
239
|
throw new WebhookEndpointDisabledError({
|
|
225
240
|
webhook: definition.name,
|
|
226
241
|
endpointId: endpoint.id,
|
|
@@ -237,7 +252,7 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
237
252
|
resolve: definition.resolve ?? resolveWebhookHost,
|
|
238
253
|
});
|
|
239
254
|
|
|
240
|
-
const event = await definition.event({ eventId: input.eventId, ctx });
|
|
255
|
+
const event = await definition.event({ eventId: input.eventId, orgId: input.orgId, ctx });
|
|
241
256
|
if (event === null) {
|
|
242
257
|
throw new WebhookEventUnknownError({ webhook: definition.name, eventId: input.eventId });
|
|
243
258
|
}
|
|
@@ -260,17 +275,41 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
260
275
|
// again now, so a receiver's freshness window measures the request in front of it rather
|
|
261
276
|
// than the age of the fact behind it.
|
|
262
277
|
const timestampSeconds = Math.floor(nowMs(clock) / 1_000);
|
|
278
|
+
// The last moment a cancelled attempt can stop without the receiver hearing anything — the
|
|
279
|
+
// drain included: a POST not yet started is handed back to the next pod at no cost.
|
|
280
|
+
stopIfCancelled(ctx);
|
|
263
281
|
const startedAt = clock.monotonic();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
282
|
+
// Past this line the drain no longer cancels: see `deliverySignal`.
|
|
283
|
+
const request = deliverySignal(ctx.signal, deadline);
|
|
284
|
+
let outcome: Outcome;
|
|
285
|
+
try {
|
|
286
|
+
outcome = await ('unresolved' in target
|
|
287
|
+
? Promise.resolve({ ok: false as const, status: null, detail: target.unresolved })
|
|
288
|
+
: attemptDelivery(
|
|
289
|
+
send,
|
|
290
|
+
endpoint,
|
|
291
|
+
target,
|
|
292
|
+
{
|
|
293
|
+
secret: endpoint.secret,
|
|
294
|
+
timestampSeconds,
|
|
295
|
+
eventId: input.eventId,
|
|
296
|
+
topic: event.topic,
|
|
297
|
+
body: event.body,
|
|
298
|
+
},
|
|
299
|
+
request.signal,
|
|
300
|
+
));
|
|
301
|
+
} finally {
|
|
302
|
+
request.dispose();
|
|
303
|
+
}
|
|
273
304
|
const durationMs = Math.max(0, clock.monotonic() - startedAt);
|
|
305
|
+
// A failure the REQUEST's cancellation caused is not the receiver's, and is not this
|
|
306
|
+
// attempt's to record: the queue has already handed the delivery to another worker, whose
|
|
307
|
+
// outcome is the one the consecutive count must hear. Recorded, it was a failure nobody saw
|
|
308
|
+
// counting toward `disableAfter` — an abandoned attempt switching off a healthy endpoint.
|
|
309
|
+
// Read off the request's signal, never the attempt's: a receiver that really failed while
|
|
310
|
+
// the worker drained is a failure like any other, and the count hears it. A delivery that
|
|
311
|
+
// LANDED is a fact, and is always recorded.
|
|
312
|
+
if (!outcome.ok && request.signal.aborted) stopIfCancelled(ctx);
|
|
274
313
|
|
|
275
314
|
// Recorded whatever happened, and BEFORE the throw: a failure that is not on the ledger is a
|
|
276
315
|
// failure the consecutive count cannot see, which is an endpoint that never gets disabled.
|
|
@@ -311,6 +350,45 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
311
350
|
throw deliveryError(definition.name, endpoint, outcome);
|
|
312
351
|
},
|
|
313
352
|
});
|
|
353
|
+
return handle;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The declared tenant, asked as declared, with one refusal added: an answer that names NO org. Only
|
|
358
|
+
* then — a 25.0.0 `tenant: ({ endpointId }) => …` never reads `orgId` and needs none, so requiring
|
|
359
|
+
* one would refuse every delivery it queued. The usual cause is a row queued before `orgId` existed
|
|
360
|
+
* under a `({ orgId }) => orgId` declaration. Coded and TERMINAL rather than `jobTenantFor`'s
|
|
361
|
+
* `assert`: an unclassified refusal spent the whole retry policy, and `x jobs retry`, re-proving it.
|
|
362
|
+
*/
|
|
363
|
+
function tenantOf(definition: WebhookDefinition): JobTenant<WebhookDeliveryInput> {
|
|
364
|
+
const declared = definition.tenant;
|
|
365
|
+
if (typeof declared !== 'function') return declared;
|
|
366
|
+
return (input) => {
|
|
367
|
+
const orgId = declared(input);
|
|
368
|
+
if (typeof orgId === 'string' && orgId.length > 0) return orgId;
|
|
369
|
+
throw new JobTenantMismatchError({
|
|
370
|
+
job: definition.name,
|
|
371
|
+
reason: `declares an org tenant, and this delivery's payload names no org for it${input.orgId === undefined ? ' (it carries no orgId)' : ''}`,
|
|
372
|
+
fix: `${definition.name}.enqueue({ endpointId, eventId, orgId }) — re-enqueue it with the org that owns the endpoint; a queued row cannot be retried into having one`,
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The org a seam is HANDED is the org the run is UNDER, or the delivery is refused before either
|
|
379
|
+
* seam reads: a lookup filtered by `orgId` would otherwise read another org's endpoint, secret and
|
|
380
|
+
* all, inside this org's run. With `tenant: 'none'` nothing would check it at all, so any `orgId`
|
|
381
|
+
* is refused there. Terminal for `tenantOf`'s reason — the payload is the same on every attempt.
|
|
382
|
+
*/
|
|
383
|
+
function assertInputOrg(name: string, orgId: string | undefined, ctx: Ctx): void {
|
|
384
|
+
if (orgId === undefined) return;
|
|
385
|
+
const runOrg = ctx.actor.orgId ?? undefined;
|
|
386
|
+
if (orgId === runOrg) return;
|
|
387
|
+
throw new JobTenantMismatchError({
|
|
388
|
+
job: name,
|
|
389
|
+
reason: `was handed orgId ${orgId}, but the run is under ${runOrg === undefined ? "tenant 'none'" : `org ${runOrg}`}`,
|
|
390
|
+
fix: `webhook({ name: '${name}', tenant: ({ orgId }) => orgId, … }) — derive the tenant from the payload's own org, or enqueue ${name} with the orgId its tenant resolves to`,
|
|
391
|
+
});
|
|
314
392
|
}
|
|
315
393
|
|
|
316
394
|
/** A URL no delivery may open, or a secret that would make the POST unsigned. The parsed url. */
|
|
@@ -346,78 +424,14 @@ function assertDeliverable(name: string, endpoint: WebhookEndpoint): URL {
|
|
|
346
424
|
return parsed;
|
|
347
425
|
}
|
|
348
426
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
send: WebhookFetch,
|
|
360
|
-
endpoint: WebhookEndpoint,
|
|
361
|
-
target: WebhookTarget,
|
|
362
|
-
signing: Parameters<typeof webhookHeaders>[0],
|
|
363
|
-
): Promise<Outcome> {
|
|
364
|
-
let response: Response;
|
|
365
|
-
try {
|
|
366
|
-
response = await send(target.url, {
|
|
367
|
-
method: 'POST',
|
|
368
|
-
headers: {
|
|
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,
|
|
373
|
-
'content-type': WEBHOOK_CONTENT_TYPE,
|
|
374
|
-
// LAST, so an endpoint row's own headers can never overwrite the signature it is proved by.
|
|
375
|
-
...webhookHeaders(signing),
|
|
376
|
-
},
|
|
377
|
-
...(target.serverName === undefined ? {} : { tls: { serverName: target.serverName } }),
|
|
378
|
-
body: signing.body,
|
|
379
|
-
// Never followed: a 3xx would re-POST a body signed for one host to whatever the receiver
|
|
380
|
-
// named, and the signature would travel with it.
|
|
381
|
-
redirect: 'manual',
|
|
382
|
-
});
|
|
383
|
-
} catch (error) {
|
|
384
|
-
// `renderThrowable`, never `String(error)` or `${error}`: this string lands in a `cause` and on
|
|
385
|
-
// a durable ledger row, and a null-prototype throwable makes both of those a `TypeError`.
|
|
386
|
-
return { ok: false, status: null, detail: renderThrowable(error) };
|
|
387
|
-
}
|
|
388
|
-
if (response.ok) return { ok: true, status: response.status };
|
|
389
|
-
const stated = retryAfterSeconds(response);
|
|
390
|
-
return {
|
|
391
|
-
ok: false,
|
|
392
|
-
status: response.status,
|
|
393
|
-
detail: `status ${response.status}`,
|
|
394
|
-
...(stated === undefined ? {} : { retryAfterSeconds: stated }),
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** Which of the three failure codes this outcome is. The split is "can the same request land?". */
|
|
399
|
-
function deliveryError(
|
|
400
|
-
name: string,
|
|
401
|
-
endpoint: WebhookEndpoint,
|
|
402
|
-
outcome: Extract<Outcome, { ok: false }>,
|
|
403
|
-
): Error {
|
|
404
|
-
const base = { webhook: name, endpointId: endpoint.id, url: endpoint.url };
|
|
405
|
-
if (outcome.status === null) {
|
|
406
|
-
return new WebhookDeliveryFailedError({ ...base, status: null, detail: outcome.detail });
|
|
407
|
-
}
|
|
408
|
-
if (!isRetryableStatus(outcome.status)) {
|
|
409
|
-
return new WebhookDeliveryRejectedError({ ...base, status: outcome.status });
|
|
410
|
-
}
|
|
411
|
-
if (outcome.retryAfterSeconds !== undefined) {
|
|
412
|
-
return new WebhookDeliveryThrottledError({
|
|
413
|
-
...base,
|
|
414
|
-
status: outcome.status,
|
|
415
|
-
retryAfterSeconds: outcome.retryAfterSeconds,
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
return new WebhookDeliveryFailedError({
|
|
419
|
-
...base,
|
|
420
|
-
status: outcome.status,
|
|
421
|
-
detail: outcome.detail,
|
|
422
|
-
});
|
|
427
|
+
/**
|
|
428
|
+
* Throw the attempt's cancellation as itself — the deadline's `X_JOB_TIMEOUT`, the drain's
|
|
429
|
+
* `X_DRAINING`, a lost lease — so `executeJob` reads the stop it caused, never a delivery failure
|
|
430
|
+
* naming a receiver that did nothing wrong. Core's `X_ABORTED` when the reason carries no code.
|
|
431
|
+
*/
|
|
432
|
+
function stopIfCancelled(ctx: Ctx): void {
|
|
433
|
+
if (!ctx.signal.aborted) return;
|
|
434
|
+
const reason: unknown = ctx.signal.reason;
|
|
435
|
+
if (isUltimateError(reason)) throw reason;
|
|
436
|
+
throwIfAborted(ctx);
|
|
423
437
|
}
|
package/src/worker-admit.ts
CHANGED
|
@@ -76,8 +76,10 @@ export function createAdmission(options: AdmissionOptions): Admit {
|
|
|
76
76
|
try {
|
|
77
77
|
await shed(job, queue, limiter.blockedBy(key) ?? 'unknown');
|
|
78
78
|
} catch (error) {
|
|
79
|
-
// The nack itself failed
|
|
80
|
-
|
|
79
|
+
// The nack itself failed, so THIS job went nowhere either: it goes back with the jobs
|
|
80
|
+
// behind it, one more best-effort try, before the round reports. Left out, it sat
|
|
81
|
+
// `running` until its lease lapsed — an attempt spent on a body that never started.
|
|
82
|
+
await handBack(driver, [job, ...behind], back);
|
|
81
83
|
throw error;
|
|
82
84
|
}
|
|
83
85
|
return { kind: 'waiting' };
|
|
@@ -128,7 +130,9 @@ export function createAdmission(options: AdmissionOptions): Admit {
|
|
|
128
130
|
const keyed = grant.key === undefined ? '' : `, key ${grant.key}`;
|
|
129
131
|
await shed(job, queue, `job concurrency (${grant.limit}${keyed})`);
|
|
130
132
|
} catch (error) {
|
|
131
|
-
|
|
133
|
+
// This job too, for the reason above. A refusal that DID settle before throwing is safe to
|
|
134
|
+
// repeat: every nack is fenced on `running` and the claim, so a settled row ignores it.
|
|
135
|
+
await handBack(driver, [job, ...behind], back);
|
|
132
136
|
throw error;
|
|
133
137
|
}
|
|
134
138
|
return { kind: 'waiting' };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// When a draining worker stops waiting for the jobs it holds. SIGTERM stops the CLAIMING; the jobs
|
|
2
|
+
// already running keep going, because a deploy that cancels work in flight re-runs every side
|
|
3
|
+
// effect it interrupted. Only near the end of the drain budget is what is still running told to
|
|
4
|
+
// stop (`cancel`), and a beat later what is still held goes back to the queue (`handBack`).
|
|
5
|
+
|
|
6
|
+
import { logger, renderThrowable, systemClock } from '@ultimat3/core';
|
|
7
|
+
import type { DrainBudget } from './drain-wait';
|
|
8
|
+
import { JobDrainedError } from './errors';
|
|
9
|
+
import type { HeldRuns } from './worker-held';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The most of the budget's tail kept for unwinding: the cancel lands this far before the deadline,
|
|
13
|
+
* the hand-back half as far, and the teardown — the registry row, the driver — gets the rest. Two
|
|
14
|
+
* seconds of a 25 s default, and never more than HALF of a small budget, so a budget of 300 ms
|
|
15
|
+
* still spends 150 ms letting its jobs finish before anything is cut.
|
|
16
|
+
*/
|
|
17
|
+
export const DRAIN_CANCEL_MARGIN_MS = 2_000;
|
|
18
|
+
|
|
19
|
+
export interface CutoffTimes {
|
|
20
|
+
/** Real monotonic ms: the held runs' `ctx.signal` aborts with `X_DRAINING`. */
|
|
21
|
+
readonly cancelAt: number;
|
|
22
|
+
/** Real monotonic ms: every claim still held is handed back uncounted. */
|
|
23
|
+
readonly handBackAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The cut-off for a deadline, read against the instant it was bound. */
|
|
27
|
+
export function cutoffTimes(deadlineAt: number, now: number): CutoffTimes {
|
|
28
|
+
const remaining = Math.max(0, deadlineAt - now);
|
|
29
|
+
const margin = Math.min(DRAIN_CANCEL_MARGIN_MS, remaining / 2);
|
|
30
|
+
return { cancelAt: deadlineAt - margin, handBackAt: deadlineAt - margin / 2 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DrainCutoff {
|
|
34
|
+
/** Stop listening to the budget and clear both timers — the teardown is over. */
|
|
35
|
+
dispose(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Arm `cancel` and `handBack` on `budget`, re-armed each time it tightens (the earliest deadline
|
|
40
|
+
* wins — `drain-wait.ts`). Each fires at most once. The timers are `unref`ed: a drained process is
|
|
41
|
+
* never kept alive by its own cut-off.
|
|
42
|
+
*/
|
|
43
|
+
export function armDrainCutoff(
|
|
44
|
+
budget: DrainBudget,
|
|
45
|
+
steps: { readonly cancel: () => void; readonly handBack: () => Promise<void> },
|
|
46
|
+
): DrainCutoff {
|
|
47
|
+
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
48
|
+
let cancelled = false;
|
|
49
|
+
let handedBack = false;
|
|
50
|
+
const clear = (): void => {
|
|
51
|
+
for (const timer of timers.splice(0)) clearTimeout(timer);
|
|
52
|
+
};
|
|
53
|
+
const at = (instant: number, fire: () => void): void => {
|
|
54
|
+
const timer = setTimeout(fire, Math.max(0, instant - systemClock.monotonic()));
|
|
55
|
+
timer.unref?.();
|
|
56
|
+
timers.push(timer);
|
|
57
|
+
};
|
|
58
|
+
const unwatch = budget.watch((deadlineAt) => {
|
|
59
|
+
clear();
|
|
60
|
+
const times = cutoffTimes(deadlineAt, systemClock.monotonic());
|
|
61
|
+
if (!cancelled) {
|
|
62
|
+
at(times.cancelAt, () => {
|
|
63
|
+
cancelled = true;
|
|
64
|
+
steps.cancel();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (!handedBack) {
|
|
68
|
+
at(times.handBackAt, () => {
|
|
69
|
+
handedBack = true;
|
|
70
|
+
// Each nack's failure is logged where it happens (`worker-hand-back.ts`); this is the one
|
|
71
|
+
// place anything else could still escape a timer, so it is observed here.
|
|
72
|
+
void steps.handBack().catch((error: unknown) => {
|
|
73
|
+
logger.error('jobs.worker.drain-hand-back-failed', { error: renderThrowable(error) });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
dispose() {
|
|
80
|
+
unwatch();
|
|
81
|
+
clear();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The worker's cut-off: `drain` aborted with `X_DRAINING` at `cancelAt`, every run `held` still
|
|
88
|
+
* holds handed back at `handBackAt`. Aborted even with nothing held: a round still inside its
|
|
89
|
+
* `claim()` starts its runs under this controller, and they must hear the cut-off too.
|
|
90
|
+
*/
|
|
91
|
+
export function armWorkerCutoff(input: {
|
|
92
|
+
readonly budget: DrainBudget;
|
|
93
|
+
readonly workerId: string;
|
|
94
|
+
/** The shutdown's signal name, carried on the reason the bodies read. */
|
|
95
|
+
readonly signal: string;
|
|
96
|
+
readonly drain: AbortController;
|
|
97
|
+
readonly held: HeldRuns;
|
|
98
|
+
}): DrainCutoff {
|
|
99
|
+
const { workerId, held } = input;
|
|
100
|
+
return armDrainCutoff(input.budget, {
|
|
101
|
+
cancel: () => {
|
|
102
|
+
if (held.size > 0) logger.warn('jobs.worker.drain-cancelling', { workerId, held: held.size });
|
|
103
|
+
input.drain.abort(new JobDrainedError({ workerId, signal: input.signal }));
|
|
104
|
+
},
|
|
105
|
+
handBack: async () => {
|
|
106
|
+
const handed = await held.handBackAll();
|
|
107
|
+
if (handed > 0) logger.warn('jobs.worker.drain-handed-back', { workerId, handed });
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -73,7 +73,7 @@ export interface FleetSlots {
|
|
|
73
73
|
* to do without one. `granted` is also the answer for "no cap declared": a job with no
|
|
74
74
|
* `concurrency` never touches the lease table.
|
|
75
75
|
*
|
|
76
|
-
* A driver with no lease store cannot reach here: `
|
|
76
|
+
* A driver with no lease store cannot reach here: `jobWorker().start()` refuses to boot when
|
|
77
77
|
* a registered job declares `concurrency` and the driver has none, because a cap that silently
|
|
78
78
|
* holds per process is the documented-guarantee-that-does-nothing axiom 3 exists to make
|
|
79
79
|
* impossible.
|
|
@@ -186,6 +186,9 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
|
186
186
|
// One in flight at a time, on the connection that is already the thing failing.
|
|
187
187
|
if (renewing) return;
|
|
188
188
|
renewing = true;
|
|
189
|
+
// Stamped BEFORE the call, as `heartbeat.ts` does: the store dates the new expiry at the
|
|
190
|
+
// statement, so a slow reply must not stretch this worker's view past it.
|
|
191
|
+
const sentAt = now();
|
|
189
192
|
try {
|
|
190
193
|
const renewed = await options.leases.renew(slot, options.ttlMs);
|
|
191
194
|
if (timer.stopped()) return;
|
|
@@ -194,7 +197,7 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
|
194
197
|
if (renewed === false) return reportLost('not-ours');
|
|
195
198
|
// A renewal that lands LATE is still late: the slot may already be another worker's.
|
|
196
199
|
if (lapsed()) return reportLost('expired');
|
|
197
|
-
renewedAt =
|
|
200
|
+
renewedAt = sentAt;
|
|
198
201
|
} catch (error) {
|
|
199
202
|
// One failed renewal is not a lost slot: the TTL gives the interval several tries.
|
|
200
203
|
logger.warn('jobs.worker.slot-renewal-failed', {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The runs one worker holds right now, each with the claim it runs under — so a drain that reaches
|
|
2
|
+
// its cut-off can hand back every row still held instead of leaving it under a lease another
|
|
3
|
+
// worker waits a whole visibility timeout to see lapse. Split from `worker.ts`, whose job is the
|
|
4
|
+
// claim loop; this one is "what am I holding, and give it back".
|
|
5
|
+
|
|
6
|
+
import type { ClaimedJob, JobDriver } from './driver';
|
|
7
|
+
import { handBack } from './worker-hand-back';
|
|
8
|
+
|
|
9
|
+
export interface HeldRuns {
|
|
10
|
+
/** How many runs are held — what `stats().inFlight` reports. */
|
|
11
|
+
readonly size: number;
|
|
12
|
+
/**
|
|
13
|
+
* The job ids held, each once — what the registry row reports. Per RUN underneath, because one
|
|
14
|
+
* worker can hold a job twice (a claim that lapsed under a body still running, then its own
|
|
15
|
+
* re-claim): keyed by id, the first run to end dropped an id the second still held.
|
|
16
|
+
*/
|
|
17
|
+
ids(): string[];
|
|
18
|
+
/**
|
|
19
|
+
* A run started. `release` is what the run's own `finally` would do for the process — the
|
|
20
|
+
* in-flight count core's drain waits on, the fleet slot — run early when the run is handed back.
|
|
21
|
+
*/
|
|
22
|
+
add(run: Promise<unknown>, claimed: ClaimedJob, release: () => Promise<void>): void;
|
|
23
|
+
/** The run settled on its own: no longer held. */
|
|
24
|
+
settle(run: Promise<unknown>): void;
|
|
25
|
+
/** Resolves the moment nothing is held — settled or handed back. */
|
|
26
|
+
empty(): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Every run still held goes back to the queue — `countsAsAttempt: false`, no delay — and stops
|
|
29
|
+
* being this worker's. Answers how many. The bodies themselves cannot be stopped; what they
|
|
30
|
+
* write after this is fenced out by the claim that just moved (`StepFence`, `SQL_ACK`).
|
|
31
|
+
*/
|
|
32
|
+
handBackAll(): Promise<number>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Holding {
|
|
36
|
+
readonly claimed: ClaimedJob;
|
|
37
|
+
readonly release: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createHeldRuns(options: {
|
|
41
|
+
readonly driver: JobDriver;
|
|
42
|
+
readonly workerId: string;
|
|
43
|
+
}): HeldRuns {
|
|
44
|
+
const held = new Map<Promise<unknown>, Holding>();
|
|
45
|
+
let waiters: (() => void)[] = [];
|
|
46
|
+
|
|
47
|
+
/** Pending hand-backs: `empty()` answers only once their nacks have LANDED, never mid-wire. */
|
|
48
|
+
let handingBack = 0;
|
|
49
|
+
const wakeIfEmpty = (): void => {
|
|
50
|
+
if (held.size > 0 || handingBack > 0 || waiters.length === 0) return;
|
|
51
|
+
const woken = waiters;
|
|
52
|
+
waiters = [];
|
|
53
|
+
for (const wake of woken) wake();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
get size() {
|
|
58
|
+
return held.size;
|
|
59
|
+
},
|
|
60
|
+
ids() {
|
|
61
|
+
return [...new Set([...held.values()].map((holding) => holding.claimed.id))];
|
|
62
|
+
},
|
|
63
|
+
add(run, claimed, release) {
|
|
64
|
+
held.set(run, { claimed, release });
|
|
65
|
+
},
|
|
66
|
+
settle(run) {
|
|
67
|
+
held.delete(run);
|
|
68
|
+
wakeIfEmpty();
|
|
69
|
+
},
|
|
70
|
+
empty() {
|
|
71
|
+
if (held.size === 0 && handingBack === 0) return Promise.resolve();
|
|
72
|
+
return new Promise<void>((resolve) => {
|
|
73
|
+
waiters.push(resolve);
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
async handBackAll() {
|
|
77
|
+
// Taken out of the set BEFORE the first await: a run settling while the nacks are on the
|
|
78
|
+
// wire is a run the hand-back already owns, and must not be counted twice. And `empty()`
|
|
79
|
+
// stays unanswered until the nacks land — the teardown waiting on it closes the driver next.
|
|
80
|
+
const stranded = [...held.values()];
|
|
81
|
+
held.clear();
|
|
82
|
+
if (stranded.length === 0) return 0;
|
|
83
|
+
handingBack += 1;
|
|
84
|
+
try {
|
|
85
|
+
await handBack(
|
|
86
|
+
options.driver,
|
|
87
|
+
stranded.map((holding) => holding.claimed),
|
|
88
|
+
{ delayMs: 0, workerId: options.workerId },
|
|
89
|
+
);
|
|
90
|
+
// After the nacks, never before: the slot is what lets the replacement run a
|
|
91
|
+
// `concurrency: 1` job, and freed first it could be taken while the row was still ours.
|
|
92
|
+
for (const holding of stranded) await holding.release();
|
|
93
|
+
} finally {
|
|
94
|
+
handingBack -= 1;
|
|
95
|
+
wakeIfEmpty();
|
|
96
|
+
}
|
|
97
|
+
return stranded.length;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|