@ultimat3/jobs 23.0.0 → 25.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 +72 -75
- package/README.md +83 -38
- package/package.json +7 -7
- package/src/backfill-errors.ts +3 -2
- package/src/backfill-ledger.ts +25 -3
- package/src/backfill-pass.ts +22 -5
- package/src/backfill-rate.ts +2 -2
- package/src/backfill.ts +2 -2
- package/src/claim-exhausted.ts +76 -0
- package/src/counters.ts +1 -1
- package/src/driver-memory-operator.ts +34 -12
- package/src/driver-memory.ts +80 -38
- package/src/driver-pg-jobs-sql.ts +27 -19
- package/src/driver-pg-operator-sql.ts +32 -12
- package/src/driver-pg-operator.ts +19 -3
- package/src/driver-pg-rows.ts +3 -3
- package/src/driver-pg-settle-sql.ts +2 -3
- package/src/driver-pg-sql.ts +99 -30
- package/src/driver-pg.ts +103 -98
- package/src/driver.ts +46 -16
- package/src/errors-concurrency.ts +1 -1
- package/src/errors-operator.ts +12 -4
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +9 -17
- package/src/events-pg.ts +11 -21
- package/src/events.ts +26 -15
- package/src/execute.ts +47 -5
- package/src/export.ts +2 -2
- package/src/heartbeat.ts +7 -2
- package/src/index.ts +45 -99
- package/src/inspect-operator.ts +4 -4
- package/src/inspect.ts +5 -4
- package/src/introspection.ts +23 -11
- package/src/job.ts +6 -7
- package/src/leases.ts +1 -1
- package/src/limits.ts +46 -15
- package/src/outbox-lease.ts +1 -1
- package/src/outbox-pg.ts +6 -7
- package/src/outbox-relay.ts +6 -6
- package/src/outbox.ts +47 -19
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +1 -2
- 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 +19 -16
- package/src/scheduler-state.ts +10 -3
- package/src/scheduler.ts +14 -15
- package/src/steps-memory.ts +2 -2
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +90 -88
- package/src/task.ts +1 -1
- package/src/webhook-attempt.ts +206 -0
- package/src/webhook-ledger.ts +15 -4
- package/src/webhook.ts +64 -118
- package/src/worker-admit.ts +14 -6
- package/src/worker-drain-cutoff.ts +110 -0
- package/src/worker-fleet-slots.ts +82 -45
- package/src/worker-held.ts +100 -0
- package/src/worker-key-busy.ts +38 -10
- package/src/worker-loop.ts +35 -11
- package/src/worker-options.ts +19 -15
- package/src/worker-registry.ts +16 -1
- 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 +124 -73
- package/src/driver-nats.ts +0 -86
- package/src/driver-redis.ts +0 -89
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// One delivery attempt on the wire: the headers it carries, the POST, what the answer means, and
|
|
2
|
+
// which of the three failure codes it becomes. Everything BEFORE the socket — the endpoint, the
|
|
3
|
+
// screen, the event, the gates — and everything AFTER it — the ledger, disable-after-N — is
|
|
4
|
+
// `webhook.ts`'s; this file only ever sees an approved target and a signed body.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
// Core's ONE table, never a fifth copy of it. The copy that lived here omitted 409, so a
|
|
8
|
+
// receiver saying "a concurrent writer won this round" dead-lettered on attempt 1 as a refusal
|
|
9
|
+
// no retry could change — the exact divergence `retryable-status.ts` was extracted to end.
|
|
10
|
+
isRetryableStatus,
|
|
11
|
+
isUltimateError,
|
|
12
|
+
renderThrowable,
|
|
13
|
+
retryAfterSecondsOf,
|
|
14
|
+
webhookHeaders,
|
|
15
|
+
} from '@ultimat3/core';
|
|
16
|
+
import { JobTimeoutError } from './errors';
|
|
17
|
+
import {
|
|
18
|
+
WebhookDeliveryFailedError,
|
|
19
|
+
WebhookDeliveryRejectedError,
|
|
20
|
+
WebhookDeliveryThrottledError,
|
|
21
|
+
} from './webhook-errors';
|
|
22
|
+
import type { WebhookTarget } from './webhook-target';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The transport — just the call: `typeof fetch` also carries `preconnect`, which no test double
|
|
26
|
+
* should have to. `init.tls.serverName` carries the hostname a PINNED connection proves, because
|
|
27
|
+
* the url it is handed names the approved address (`webhook-target.ts`); Bun's `fetch` honours it.
|
|
28
|
+
*/
|
|
29
|
+
export type WebhookFetch = (
|
|
30
|
+
url: string,
|
|
31
|
+
init: RequestInit & { readonly tls?: { readonly serverName?: string } },
|
|
32
|
+
) => Promise<Response>;
|
|
33
|
+
|
|
34
|
+
/** The one content type a delivery announces. The BODY is the app's; how it is framed is not. */
|
|
35
|
+
export const WEBHOOK_CONTENT_TYPE = 'application/json';
|
|
36
|
+
|
|
37
|
+
/** What of a `WebhookEndpoint` an attempt reads. Never the secret: that is inside `signing`. */
|
|
38
|
+
export interface DeliveryEndpoint {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly url: string;
|
|
41
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type Outcome =
|
|
45
|
+
| { readonly ok: true; readonly status: number }
|
|
46
|
+
| {
|
|
47
|
+
readonly ok: false;
|
|
48
|
+
readonly status: number | null;
|
|
49
|
+
readonly detail: string;
|
|
50
|
+
readonly retryAfterSeconds?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The receiver's `Retry-After`, through core's one reader: delta-seconds as before, and now the
|
|
55
|
+
* HTTP-date form too, measured against the SAME response's `Date` — the receiver's clock against
|
|
56
|
+
* the receiver's clock, which is what made the date safe to read without trusting ours. Capped at
|
|
57
|
+
* a day, and the nack clamps it to the policy's `maxDelay` on top.
|
|
58
|
+
*/
|
|
59
|
+
const retryAfterSeconds = (response: Response): number | undefined =>
|
|
60
|
+
retryAfterSecondsOf(response.headers.get('retry-after'), response.headers.get('date'));
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The row's own headers, then the framework's — each SET, case-insensitively, over whatever the row
|
|
64
|
+
* said. An object spread cannot do this: `{ Host: 'evil.test', host: <pinned> }` keeps both keys and
|
|
65
|
+
* `fetch` joins them, so a row would reach another virtual host, change the content type, and ship
|
|
66
|
+
* a forged value inside the signature list. Going through `Headers` collapses every spelling of a
|
|
67
|
+
* name to one entry before the framework's value replaces it. Returned as a plain lowercase record,
|
|
68
|
+
* because an injected `WebhookFetch` has always been handed one.
|
|
69
|
+
*
|
|
70
|
+
* Inside the caller's `try`: a row header the platform refuses (a newline in a value) is the same
|
|
71
|
+
* failed attempt it always was, never a bare `TypeError` out of the job.
|
|
72
|
+
*/
|
|
73
|
+
function deliveryHeaders(
|
|
74
|
+
endpoint: DeliveryEndpoint,
|
|
75
|
+
target: WebhookTarget,
|
|
76
|
+
signing: Parameters<typeof webhookHeaders>[0],
|
|
77
|
+
): Record<string, string> {
|
|
78
|
+
const headers = new Headers(endpoint.headers);
|
|
79
|
+
// The name the pinned address is reached AS, so an endpoint cannot redirect the request to a
|
|
80
|
+
// different virtual host than the one it registered.
|
|
81
|
+
headers.set('host', target.host);
|
|
82
|
+
headers.set('content-type', WEBHOOK_CONTENT_TYPE);
|
|
83
|
+
// The signature and the event identity it covers: a row can never overwrite what it is proved by.
|
|
84
|
+
for (const [name, value] of Object.entries(webhookHeaders(signing))) headers.set(name, value);
|
|
85
|
+
return Object.fromEntries(headers.entries());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The reason `JobDrainedError` carries — the one cancellation a request on the wire outlives. */
|
|
89
|
+
const DRAINING = 'X_DRAINING';
|
|
90
|
+
|
|
91
|
+
const isDrain = (reason: unknown): boolean => isUltimateError(reason) && reason.code === DRAINING;
|
|
92
|
+
|
|
93
|
+
/** The request's own signal, and the listener it hangs on the attempt's, handed back after. */
|
|
94
|
+
export interface DeliverySignal {
|
|
95
|
+
readonly signal: AbortSignal;
|
|
96
|
+
dispose(): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Where this attempt's own deadline falls, on `performance.now()`'s clock. */
|
|
100
|
+
export interface AttemptDeadline {
|
|
101
|
+
readonly atMs: number;
|
|
102
|
+
readonly job: string;
|
|
103
|
+
readonly timeoutMs: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The attempt's signal for every reason but the DRAIN. A deadline or a lost lease is followed by a
|
|
108
|
+
* nack that hands the job to another worker at once, so a request still open is the receiver
|
|
109
|
+
* getting the event twice: those tear it down. The drain is the opposite case — tearing a POST
|
|
110
|
+
* already on the wire down hands the job to the next pod, which re-POSTs the same event on every
|
|
111
|
+
* deploy — so the request finishes inside the drain budget instead, and its outcome is recorded.
|
|
112
|
+
*
|
|
113
|
+
* Not past the attempt's DEADLINE, though. The attempt's signal keeps its FIRST reason, so once the
|
|
114
|
+
* drain has aborted it the deadline behind it never arrives there — while `executeJob` still hands
|
|
115
|
+
* the job on when the deadline passes. So a drained request arms the deadline itself.
|
|
116
|
+
*/
|
|
117
|
+
export function deliverySignal(attempt: AbortSignal, deadline?: AttemptDeadline): DeliverySignal {
|
|
118
|
+
const request = new AbortController();
|
|
119
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
120
|
+
const follow = (): void => {
|
|
121
|
+
if (!isDrain(attempt.reason)) {
|
|
122
|
+
request.abort(attempt.reason);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (deadline === undefined) return;
|
|
126
|
+
timer = setTimeout(
|
|
127
|
+
() =>
|
|
128
|
+
request.abort(new JobTimeoutError({ job: deadline.job, timeoutMs: deadline.timeoutMs })),
|
|
129
|
+
Math.max(0, deadline.atMs - performance.now()),
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
if (attempt.aborted) follow();
|
|
133
|
+
else attempt.addEventListener('abort', follow, { once: true });
|
|
134
|
+
return {
|
|
135
|
+
signal: request.signal,
|
|
136
|
+
dispose: () => {
|
|
137
|
+
attempt.removeEventListener('abort', follow);
|
|
138
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One POST, on `deliverySignal`'s signal: torn down by the deadline or a lost lease, never by the
|
|
145
|
+
* drain.
|
|
146
|
+
*/
|
|
147
|
+
export async function attemptDelivery(
|
|
148
|
+
send: WebhookFetch,
|
|
149
|
+
endpoint: DeliveryEndpoint,
|
|
150
|
+
target: WebhookTarget,
|
|
151
|
+
signing: Parameters<typeof webhookHeaders>[0],
|
|
152
|
+
signal: AbortSignal,
|
|
153
|
+
): Promise<Outcome> {
|
|
154
|
+
let response: Response;
|
|
155
|
+
try {
|
|
156
|
+
response = await send(target.url, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: deliveryHeaders(endpoint, target, signing),
|
|
159
|
+
...(target.serverName === undefined ? {} : { tls: { serverName: target.serverName } }),
|
|
160
|
+
body: signing.body,
|
|
161
|
+
// Never followed: a 3xx would re-POST a body signed for one host to whatever the receiver
|
|
162
|
+
// named, and the signature would travel with it.
|
|
163
|
+
redirect: 'manual',
|
|
164
|
+
signal,
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
// `renderThrowable`, never `String(error)` or `${error}`: this string lands in a `cause` and on
|
|
168
|
+
// a durable ledger row, and a null-prototype throwable makes both of those a `TypeError`.
|
|
169
|
+
return { ok: false, status: null, detail: renderThrowable(error) };
|
|
170
|
+
}
|
|
171
|
+
if (response.ok) return { ok: true, status: response.status };
|
|
172
|
+
const stated = retryAfterSeconds(response);
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
status: response.status,
|
|
176
|
+
detail: `status ${response.status}`,
|
|
177
|
+
...(stated === undefined ? {} : { retryAfterSeconds: stated }),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Which of the three failure codes this outcome is. The split is "can the same request land?". */
|
|
182
|
+
export function deliveryError(
|
|
183
|
+
name: string,
|
|
184
|
+
endpoint: DeliveryEndpoint,
|
|
185
|
+
outcome: Extract<Outcome, { ok: false }>,
|
|
186
|
+
): Error {
|
|
187
|
+
const base = { webhook: name, endpointId: endpoint.id, url: endpoint.url };
|
|
188
|
+
if (outcome.status === null) {
|
|
189
|
+
return new WebhookDeliveryFailedError({ ...base, status: null, detail: outcome.detail });
|
|
190
|
+
}
|
|
191
|
+
if (!isRetryableStatus(outcome.status)) {
|
|
192
|
+
return new WebhookDeliveryRejectedError({ ...base, status: outcome.status });
|
|
193
|
+
}
|
|
194
|
+
if (outcome.retryAfterSeconds !== undefined) {
|
|
195
|
+
return new WebhookDeliveryThrottledError({
|
|
196
|
+
...base,
|
|
197
|
+
status: outcome.status,
|
|
198
|
+
retryAfterSeconds: outcome.retryAfterSeconds,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return new WebhookDeliveryFailedError({
|
|
202
|
+
...base,
|
|
203
|
+
status: outcome.status,
|
|
204
|
+
detail: outcome.detail,
|
|
205
|
+
});
|
|
206
|
+
}
|
package/src/webhook-ledger.ts
CHANGED
|
@@ -39,11 +39,19 @@ export interface WebhookLedger {
|
|
|
39
39
|
*/
|
|
40
40
|
record(attempt: WebhookAttempt): Promise<number>;
|
|
41
41
|
/**
|
|
42
|
-
* Stop delivering to this endpoint. Called
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* Stop delivering to this endpoint. Called by the attempt whose count reached `disableAfter` —
|
|
43
|
+
* and, rarely, by a second attempt that was already in flight when the first one crossed it, so
|
|
44
|
+
* an implementation is IDEMPOTENT: disabling a disabled endpoint changes nothing. Re-enabling is
|
|
45
|
+
* the app's — a dead endpoint that heals itself is a retry loop with no end.
|
|
45
46
|
*/
|
|
46
47
|
disable(endpointId: string, reason: string): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Whether `disable` has switched this endpoint off. Asked before every attempt opens a socket,
|
|
50
|
+
* because `disable` is the only thing the mechanism writes: a ledger that recorded the verdict
|
|
51
|
+
* and was never asked for it again is an endpoint that keeps receiving POSTs. An app whose
|
|
52
|
+
* `disable` sets its endpoint row's `disabled` column answers from that same column.
|
|
53
|
+
*/
|
|
54
|
+
isDisabled(endpointId: string): Promise<boolean>;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/** Attempts one process keeps before the oldest go. A dev ledger that grows forever is a leak. */
|
|
@@ -88,9 +96,12 @@ export function memoryWebhookLedger(
|
|
|
88
96
|
return Promise.resolve(failures);
|
|
89
97
|
},
|
|
90
98
|
disable(endpointId: string, reason: string): Promise<void> {
|
|
91
|
-
off
|
|
99
|
+
// The FIRST reason stays: it names the run that switched the endpoint off, and a late
|
|
100
|
+
// in-flight attempt repeating the verdict must not overwrite it.
|
|
101
|
+
if (!off.has(endpointId)) off.set(endpointId, reason);
|
|
92
102
|
return Promise.resolve();
|
|
93
103
|
},
|
|
104
|
+
isDisabled: (endpointId: string): Promise<boolean> => Promise.resolve(off.has(endpointId)),
|
|
94
105
|
attempts: (): readonly WebhookAttempt[] => [...attempts],
|
|
95
106
|
disabled: (): ReadonlyMap<string, string> => new Map(off),
|
|
96
107
|
reset(): void {
|
package/src/webhook.ts
CHANGED
|
@@ -19,14 +19,10 @@ 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';
|
|
@@ -36,10 +32,9 @@ import { job } from './job';
|
|
|
36
32
|
import type { RetryPolicy } from './retry';
|
|
37
33
|
import { DEFAULT_RETRY } from './retry';
|
|
38
34
|
import type { JobTenant } from './tenant';
|
|
35
|
+
import type { Outcome, WebhookFetch } from './webhook-attempt';
|
|
36
|
+
import { attemptDelivery, deliveryError, deliverySignal } from './webhook-attempt';
|
|
39
37
|
import {
|
|
40
|
-
WebhookDeliveryFailedError,
|
|
41
|
-
WebhookDeliveryRejectedError,
|
|
42
|
-
WebhookDeliveryThrottledError,
|
|
43
38
|
WebhookEndpointDisabledError,
|
|
44
39
|
WebhookEndpointInvalidError,
|
|
45
40
|
WebhookEndpointUnknownError,
|
|
@@ -47,19 +42,9 @@ import {
|
|
|
47
42
|
WebhookEventUnknownError,
|
|
48
43
|
} from './webhook-errors';
|
|
49
44
|
import type { WebhookLedger } from './webhook-ledger';
|
|
50
|
-
import type { WebhookResolve
|
|
45
|
+
import type { WebhookResolve } from './webhook-target';
|
|
51
46
|
import { resolveWebhookHost, webhookTarget } from './webhook-target';
|
|
52
47
|
|
|
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
48
|
/**
|
|
64
49
|
* Consecutive failures before an endpoint stops taking deliveries. Ten is roughly a day of a
|
|
65
50
|
* retrying queue against a dead receiver, which is long enough for an outage and short enough that
|
|
@@ -67,12 +52,6 @@ export type WebhookFetch = (
|
|
|
67
52
|
*/
|
|
68
53
|
export const DEFAULT_WEBHOOK_DISABLE_AFTER = 10;
|
|
69
54
|
|
|
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
55
|
export interface WebhookEndpoint {
|
|
77
56
|
readonly id: string;
|
|
78
57
|
/**
|
|
@@ -184,13 +163,6 @@ export interface WebhookDefinition {
|
|
|
184
163
|
readonly timeout?: DurationInput;
|
|
185
164
|
}
|
|
186
165
|
|
|
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
166
|
export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliveryInput> {
|
|
195
167
|
const clock = definition.clock ?? systemClock;
|
|
196
168
|
const disableAfter = finiteOption(
|
|
@@ -200,7 +172,8 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
200
172
|
);
|
|
201
173
|
const send = definition.fetch ?? ((url, init) => fetch(url, init));
|
|
202
174
|
|
|
203
|
-
|
|
175
|
+
// Named so `run` can read the deadline `job()` resolved — one parse of `timeout`, never two.
|
|
176
|
+
const handle: JobHandle<WebhookDeliveryInput> = job<WebhookDeliveryInput>({
|
|
204
177
|
name: definition.name,
|
|
205
178
|
input: t.object({ endpointId: t.string, eventId: t.string }),
|
|
206
179
|
// Endpoint AND event: the same event fans out to every subscribed endpoint, so a key on the
|
|
@@ -211,6 +184,16 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
211
184
|
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
212
185
|
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
213
186
|
async run({ input, ctx, attempt }): Promise<WebhookReport> {
|
|
187
|
+
// On the clock `executeJob`'s deadline timer runs on, read as the body starts — which is
|
|
188
|
+
// when that timer is armed.
|
|
189
|
+
const deadline =
|
|
190
|
+
handle.timeoutMs === undefined
|
|
191
|
+
? undefined
|
|
192
|
+
: {
|
|
193
|
+
atMs: performance.now() + handle.timeoutMs,
|
|
194
|
+
job: definition.name,
|
|
195
|
+
timeoutMs: handle.timeoutMs,
|
|
196
|
+
};
|
|
214
197
|
const endpoint = await definition.endpoint({ endpointId: input.endpointId, ctx });
|
|
215
198
|
if (endpoint === null) {
|
|
216
199
|
throw new WebhookEndpointUnknownError({
|
|
@@ -220,7 +203,9 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
220
203
|
}
|
|
221
204
|
// Before the ledger and before the socket: a disabled endpoint costs nothing, which is the
|
|
222
205
|
// whole point of disabling one.
|
|
223
|
-
|
|
206
|
+
// Asked of BOTH sources, because `disableAfter` writes only the ledger: an endpoint the
|
|
207
|
+
// mechanism switched off must stop here whether or not the app mirrored it onto its row.
|
|
208
|
+
if (endpoint.disabled === true || (await definition.ledger.isDisabled(endpoint.id))) {
|
|
224
209
|
throw new WebhookEndpointDisabledError({
|
|
225
210
|
webhook: definition.name,
|
|
226
211
|
endpointId: endpoint.id,
|
|
@@ -260,17 +245,41 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
260
245
|
// again now, so a receiver's freshness window measures the request in front of it rather
|
|
261
246
|
// than the age of the fact behind it.
|
|
262
247
|
const timestampSeconds = Math.floor(nowMs(clock) / 1_000);
|
|
248
|
+
// The last moment a cancelled attempt can stop without the receiver hearing anything — the
|
|
249
|
+
// drain included: a POST not yet started is handed back to the next pod at no cost.
|
|
250
|
+
stopIfCancelled(ctx);
|
|
263
251
|
const startedAt = clock.monotonic();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
252
|
+
// Past this line the drain no longer cancels: see `deliverySignal`.
|
|
253
|
+
const request = deliverySignal(ctx.signal, deadline);
|
|
254
|
+
let outcome: Outcome;
|
|
255
|
+
try {
|
|
256
|
+
outcome = await ('unresolved' in target
|
|
257
|
+
? Promise.resolve({ ok: false as const, status: null, detail: target.unresolved })
|
|
258
|
+
: attemptDelivery(
|
|
259
|
+
send,
|
|
260
|
+
endpoint,
|
|
261
|
+
target,
|
|
262
|
+
{
|
|
263
|
+
secret: endpoint.secret,
|
|
264
|
+
timestampSeconds,
|
|
265
|
+
eventId: input.eventId,
|
|
266
|
+
topic: event.topic,
|
|
267
|
+
body: event.body,
|
|
268
|
+
},
|
|
269
|
+
request.signal,
|
|
270
|
+
));
|
|
271
|
+
} finally {
|
|
272
|
+
request.dispose();
|
|
273
|
+
}
|
|
273
274
|
const durationMs = Math.max(0, clock.monotonic() - startedAt);
|
|
275
|
+
// A failure the REQUEST's cancellation caused is not the receiver's, and is not this
|
|
276
|
+
// attempt's to record: the queue has already handed the delivery to another worker, whose
|
|
277
|
+
// outcome is the one the consecutive count must hear. Recorded, it was a failure nobody saw
|
|
278
|
+
// counting toward `disableAfter` — an abandoned attempt switching off a healthy endpoint.
|
|
279
|
+
// Read off the request's signal, never the attempt's: a receiver that really failed while
|
|
280
|
+
// the worker drained is a failure like any other, and the count hears it. A delivery that
|
|
281
|
+
// LANDED is a fact, and is always recorded.
|
|
282
|
+
if (!outcome.ok && request.signal.aborted) stopIfCancelled(ctx);
|
|
274
283
|
|
|
275
284
|
// Recorded whatever happened, and BEFORE the throw: a failure that is not on the ledger is a
|
|
276
285
|
// failure the consecutive count cannot see, which is an endpoint that never gets disabled.
|
|
@@ -311,6 +320,7 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
311
320
|
throw deliveryError(definition.name, endpoint, outcome);
|
|
312
321
|
},
|
|
313
322
|
});
|
|
323
|
+
return handle;
|
|
314
324
|
}
|
|
315
325
|
|
|
316
326
|
/** A URL no delivery may open, or a secret that would make the POST unsigned. The parsed url. */
|
|
@@ -346,78 +356,14 @@ function assertDeliverable(name: string, endpoint: WebhookEndpoint): URL {
|
|
|
346
356
|
return parsed;
|
|
347
357
|
}
|
|
348
358
|
|
|
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
|
-
});
|
|
359
|
+
/**
|
|
360
|
+
* Throw the attempt's cancellation as itself — the deadline's `X_JOB_TIMEOUT`, the drain's
|
|
361
|
+
* `X_DRAINING`, a lost lease — so `executeJob` reads the stop it caused, never a delivery failure
|
|
362
|
+
* naming a receiver that did nothing wrong. Core's `X_ABORTED` when the reason carries no code.
|
|
363
|
+
*/
|
|
364
|
+
function stopIfCancelled(ctx: Ctx): void {
|
|
365
|
+
if (!ctx.signal.aborted) return;
|
|
366
|
+
const reason: unknown = ctx.signal.reason;
|
|
367
|
+
if (isUltimateError(reason)) throw reason;
|
|
368
|
+
throwIfAborted(ctx);
|
|
423
369
|
}
|
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' };
|
|
@@ -95,7 +97,8 @@ export function createAdmission(options: AdmissionOptions): Admit {
|
|
|
95
97
|
try {
|
|
96
98
|
grant = await fleetSlots.acquire(job);
|
|
97
99
|
} catch (error) {
|
|
98
|
-
|
|
100
|
+
// Never started: the slot AND the rate stamp go back (`Lease.abandon`).
|
|
101
|
+
lease.abandon();
|
|
99
102
|
// This job and every one behind it go BACK, unburned — rethrowing alone stranded them in
|
|
100
103
|
// `running` with an attempt spent on work that never started.
|
|
101
104
|
await handBack(driver, [job, ...behind], back);
|
|
@@ -106,7 +109,9 @@ export function createAdmission(options: AdmissionOptions): Admit {
|
|
|
106
109
|
// refusal before the body: it fails as an attempt, never as a round.
|
|
107
110
|
if (grant.outcome === 'undecidable') return { kind: 'run', lease, refusal: grant.error };
|
|
108
111
|
|
|
109
|
-
|
|
112
|
+
// Over the fleet cap, so this run does not start: `abandon`, never `release`, or a tenant
|
|
113
|
+
// waiting behind a full `job.concurrency` pays one `ratePerTenant` start per poll interval.
|
|
114
|
+
lease.abandon();
|
|
110
115
|
try {
|
|
111
116
|
// The two `whenBusy` answers. `'fail'` settles the run here, its body never started.
|
|
112
117
|
if (grant.outcome === 'fail') {
|
|
@@ -119,12 +124,15 @@ export function createAdmission(options: AdmissionOptions): Admit {
|
|
|
119
124
|
context: options.context,
|
|
120
125
|
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
121
126
|
});
|
|
122
|
-
|
|
127
|
+
// No execution: the nack did not land, so the row is not this worker's to have refused.
|
|
128
|
+
return execution === undefined ? { kind: 'waiting' } : { kind: 'refused', execution };
|
|
123
129
|
}
|
|
124
130
|
const keyed = grant.key === undefined ? '' : `, key ${grant.key}`;
|
|
125
131
|
await shed(job, queue, `job concurrency (${grant.limit}${keyed})`);
|
|
126
132
|
} catch (error) {
|
|
127
|
-
|
|
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);
|
|
128
136
|
throw error;
|
|
129
137
|
}
|
|
130
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
|
+
}
|