@beignet/core 0.0.31 → 0.0.33
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/CHANGELOG.md +115 -0
- package/README.md +346 -21
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +63 -47
- package/dist/application/index.js.map +1 -1
- package/dist/domain/entity.d.ts +37 -6
- package/dist/domain/entity.d.ts.map +1 -1
- package/dist/domain/entity.js +9 -7
- package/dist/domain/entity.js.map +1 -1
- package/dist/domain/index.d.ts +4 -4
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +4 -4
- package/dist/domain/index.js.map +1 -1
- package/dist/events/index.d.ts +6 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +20 -10
- package/dist/events/index.js.map +1 -1
- package/dist/flags/index.d.ts +5 -4
- package/dist/flags/index.d.ts.map +1 -1
- package/dist/flags/index.js +10 -9
- package/dist/flags/index.js.map +1 -1
- package/dist/idempotency/index.d.ts +11 -1
- package/dist/idempotency/index.d.ts.map +1 -1
- package/dist/idempotency/index.js +4 -3
- package/dist/idempotency/index.js.map +1 -1
- package/dist/jobs/index.d.ts +449 -9
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +472 -12
- package/dist/jobs/index.js.map +1 -1
- package/dist/outbox/index.d.ts +170 -3
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +192 -8
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/events.d.ts +2 -2
- package/dist/ports/index.d.ts +6 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js +1 -0
- package/dist/ports/index.js.map +1 -1
- package/dist/providers/instrumentation.d.ts +4 -0
- package/dist/providers/instrumentation.d.ts.map +1 -1
- package/dist/providers/instrumentation.js.map +1 -1
- package/dist/providers/provider.d.ts +10 -2
- package/dist/providers/provider.d.ts.map +1 -1
- package/dist/providers/provider.js.map +1 -1
- package/dist/schedules/index.d.ts +8 -7
- package/dist/schedules/index.d.ts.map +1 -1
- package/dist/schedules/index.js +47 -16
- package/dist/schedules/index.js.map +1 -1
- package/dist/search/index.d.ts +2 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +1 -0
- package/dist/search/index.js.map +1 -1
- package/dist/server/hooks/index.d.ts +1 -0
- package/dist/server/hooks/index.d.ts.map +1 -1
- package/dist/server/hooks/index.js +1 -0
- package/dist/server/hooks/index.js.map +1 -1
- package/dist/server/hooks/rate-limit.d.ts +26 -13
- package/dist/server/hooks/rate-limit.d.ts.map +1 -1
- package/dist/server/hooks/rate-limit.js +28 -34
- package/dist/server/hooks/rate-limit.js.map +1 -1
- package/dist/server/hooks/security.d.ts +179 -0
- package/dist/server/hooks/security.d.ts.map +1 -0
- package/dist/server/hooks/security.js +225 -0
- package/dist/server/hooks/security.js.map +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +8 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/instrumentation.d.ts.map +1 -1
- package/dist/server/instrumentation.js +22 -6
- package/dist/server/instrumentation.js.map +1 -1
- package/dist/server/providers/loadProviderConfig.d.ts.map +1 -1
- package/dist/server/providers/loadProviderConfig.js +15 -2
- package/dist/server/providers/loadProviderConfig.js.map +1 -1
- package/dist/server/request-context.d.ts +4 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +3 -0
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/runtime-integrity.d.ts +84 -0
- package/dist/server/runtime-integrity.d.ts.map +1 -0
- package/dist/server/runtime-integrity.js +160 -0
- package/dist/server/runtime-integrity.js.map +1 -0
- package/dist/server/server.d.ts +10 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +55 -1
- package/dist/server/server.js.map +1 -1
- package/dist/server/trusted-proxy.d.ts +77 -0
- package/dist/server/trusted-proxy.d.ts.map +1 -0
- package/dist/server/trusted-proxy.js +129 -0
- package/dist/server/trusted-proxy.js.map +1 -0
- package/dist/tasks/index.d.ts +6 -7
- package/dist/tasks/index.d.ts.map +1 -1
- package/dist/tasks/index.js +22 -5
- package/dist/tasks/index.js.map +1 -1
- package/dist/tenancy/index.d.ts +41 -0
- package/dist/tenancy/index.d.ts.map +1 -0
- package/dist/tenancy/index.js +34 -0
- package/dist/tenancy/index.js.map +1 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +12 -3
- package/dist/testing/index.js.map +1 -1
- package/dist/tracing/execution.d.ts +14 -0
- package/dist/tracing/execution.d.ts.map +1 -0
- package/dist/tracing/execution.js +17 -0
- package/dist/tracing/execution.js.map +1 -0
- package/dist/tracing/index.d.ts +49 -0
- package/dist/tracing/index.d.ts.map +1 -1
- package/dist/tracing/index.js +59 -0
- package/dist/tracing/index.js.map +1 -1
- package/dist/uploads/client.d.ts.map +1 -1
- package/dist/uploads/client.js +44 -6
- package/dist/uploads/client.js.map +1 -1
- package/dist/uploads/index.d.ts +182 -2
- package/dist/uploads/index.d.ts.map +1 -1
- package/dist/uploads/index.js +468 -76
- package/dist/uploads/index.js.map +1 -1
- package/dist/webhooks/index.d.ts +56 -4
- package/dist/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +107 -2
- package/dist/webhooks/index.js.map +1 -1
- package/package.json +5 -1
- package/skills/app-architecture/SKILL.md +23 -2
- package/src/application/index.ts +88 -56
- package/src/domain/entity.ts +61 -13
- package/src/domain/index.ts +8 -7
- package/src/events/index.ts +27 -14
- package/src/flags/index.ts +23 -19
- package/src/idempotency/index.ts +17 -3
- package/src/jobs/index.ts +1022 -19
- package/src/outbox/index.ts +409 -9
- package/src/ports/events.ts +2 -2
- package/src/ports/index.ts +17 -0
- package/src/providers/instrumentation.ts +4 -0
- package/src/providers/provider.ts +11 -2
- package/src/schedules/index.ts +60 -26
- package/src/search/index.ts +3 -1
- package/src/server/hooks/index.ts +10 -0
- package/src/server/hooks/rate-limit.ts +52 -46
- package/src/server/hooks/security.ts +503 -0
- package/src/server/index.ts +8 -0
- package/src/server/instrumentation.ts +25 -5
- package/src/server/providers/loadProviderConfig.ts +19 -2
- package/src/server/request-context.ts +7 -0
- package/src/server/runtime-integrity.ts +322 -0
- package/src/server/server.ts +86 -1
- package/src/server/trusted-proxy.ts +249 -0
- package/src/tasks/index.ts +29 -12
- package/src/tenancy/index.ts +55 -0
- package/src/testing/index.ts +19 -3
- package/src/tracing/execution.ts +37 -0
- package/src/tracing/index.ts +137 -0
- package/src/uploads/client.ts +65 -6
- package/src/uploads/index.ts +713 -76
- package/src/webhooks/index.ts +240 -9
- package/src/domain/events.ts +0 -59
package/src/jobs/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { LeaseHandle, LeaseMetadata, LocksPort } from "../locks/index.js";
|
|
3
|
+
import { runWithResolvedTracingContext } from "../tracing/execution.js";
|
|
4
|
+
import type { TracingPort } from "../tracing/index.js";
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Any Standard Schema compatible validator.
|
|
@@ -10,6 +13,8 @@ export type StandardSchema = StandardSchemaV1<unknown, unknown>;
|
|
|
10
13
|
*/
|
|
11
14
|
export type MaybePromise<T> = T | Promise<T>;
|
|
12
15
|
|
|
16
|
+
type NoInferJob<T> = [T][T extends unknown ? 0 : never];
|
|
17
|
+
|
|
13
18
|
/**
|
|
14
19
|
* Infer the parsed output type from a Standard Schema.
|
|
15
20
|
*/
|
|
@@ -17,15 +22,30 @@ export type InferSchemaOutput<T extends StandardSchemaV1> =
|
|
|
17
22
|
StandardSchemaV1.InferOutput<T>;
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
|
-
* Duration accepted by
|
|
25
|
+
* Duration accepted by job helpers. Numbers are milliseconds.
|
|
21
26
|
*/
|
|
22
|
-
export type
|
|
27
|
+
export type JobDuration =
|
|
23
28
|
| number
|
|
24
29
|
| `${number}ms`
|
|
25
30
|
| `${number}s`
|
|
26
31
|
| `${number}m`
|
|
27
32
|
| `${number}h`;
|
|
28
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Duration accepted by retry helpers. Numbers are milliseconds.
|
|
36
|
+
*/
|
|
37
|
+
export type JobRetryDuration = JobDuration;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Duration accepted by job timeout helpers. Numbers are milliseconds.
|
|
41
|
+
*/
|
|
42
|
+
export type JobTimeoutDuration = JobDuration;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Duration accepted by job execution lease helpers. Numbers are milliseconds.
|
|
46
|
+
*/
|
|
47
|
+
export type JobExecutionLeaseDuration = JobDuration;
|
|
48
|
+
|
|
29
49
|
/**
|
|
30
50
|
* Retry strategy understood by Beignet job adapters.
|
|
31
51
|
*/
|
|
@@ -58,6 +78,71 @@ export interface JobRetryPredicateArgs {
|
|
|
58
78
|
*/
|
|
59
79
|
export type JobRetryPredicate = (args: JobRetryPredicateArgs) => boolean;
|
|
60
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Dispatch-time uniqueness metadata for a job.
|
|
83
|
+
*
|
|
84
|
+
* Unique jobs are suppressed while the resolved key's lease is active.
|
|
85
|
+
* The lease is intentionally left to expire after a successful dispatch.
|
|
86
|
+
*/
|
|
87
|
+
export interface JobUniqueOptions {
|
|
88
|
+
/**
|
|
89
|
+
* Logical uniqueness key within this job name.
|
|
90
|
+
*/
|
|
91
|
+
key: string;
|
|
92
|
+
/**
|
|
93
|
+
* How long duplicate dispatches should be suppressed.
|
|
94
|
+
*/
|
|
95
|
+
ttl: JobDuration;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Arguments passed to a per-payload unique job resolver.
|
|
100
|
+
*/
|
|
101
|
+
export interface JobUniqueResolverArgs<Payload extends StandardSchema> {
|
|
102
|
+
/**
|
|
103
|
+
* Stable job name.
|
|
104
|
+
*/
|
|
105
|
+
jobName: string;
|
|
106
|
+
/**
|
|
107
|
+
* Parsed job payload.
|
|
108
|
+
*/
|
|
109
|
+
payload: StandardSchemaV1.InferOutput<Payload>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Per-payload resolver for dispatch-time uniqueness.
|
|
114
|
+
*/
|
|
115
|
+
export type JobUniqueResolver<Payload extends StandardSchema> = {
|
|
116
|
+
bivarianceHack(
|
|
117
|
+
args: JobUniqueResolverArgs<Payload>,
|
|
118
|
+
): MaybePromise<JobUniqueOptions | null | undefined>;
|
|
119
|
+
}["bivarianceHack"];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Unique job declaration value.
|
|
123
|
+
*/
|
|
124
|
+
export type JobUniqueConfig<Payload extends StandardSchema = StandardSchema> =
|
|
125
|
+
| JobUniqueOptions
|
|
126
|
+
| JobUniqueResolver<Payload>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolved uniqueness metadata used by dispatchers.
|
|
130
|
+
*/
|
|
131
|
+
export interface ResolvedJobUniqueOptions {
|
|
132
|
+
/**
|
|
133
|
+
* Logical uniqueness key returned by the job declaration.
|
|
134
|
+
*/
|
|
135
|
+
key: string;
|
|
136
|
+
/**
|
|
137
|
+
* Concrete lock key used by `LocksPort`.
|
|
138
|
+
*/
|
|
139
|
+
lockKey: string;
|
|
140
|
+
/**
|
|
141
|
+
* Lease TTL in milliseconds.
|
|
142
|
+
*/
|
|
143
|
+
ttlMs: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
61
146
|
/**
|
|
62
147
|
* Job definition created by `defineJob(...)`.
|
|
63
148
|
*/
|
|
@@ -86,6 +171,18 @@ export interface JobDef<
|
|
|
86
171
|
* Retry metadata for durable job providers.
|
|
87
172
|
*/
|
|
88
173
|
readonly retry?: JobRetryOptions;
|
|
174
|
+
/**
|
|
175
|
+
* Optional dispatch-time uniqueness guard.
|
|
176
|
+
*/
|
|
177
|
+
readonly unique?: JobUniqueConfig<Payload>;
|
|
178
|
+
/**
|
|
179
|
+
* Optional maximum execution time for each job handler attempt.
|
|
180
|
+
*/
|
|
181
|
+
readonly timeout?: JobTimeoutDuration;
|
|
182
|
+
/**
|
|
183
|
+
* Optional hooks that wrap each handler attempt for this job.
|
|
184
|
+
*/
|
|
185
|
+
readonly hooks?: readonly JobHook<JobDef<Name, Payload, Ctx>, Ctx>[];
|
|
89
186
|
/**
|
|
90
187
|
* Handle a parsed job payload.
|
|
91
188
|
*/
|
|
@@ -112,10 +209,174 @@ export interface JobHandleArgs<J extends JobDef, Ctx> {
|
|
|
112
209
|
* Parsed job payload.
|
|
113
210
|
*/
|
|
114
211
|
payload: InferJobPayload<J>;
|
|
212
|
+
/** Handler context. */
|
|
213
|
+
ctx: Ctx;
|
|
115
214
|
/**
|
|
116
|
-
*
|
|
215
|
+
* Abort signal that fires when the job's declared timeout expires.
|
|
216
|
+
*
|
|
217
|
+
* Beignet dispatchers and worker helpers provide this signal. It is optional
|
|
218
|
+
* in the type so direct handler tests can stay lightweight.
|
|
219
|
+
*
|
|
220
|
+
* JavaScript cannot forcibly stop arbitrary async work, so handlers that
|
|
221
|
+
* call cancellable APIs should pass this signal through.
|
|
117
222
|
*/
|
|
118
|
-
|
|
223
|
+
signal?: AbortSignal;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Arguments passed to job execution hooks.
|
|
228
|
+
*/
|
|
229
|
+
export interface JobHookArgs<J extends JobDef, Ctx>
|
|
230
|
+
extends JobHandleArgs<J, Ctx> {
|
|
231
|
+
/**
|
|
232
|
+
* Abort signal provided by the Beignet execution runner.
|
|
233
|
+
*/
|
|
234
|
+
signal: AbortSignal;
|
|
235
|
+
/**
|
|
236
|
+
* One-based execution attempt when the runner can report it.
|
|
237
|
+
*/
|
|
238
|
+
attempt?: number;
|
|
239
|
+
/**
|
|
240
|
+
* Maximum execution attempts when the runner can report it.
|
|
241
|
+
*/
|
|
242
|
+
maxAttempts?: number;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Hook that wraps one job handler attempt.
|
|
247
|
+
*
|
|
248
|
+
* Hooks run only through Beignet dispatchers and worker helpers. Direct calls
|
|
249
|
+
* to `job.handle(...)` bypass hooks, which keeps lightweight unit tests
|
|
250
|
+
* possible.
|
|
251
|
+
*/
|
|
252
|
+
export type JobHook<J extends JobDef = JobDef, Ctx = unknown> = {
|
|
253
|
+
bivarianceHack(
|
|
254
|
+
args: JobHookArgs<J, Ctx>,
|
|
255
|
+
next: () => Promise<void>,
|
|
256
|
+
): MaybePromise<void>;
|
|
257
|
+
}["bivarianceHack"];
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Arguments passed to an execution lease key resolver.
|
|
261
|
+
*/
|
|
262
|
+
export type JobExecutionLeaseResolverArgs<J extends JobDef, Ctx> = JobHookArgs<
|
|
263
|
+
J,
|
|
264
|
+
Ctx
|
|
265
|
+
>;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Resolver for the logical execution lease key within a job name.
|
|
269
|
+
*/
|
|
270
|
+
export type JobExecutionLeaseKeyResolver<J extends JobDef, Ctx> = {
|
|
271
|
+
bivarianceHack(
|
|
272
|
+
args: JobExecutionLeaseResolverArgs<J, Ctx>,
|
|
273
|
+
): MaybePromise<string>;
|
|
274
|
+
}["bivarianceHack"];
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Resolver for the locks port used by an execution lease hook.
|
|
278
|
+
*/
|
|
279
|
+
export type JobExecutionLeaseLocksResolver<J extends JobDef, Ctx> =
|
|
280
|
+
| LocksPort
|
|
281
|
+
| {
|
|
282
|
+
bivarianceHack(
|
|
283
|
+
args: JobExecutionLeaseResolverArgs<J, Ctx>,
|
|
284
|
+
): MaybePromise<LocksPort>;
|
|
285
|
+
}["bivarianceHack"];
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolver for optional diagnostics metadata attached to lease acquisition.
|
|
289
|
+
*/
|
|
290
|
+
export type JobExecutionLeaseMetadataResolver<J extends JobDef, Ctx> =
|
|
291
|
+
| LeaseMetadata
|
|
292
|
+
| {
|
|
293
|
+
bivarianceHack(
|
|
294
|
+
args: JobExecutionLeaseResolverArgs<J, Ctx>,
|
|
295
|
+
): MaybePromise<LeaseMetadata | undefined>;
|
|
296
|
+
}["bivarianceHack"];
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Arguments passed when an execution lease cannot be acquired.
|
|
300
|
+
*/
|
|
301
|
+
export interface JobExecutionLeaseUnavailableArgs<J extends JobDef, Ctx>
|
|
302
|
+
extends JobHookArgs<J, Ctx> {
|
|
303
|
+
/**
|
|
304
|
+
* Logical lease key returned by the hook configuration.
|
|
305
|
+
*/
|
|
306
|
+
key: string;
|
|
307
|
+
/**
|
|
308
|
+
* Concrete lock key passed to `LocksPort`.
|
|
309
|
+
*/
|
|
310
|
+
lockKey: string;
|
|
311
|
+
/**
|
|
312
|
+
* Acquisition failure reason returned by `LocksPort`.
|
|
313
|
+
*/
|
|
314
|
+
reason: "unavailable" | "timeout";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Behavior when an execution lease cannot be acquired.
|
|
319
|
+
*
|
|
320
|
+
* `"skip"` treats the attempt as successful without running the handler.
|
|
321
|
+
* `"throw"` fails the attempt with `JobExecutionLeaseUnavailableError` so the
|
|
322
|
+
* job retry policy can classify it. A function can log or throw custom errors;
|
|
323
|
+
* returning from it skips the handler.
|
|
324
|
+
*/
|
|
325
|
+
export type JobExecutionLeaseUnavailableBehavior<J extends JobDef, Ctx> =
|
|
326
|
+
| "skip"
|
|
327
|
+
| "throw"
|
|
328
|
+
| {
|
|
329
|
+
bivarianceHack(
|
|
330
|
+
args: JobExecutionLeaseUnavailableArgs<J, Ctx>,
|
|
331
|
+
): MaybePromise<void>;
|
|
332
|
+
}["bivarianceHack"];
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Options for `createJobExecutionLeaseHook(...)`.
|
|
336
|
+
*/
|
|
337
|
+
export interface JobExecutionLeaseHookOptions<
|
|
338
|
+
J extends JobDef = JobDef,
|
|
339
|
+
Ctx = unknown,
|
|
340
|
+
> {
|
|
341
|
+
/**
|
|
342
|
+
* Locks port or resolver used to acquire the execution lease.
|
|
343
|
+
*/
|
|
344
|
+
locks: JobExecutionLeaseLocksResolver<J, Ctx>;
|
|
345
|
+
/**
|
|
346
|
+
* Logical lease key within this job name.
|
|
347
|
+
*/
|
|
348
|
+
key: string | JobExecutionLeaseKeyResolver<J, Ctx>;
|
|
349
|
+
/**
|
|
350
|
+
* Lease time-to-live. This is the real safety boundary in serverless runtimes
|
|
351
|
+
* where an invocation may terminate before `finally` runs.
|
|
352
|
+
*/
|
|
353
|
+
ttl: JobExecutionLeaseDuration;
|
|
354
|
+
/**
|
|
355
|
+
* How long to wait for an existing lease before applying `onUnavailable`.
|
|
356
|
+
*
|
|
357
|
+
* Defaults to no wait.
|
|
358
|
+
*/
|
|
359
|
+
wait?: JobExecutionLeaseDuration;
|
|
360
|
+
/**
|
|
361
|
+
* Delay between acquisition attempts while waiting.
|
|
362
|
+
*/
|
|
363
|
+
retryDelay?: JobExecutionLeaseDuration;
|
|
364
|
+
/**
|
|
365
|
+
* Prefix used to build the concrete lock key.
|
|
366
|
+
*
|
|
367
|
+
* Defaults to `"jobs:lease"`.
|
|
368
|
+
*/
|
|
369
|
+
keyPrefix?: string;
|
|
370
|
+
/**
|
|
371
|
+
* Optional diagnostics metadata attached to the lease acquire call.
|
|
372
|
+
*/
|
|
373
|
+
metadata?: JobExecutionLeaseMetadataResolver<J, Ctx>;
|
|
374
|
+
/**
|
|
375
|
+
* Behavior when the lease cannot be acquired.
|
|
376
|
+
*
|
|
377
|
+
* Defaults to `"skip"` so overlapping executions do not create retry storms.
|
|
378
|
+
*/
|
|
379
|
+
onUnavailable?: JobExecutionLeaseUnavailableBehavior<J, Ctx>;
|
|
119
380
|
}
|
|
120
381
|
|
|
121
382
|
/**
|
|
@@ -177,6 +438,21 @@ export interface DefineJobOptions<
|
|
|
177
438
|
* Retry metadata for durable job providers.
|
|
178
439
|
*/
|
|
179
440
|
retry?: JobRetryOptions;
|
|
441
|
+
/**
|
|
442
|
+
* Optional dispatch-time uniqueness guard.
|
|
443
|
+
*/
|
|
444
|
+
unique?: JobUniqueConfig<Payload>;
|
|
445
|
+
/**
|
|
446
|
+
* Optional maximum execution time for each job handler attempt.
|
|
447
|
+
*/
|
|
448
|
+
timeout?: JobTimeoutDuration;
|
|
449
|
+
/**
|
|
450
|
+
* Optional hooks that wrap each handler attempt for this job.
|
|
451
|
+
*/
|
|
452
|
+
hooks?: readonly JobHook<
|
|
453
|
+
JobDef<NoInferJob<Name>, NoInferJob<Payload>, Ctx>,
|
|
454
|
+
Ctx
|
|
455
|
+
>[];
|
|
180
456
|
/**
|
|
181
457
|
* Handle a parsed job payload.
|
|
182
458
|
*/
|
|
@@ -284,10 +560,126 @@ export interface InlineJobDispatcherOptions<Ctx> {
|
|
|
284
560
|
*/
|
|
285
561
|
ctx?: Ctx | (() => MaybePromise<Ctx>);
|
|
286
562
|
/**
|
|
287
|
-
* Called when a dispatched inline job fails
|
|
288
|
-
* rethrown to the caller.
|
|
563
|
+
* Called when a dispatched inline job fails all attempts allowed by its
|
|
564
|
+
* retry policy. When omitted, the final error is rethrown to the caller.
|
|
289
565
|
*/
|
|
290
566
|
onError?: (error: unknown, job: JobDef<string, StandardSchema, Ctx>) => void;
|
|
567
|
+
/**
|
|
568
|
+
* Sleep implementation used between retry attempts. Defaults to a real
|
|
569
|
+
* `setTimeout` delay; inject a fake in tests to keep retries instant.
|
|
570
|
+
*/
|
|
571
|
+
sleep?: (ms: number) => Promise<void>;
|
|
572
|
+
/**
|
|
573
|
+
* Honor the job's declared retry policy inline. Defaults to `true`. Set
|
|
574
|
+
* `false` when another layer owns execution retries for every dispatch
|
|
575
|
+
* through this dispatcher.
|
|
576
|
+
*/
|
|
577
|
+
retry?: boolean;
|
|
578
|
+
/**
|
|
579
|
+
* Hooks that wrap every job attempt executed by this dispatcher. Runner hooks
|
|
580
|
+
* wrap job-local hooks.
|
|
581
|
+
*/
|
|
582
|
+
hooks?: readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Well-known symbol under which the inline dispatcher exposes a
|
|
587
|
+
* single-attempt dispatch. Delivery systems that own execution retries
|
|
588
|
+
* themselves — the outbox drain — call it instead of `dispatch(...)` so a
|
|
589
|
+
* job's retry policy runs in exactly one layer. Registered with `Symbol.for`
|
|
590
|
+
* so multiple core copies in one process agree on the key.
|
|
591
|
+
*/
|
|
592
|
+
export const SINGLE_ATTEMPT_DISPATCH: unique symbol = Symbol.for(
|
|
593
|
+
"beignet.jobs.singleAttemptDispatch",
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Metadata passed to a single-attempt dispatch when another delivery layer
|
|
598
|
+
* owns retry scheduling.
|
|
599
|
+
*/
|
|
600
|
+
export interface SingleAttemptJobDispatchOptions {
|
|
601
|
+
/**
|
|
602
|
+
* One-based delivery attempt.
|
|
603
|
+
*/
|
|
604
|
+
attempt?: number;
|
|
605
|
+
/**
|
|
606
|
+
* Maximum delivery attempts.
|
|
607
|
+
*/
|
|
608
|
+
maxAttempts?: number;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Shape of the single-attempt dispatch exposed under
|
|
613
|
+
* `SINGLE_ATTEMPT_DISPATCH`.
|
|
614
|
+
*/
|
|
615
|
+
export type SingleAttemptJobDispatch = <J extends JobDef>(
|
|
616
|
+
job: J,
|
|
617
|
+
payload: InferJobPayload<J>,
|
|
618
|
+
options?: SingleAttemptJobDispatchOptions,
|
|
619
|
+
) => Promise<void>;
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Job dispatcher shape accepted by job wrappers.
|
|
623
|
+
*/
|
|
624
|
+
export interface JobDispatcher {
|
|
625
|
+
dispatch<J extends JobDef>(
|
|
626
|
+
job: J,
|
|
627
|
+
payload: InferJobPayload<J>,
|
|
628
|
+
): MaybePromise<void>;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Arguments passed when a unique job dispatch is suppressed.
|
|
633
|
+
*/
|
|
634
|
+
export interface UniqueJobDuplicateArgs<J extends JobDef = JobDef> {
|
|
635
|
+
/**
|
|
636
|
+
* Job definition whose dispatch was suppressed.
|
|
637
|
+
*/
|
|
638
|
+
job: J;
|
|
639
|
+
/**
|
|
640
|
+
* Parsed job payload.
|
|
641
|
+
*/
|
|
642
|
+
payload: InferJobPayload<J>;
|
|
643
|
+
/**
|
|
644
|
+
* Logical uniqueness key returned by the job declaration.
|
|
645
|
+
*/
|
|
646
|
+
key: string;
|
|
647
|
+
/**
|
|
648
|
+
* Concrete lock key used by `LocksPort`.
|
|
649
|
+
*/
|
|
650
|
+
lockKey: string;
|
|
651
|
+
/**
|
|
652
|
+
* Lease TTL in milliseconds.
|
|
653
|
+
*/
|
|
654
|
+
ttlMs: number;
|
|
655
|
+
/**
|
|
656
|
+
* Lock acquisition reason returned by `LocksPort`.
|
|
657
|
+
*/
|
|
658
|
+
reason: "unavailable" | "timeout";
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Options for `createUniqueJobDispatcher(...)`.
|
|
663
|
+
*/
|
|
664
|
+
export interface UniqueJobDispatcherOptions {
|
|
665
|
+
/**
|
|
666
|
+
* Dispatcher to call when a job is not unique or the unique lease is acquired.
|
|
667
|
+
*/
|
|
668
|
+
jobs: JobDispatcher;
|
|
669
|
+
/**
|
|
670
|
+
* Lease-backed lock port used as the uniqueness guard.
|
|
671
|
+
*/
|
|
672
|
+
locks: LocksPort;
|
|
673
|
+
/**
|
|
674
|
+
* Prefix for concrete lock keys.
|
|
675
|
+
*
|
|
676
|
+
* Defaults to `"jobs:unique"`.
|
|
677
|
+
*/
|
|
678
|
+
keyPrefix?: string;
|
|
679
|
+
/**
|
|
680
|
+
* Called when a duplicate dispatch is suppressed.
|
|
681
|
+
*/
|
|
682
|
+
onDuplicate?: (args: UniqueJobDuplicateArgs) => MaybePromise<void>;
|
|
291
683
|
}
|
|
292
684
|
|
|
293
685
|
/**
|
|
@@ -295,7 +687,8 @@ export interface InlineJobDispatcherOptions<Ctx> {
|
|
|
295
687
|
*/
|
|
296
688
|
export interface InlineJobDispatcher<Ctx = unknown> {
|
|
297
689
|
/**
|
|
298
|
-
* Validate a payload and run the job handler
|
|
690
|
+
* Validate a payload and run the job handler inline, honoring the job's
|
|
691
|
+
* declared retry policy before failing.
|
|
299
692
|
*/
|
|
300
693
|
dispatch<J extends JobDef<string, StandardSchema, Ctx>>(
|
|
301
694
|
job: J,
|
|
@@ -337,6 +730,66 @@ export class JobValidationError extends Error {
|
|
|
337
730
|
}
|
|
338
731
|
}
|
|
339
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Error thrown when a job handler exceeds its declared timeout.
|
|
735
|
+
*/
|
|
736
|
+
export class JobTimeoutError extends Error {
|
|
737
|
+
/**
|
|
738
|
+
* Stable job name that timed out.
|
|
739
|
+
*/
|
|
740
|
+
readonly jobName: string;
|
|
741
|
+
/**
|
|
742
|
+
* Timeout in milliseconds.
|
|
743
|
+
*/
|
|
744
|
+
readonly timeoutMs: number;
|
|
745
|
+
|
|
746
|
+
constructor(args: { jobName: string; timeoutMs: number }) {
|
|
747
|
+
super(`Job "${args.jobName}" timed out after ${args.timeoutMs}ms.`);
|
|
748
|
+
this.name = "JobTimeoutError";
|
|
749
|
+
this.jobName = args.jobName;
|
|
750
|
+
this.timeoutMs = args.timeoutMs;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Error thrown when an execution lease hook is configured to fail on an
|
|
756
|
+
* unavailable lease.
|
|
757
|
+
*/
|
|
758
|
+
export class JobExecutionLeaseUnavailableError extends Error {
|
|
759
|
+
/**
|
|
760
|
+
* Stable job name whose execution lease was unavailable.
|
|
761
|
+
*/
|
|
762
|
+
readonly jobName: string;
|
|
763
|
+
/**
|
|
764
|
+
* Logical lease key returned by the hook configuration.
|
|
765
|
+
*/
|
|
766
|
+
readonly key: string;
|
|
767
|
+
/**
|
|
768
|
+
* Concrete lock key passed to `LocksPort`.
|
|
769
|
+
*/
|
|
770
|
+
readonly lockKey: string;
|
|
771
|
+
/**
|
|
772
|
+
* Acquisition failure reason returned by `LocksPort`.
|
|
773
|
+
*/
|
|
774
|
+
readonly reason: "unavailable" | "timeout";
|
|
775
|
+
|
|
776
|
+
constructor(args: {
|
|
777
|
+
jobName: string;
|
|
778
|
+
key: string;
|
|
779
|
+
lockKey: string;
|
|
780
|
+
reason: "unavailable" | "timeout";
|
|
781
|
+
}) {
|
|
782
|
+
super(
|
|
783
|
+
`Job "${args.jobName}" execution lease "${args.key}" is unavailable (${args.reason}).`,
|
|
784
|
+
);
|
|
785
|
+
this.name = "JobExecutionLeaseUnavailableError";
|
|
786
|
+
this.jobName = args.jobName;
|
|
787
|
+
this.key = args.key;
|
|
788
|
+
this.lockKey = args.lockKey;
|
|
789
|
+
this.reason = args.reason;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
340
793
|
function formatPath(path: StandardSchemaV1.Issue["path"]): string {
|
|
341
794
|
if (!path?.length) return "";
|
|
342
795
|
|
|
@@ -370,12 +823,24 @@ function assertPositiveNumber(name: string, value: number): void {
|
|
|
370
823
|
}
|
|
371
824
|
}
|
|
372
825
|
|
|
373
|
-
function
|
|
826
|
+
function assertNonEmptyString(name: string, value: string): void {
|
|
827
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
828
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function durationToMs(name: string, value: JobDuration | unknown): number {
|
|
374
833
|
if (typeof value === "number") {
|
|
375
834
|
assertPositiveInteger(name, value);
|
|
376
835
|
return value;
|
|
377
836
|
}
|
|
378
837
|
|
|
838
|
+
if (typeof value !== "string") {
|
|
839
|
+
throw new Error(
|
|
840
|
+
`${name} must be a positive millisecond value or duration string like "500ms", "30s", "5m", or "1h".`,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
379
844
|
const match = /^(\d+)(ms|s|m|h)$/.exec(value);
|
|
380
845
|
if (!match) {
|
|
381
846
|
throw new Error(
|
|
@@ -400,7 +865,50 @@ function durationToMs(name: string, value: JobRetryDuration): number {
|
|
|
400
865
|
}
|
|
401
866
|
}
|
|
402
867
|
|
|
868
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
869
|
+
return typeof value === "object" && value !== null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function validateJobUniqueOptions(options: JobUniqueOptions): JobUniqueOptions {
|
|
873
|
+
if (!isRecord(options)) {
|
|
874
|
+
throw new Error("unique must be an object or resolver function");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
assertNonEmptyString("unique.key", options.key);
|
|
878
|
+
durationToMs("unique.ttl", options.ttl);
|
|
879
|
+
return options;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function validateJobUniqueConfig<Payload extends StandardSchema>(
|
|
883
|
+
config: JobUniqueConfig<Payload> | undefined,
|
|
884
|
+
): JobUniqueConfig<Payload> | undefined {
|
|
885
|
+
if (config === undefined) return undefined;
|
|
886
|
+
if (typeof config === "function") return config;
|
|
887
|
+
return validateJobUniqueOptions(config);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function validateJobTimeout(
|
|
891
|
+
timeout: JobTimeoutDuration | undefined,
|
|
892
|
+
): JobTimeoutDuration | undefined {
|
|
893
|
+
if (timeout === undefined) return undefined;
|
|
894
|
+
durationToMs("timeout", timeout);
|
|
895
|
+
return timeout;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const VALIDATED_RETRY_OPTIONS = Symbol("beignet.jobs.validatedRetryOptions");
|
|
899
|
+
|
|
403
900
|
function validateJobRetryOptions(options: JobRetryOptions): JobRetryOptions {
|
|
901
|
+
// Options returned by this function are branded so repeated validation —
|
|
902
|
+
// per attempt in the retry helpers — short-circuits instead of re-parsing
|
|
903
|
+
// duration strings and reallocating.
|
|
904
|
+
if (
|
|
905
|
+
(options as { [VALIDATED_RETRY_OPTIONS]?: boolean })[
|
|
906
|
+
VALIDATED_RETRY_OPTIONS
|
|
907
|
+
]
|
|
908
|
+
) {
|
|
909
|
+
return options;
|
|
910
|
+
}
|
|
911
|
+
|
|
404
912
|
const strategy = options.strategy ?? "exponential";
|
|
405
913
|
|
|
406
914
|
if (!["none", "fixed", "exponential"].includes(strategy)) {
|
|
@@ -436,11 +944,16 @@ function validateJobRetryOptions(options: JobRetryOptions): JobRetryOptions {
|
|
|
436
944
|
}
|
|
437
945
|
}
|
|
438
946
|
|
|
439
|
-
|
|
947
|
+
const validated = {
|
|
440
948
|
...options,
|
|
441
949
|
strategy,
|
|
442
950
|
attempts,
|
|
443
951
|
};
|
|
952
|
+
Object.defineProperty(validated, VALIDATED_RETRY_OPTIONS, {
|
|
953
|
+
value: true,
|
|
954
|
+
enumerable: false,
|
|
955
|
+
});
|
|
956
|
+
return validated;
|
|
444
957
|
}
|
|
445
958
|
|
|
446
959
|
/**
|
|
@@ -517,6 +1030,162 @@ export function getJobRetryDelayMs(
|
|
|
517
1030
|
return delayMs;
|
|
518
1031
|
}
|
|
519
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Return the execution timeout in milliseconds configured by a job.
|
|
1035
|
+
*/
|
|
1036
|
+
export function getJobTimeoutMs(
|
|
1037
|
+
job: Pick<JobDef, "timeout">,
|
|
1038
|
+
): number | undefined {
|
|
1039
|
+
return job.timeout === undefined
|
|
1040
|
+
? undefined
|
|
1041
|
+
: durationToMs("timeout", job.timeout);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function jobExecutionLeaseLockKey(
|
|
1045
|
+
jobName: string,
|
|
1046
|
+
key: string,
|
|
1047
|
+
keyPrefix = "jobs:lease",
|
|
1048
|
+
): string {
|
|
1049
|
+
return `${keyPrefix}:${jobName}:${key}`;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function validateExecutionLeaseKey(key: string): string {
|
|
1053
|
+
assertNonEmptyString("executionLease.key", key);
|
|
1054
|
+
return key;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
1058
|
+
return signal.reason ?? new Error("Job execution aborted.");
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function resolveExecutionLeaseLocks<J extends JobDef, Ctx>(
|
|
1062
|
+
locks: JobExecutionLeaseLocksResolver<J, Ctx>,
|
|
1063
|
+
args: JobExecutionLeaseResolverArgs<J, Ctx>,
|
|
1064
|
+
): Promise<LocksPort> {
|
|
1065
|
+
return typeof locks === "function" ? await locks(args) : locks;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
async function resolveExecutionLeaseMetadata<J extends JobDef, Ctx>(
|
|
1069
|
+
metadata: JobExecutionLeaseMetadataResolver<J, Ctx> | undefined,
|
|
1070
|
+
args: JobExecutionLeaseResolverArgs<J, Ctx>,
|
|
1071
|
+
): Promise<LeaseMetadata | undefined> {
|
|
1072
|
+
return typeof metadata === "function" ? await metadata(args) : metadata;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function releaseExecutionLease(lease: LeaseHandle): Promise<void> {
|
|
1076
|
+
try {
|
|
1077
|
+
await lease.release();
|
|
1078
|
+
} catch {
|
|
1079
|
+
// The lease TTL is the correctness boundary. Release is best effort so a
|
|
1080
|
+
// provider outage after handler side effects does not turn success into a
|
|
1081
|
+
// retry.
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async function handleUnavailableExecutionLease<J extends JobDef, Ctx>(
|
|
1086
|
+
behavior: JobExecutionLeaseUnavailableBehavior<J, Ctx> | undefined,
|
|
1087
|
+
args: JobExecutionLeaseUnavailableArgs<J, Ctx>,
|
|
1088
|
+
): Promise<void> {
|
|
1089
|
+
if (behavior === undefined || behavior === "skip") return;
|
|
1090
|
+
|
|
1091
|
+
if (behavior === "throw") {
|
|
1092
|
+
throw new JobExecutionLeaseUnavailableError({
|
|
1093
|
+
jobName: args.job.name,
|
|
1094
|
+
key: args.key,
|
|
1095
|
+
lockKey: args.lockKey,
|
|
1096
|
+
reason: args.reason,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
await behavior(args);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Create a job hook that prevents overlapping handler attempts for the same
|
|
1105
|
+
* logical execution key.
|
|
1106
|
+
*
|
|
1107
|
+
* The hook uses one bounded `LocksPort.acquire(...)` call and never starts
|
|
1108
|
+
* renewal loops, so it can run in serverless entrypoints as long as `locks`
|
|
1109
|
+
* points at shared storage. `ttl` is the real safety boundary when a runtime
|
|
1110
|
+
* terminates before best-effort release runs.
|
|
1111
|
+
*/
|
|
1112
|
+
export function createJobExecutionLeaseHook<
|
|
1113
|
+
J extends JobDef = JobDef,
|
|
1114
|
+
Ctx = unknown,
|
|
1115
|
+
>(options: JobExecutionLeaseHookOptions<J, Ctx>): JobHook<J, Ctx> {
|
|
1116
|
+
const ttlMs = durationToMs("executionLease.ttl", options.ttl);
|
|
1117
|
+
const waitMs =
|
|
1118
|
+
options.wait === undefined
|
|
1119
|
+
? undefined
|
|
1120
|
+
: durationToMs("executionLease.wait", options.wait);
|
|
1121
|
+
const retryDelayMs =
|
|
1122
|
+
options.retryDelay === undefined
|
|
1123
|
+
? undefined
|
|
1124
|
+
: durationToMs("executionLease.retryDelay", options.retryDelay);
|
|
1125
|
+
const keyPrefix = options.keyPrefix ?? "jobs:lease";
|
|
1126
|
+
assertNonEmptyString("executionLease.keyPrefix", keyPrefix);
|
|
1127
|
+
|
|
1128
|
+
if (typeof options.key === "string") {
|
|
1129
|
+
validateExecutionLeaseKey(options.key);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
return async (args, next) => {
|
|
1133
|
+
if (args.signal.aborted) throw abortReason(args.signal);
|
|
1134
|
+
|
|
1135
|
+
const key = validateExecutionLeaseKey(
|
|
1136
|
+
typeof options.key === "function" ? await options.key(args) : options.key,
|
|
1137
|
+
);
|
|
1138
|
+
if (args.signal.aborted) throw abortReason(args.signal);
|
|
1139
|
+
|
|
1140
|
+
const lockKey = jobExecutionLeaseLockKey(args.job.name, key, keyPrefix);
|
|
1141
|
+
const locks = await resolveExecutionLeaseLocks(options.locks, args);
|
|
1142
|
+
if (args.signal.aborted) throw abortReason(args.signal);
|
|
1143
|
+
|
|
1144
|
+
const metadata = await resolveExecutionLeaseMetadata(
|
|
1145
|
+
options.metadata,
|
|
1146
|
+
args,
|
|
1147
|
+
);
|
|
1148
|
+
if (args.signal.aborted) throw abortReason(args.signal);
|
|
1149
|
+
|
|
1150
|
+
const result = await locks.acquire(lockKey, {
|
|
1151
|
+
ttlMs,
|
|
1152
|
+
...(waitMs === undefined ? {} : { waitMs }),
|
|
1153
|
+
...(retryDelayMs === undefined ? {} : { retryDelayMs }),
|
|
1154
|
+
metadata: {
|
|
1155
|
+
...(metadata ?? {}),
|
|
1156
|
+
capability: "jobs",
|
|
1157
|
+
jobName: args.job.name,
|
|
1158
|
+
leaseKey: key,
|
|
1159
|
+
attempt: args.attempt ?? null,
|
|
1160
|
+
maxAttempts: args.maxAttempts ?? null,
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
if (!result.acquired) {
|
|
1165
|
+
if (args.signal.aborted) throw abortReason(args.signal);
|
|
1166
|
+
|
|
1167
|
+
await handleUnavailableExecutionLease(options.onUnavailable, {
|
|
1168
|
+
...args,
|
|
1169
|
+
key,
|
|
1170
|
+
lockKey,
|
|
1171
|
+
reason: result.reason,
|
|
1172
|
+
});
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (args.signal.aborted) {
|
|
1177
|
+
await releaseExecutionLease(result.lease);
|
|
1178
|
+
throw abortReason(args.signal);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
try {
|
|
1182
|
+
await next();
|
|
1183
|
+
} finally {
|
|
1184
|
+
await releaseExecutionLease(result.lease);
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
520
1189
|
async function parsePayload<Schema extends StandardSchemaV1>(
|
|
521
1190
|
schema: Schema,
|
|
522
1191
|
input: unknown,
|
|
@@ -559,6 +1228,8 @@ function defineJobImpl<
|
|
|
559
1228
|
const retryOptions = options.retry
|
|
560
1229
|
? validateJobRetryOptions(options.retry)
|
|
561
1230
|
: undefined;
|
|
1231
|
+
const uniqueOptions = validateJobUniqueConfig(options.unique);
|
|
1232
|
+
const timeout = validateJobTimeout(options.timeout);
|
|
562
1233
|
|
|
563
1234
|
return {
|
|
564
1235
|
kind: "job",
|
|
@@ -566,6 +1237,9 @@ function defineJobImpl<
|
|
|
566
1237
|
payload: options.payload,
|
|
567
1238
|
description: options.description,
|
|
568
1239
|
retry: retryOptions,
|
|
1240
|
+
unique: uniqueOptions,
|
|
1241
|
+
timeout,
|
|
1242
|
+
hooks: options.hooks,
|
|
569
1243
|
handle: options.handle as JobDef<Name, Payload, Ctx>["handle"],
|
|
570
1244
|
};
|
|
571
1245
|
}
|
|
@@ -582,30 +1256,359 @@ export async function parseJobPayload<J extends JobDef>(
|
|
|
582
1256
|
})) as InferJobPayload<J>;
|
|
583
1257
|
}
|
|
584
1258
|
|
|
1259
|
+
/**
|
|
1260
|
+
* Options for running one parsed job handler attempt.
|
|
1261
|
+
*/
|
|
1262
|
+
export interface RunJobHandlerOptions<
|
|
1263
|
+
J extends JobDef<string, StandardSchema, Ctx>,
|
|
1264
|
+
Ctx,
|
|
1265
|
+
> {
|
|
1266
|
+
/**
|
|
1267
|
+
* Job definition to execute.
|
|
1268
|
+
*/
|
|
1269
|
+
job: J;
|
|
1270
|
+
/**
|
|
1271
|
+
* Parsed job payload.
|
|
1272
|
+
*/
|
|
1273
|
+
payload: InferJobPayload<J>;
|
|
1274
|
+
/** Handler context or factory resolved inside the job span. */
|
|
1275
|
+
ctx: Ctx | (() => MaybePromise<Ctx>);
|
|
1276
|
+
/** Runtime tracing port used before a lazy context factory runs. */
|
|
1277
|
+
tracing?: TracingPort;
|
|
1278
|
+
/**
|
|
1279
|
+
* Runner-level hooks. These wrap job-local hooks.
|
|
1280
|
+
*/
|
|
1281
|
+
hooks?: readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[];
|
|
1282
|
+
/**
|
|
1283
|
+
* One-based execution attempt when the runner can report it.
|
|
1284
|
+
*/
|
|
1285
|
+
attempt?: number;
|
|
1286
|
+
/**
|
|
1287
|
+
* Maximum execution attempts when the runner can report it.
|
|
1288
|
+
*/
|
|
1289
|
+
maxAttempts?: number;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function normalizeJobHooks<Ctx>(
|
|
1293
|
+
job: JobDef<string, StandardSchema, Ctx>,
|
|
1294
|
+
hooks: readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[] = [],
|
|
1295
|
+
): readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[] {
|
|
1296
|
+
return [
|
|
1297
|
+
...hooks,
|
|
1298
|
+
...((job.hooks ?? []) as readonly JobHook<
|
|
1299
|
+
JobDef<string, StandardSchema, Ctx>,
|
|
1300
|
+
Ctx
|
|
1301
|
+
>[]),
|
|
1302
|
+
];
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Run a parsed job handler once, enforcing hooks and the job's declared
|
|
1307
|
+
* timeout.
|
|
1308
|
+
*/
|
|
1309
|
+
export async function runJobHandler<
|
|
1310
|
+
J extends JobDef<string, StandardSchema, Ctx>,
|
|
1311
|
+
Ctx,
|
|
1312
|
+
>(args: RunJobHandlerOptions<J, Ctx>): Promise<void> {
|
|
1313
|
+
const traceAttributes = {
|
|
1314
|
+
"beignet.job.name": args.job.name,
|
|
1315
|
+
...(args.attempt === undefined
|
|
1316
|
+
? {}
|
|
1317
|
+
: { "beignet.job.attempt": args.attempt }),
|
|
1318
|
+
...(args.maxAttempts === undefined
|
|
1319
|
+
? {}
|
|
1320
|
+
: { "beignet.job.max_attempts": args.maxAttempts }),
|
|
1321
|
+
} as const;
|
|
1322
|
+
|
|
1323
|
+
await runWithResolvedTracingContext({
|
|
1324
|
+
tracing: args.tracing,
|
|
1325
|
+
ctx: args.ctx,
|
|
1326
|
+
operation: {
|
|
1327
|
+
name: `beignet.job ${args.job.name}`,
|
|
1328
|
+
type: "job",
|
|
1329
|
+
kind: "consumer",
|
|
1330
|
+
attributes: traceAttributes,
|
|
1331
|
+
metricAttributes: traceAttributes,
|
|
1332
|
+
},
|
|
1333
|
+
run: async (ctx) => {
|
|
1334
|
+
const timeoutMs = getJobTimeoutMs(args.job);
|
|
1335
|
+
const controller = new AbortController();
|
|
1336
|
+
const hooks = normalizeJobHooks(args.job, args.hooks);
|
|
1337
|
+
const hookArgs = {
|
|
1338
|
+
job: args.job,
|
|
1339
|
+
payload: args.payload,
|
|
1340
|
+
ctx,
|
|
1341
|
+
signal: controller.signal,
|
|
1342
|
+
attempt: args.attempt,
|
|
1343
|
+
maxAttempts: args.maxAttempts,
|
|
1344
|
+
} satisfies JobHookArgs<J, Ctx>;
|
|
1345
|
+
|
|
1346
|
+
const run = Promise.resolve().then(async () => {
|
|
1347
|
+
let index = -1;
|
|
1348
|
+
const dispatch = async (nextIndex: number): Promise<void> => {
|
|
1349
|
+
if (nextIndex <= index) {
|
|
1350
|
+
throw new Error(
|
|
1351
|
+
`Job "${args.job.name}" hook called next() multiple times.`,
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
index = nextIndex;
|
|
1355
|
+
|
|
1356
|
+
const hook = hooks[nextIndex];
|
|
1357
|
+
if (!hook) {
|
|
1358
|
+
await args.job.handle({
|
|
1359
|
+
job: args.job,
|
|
1360
|
+
payload: args.payload,
|
|
1361
|
+
ctx,
|
|
1362
|
+
signal: controller.signal,
|
|
1363
|
+
});
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
await hook(hookArgs, () => dispatch(nextIndex + 1));
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
await dispatch(0);
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
if (timeoutMs === undefined) {
|
|
1374
|
+
await run;
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const timeoutError = new JobTimeoutError({
|
|
1379
|
+
jobName: args.job.name,
|
|
1380
|
+
timeoutMs,
|
|
1381
|
+
});
|
|
1382
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1383
|
+
|
|
1384
|
+
try {
|
|
1385
|
+
await Promise.race([
|
|
1386
|
+
run,
|
|
1387
|
+
new Promise<void>((_, reject) => {
|
|
1388
|
+
timeout = setTimeout(() => {
|
|
1389
|
+
controller.abort(timeoutError);
|
|
1390
|
+
reject(timeoutError);
|
|
1391
|
+
}, timeoutMs);
|
|
1392
|
+
}),
|
|
1393
|
+
]);
|
|
1394
|
+
} finally {
|
|
1395
|
+
if (timeout !== undefined) {
|
|
1396
|
+
clearTimeout(timeout);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function jobUniqueLockKey(
|
|
1404
|
+
jobName: string,
|
|
1405
|
+
key: string,
|
|
1406
|
+
keyPrefix = "jobs:unique",
|
|
1407
|
+
): string {
|
|
1408
|
+
return `${keyPrefix}:${jobName}:${key}`;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Resolve a job's dispatch-time uniqueness metadata for a parsed payload.
|
|
1413
|
+
*/
|
|
1414
|
+
export async function resolveJobUnique<J extends JobDef>(
|
|
1415
|
+
job: J,
|
|
1416
|
+
payload: InferJobPayload<J>,
|
|
1417
|
+
options: { keyPrefix?: string } = {},
|
|
1418
|
+
): Promise<ResolvedJobUniqueOptions | undefined> {
|
|
1419
|
+
const config = job.unique;
|
|
1420
|
+
if (!config) return undefined;
|
|
1421
|
+
|
|
1422
|
+
const unique =
|
|
1423
|
+
typeof config === "function"
|
|
1424
|
+
? await config({ jobName: job.name, payload })
|
|
1425
|
+
: config;
|
|
1426
|
+
if (unique == null) return undefined;
|
|
1427
|
+
|
|
1428
|
+
const validated = validateJobUniqueOptions(unique);
|
|
1429
|
+
return {
|
|
1430
|
+
key: validated.key,
|
|
1431
|
+
lockKey: jobUniqueLockKey(job.name, validated.key, options.keyPrefix),
|
|
1432
|
+
ttlMs: durationToMs("unique.ttl", validated.ttl),
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
|
|
585
1436
|
/**
|
|
586
1437
|
* Create a local/test dispatcher that runs job handlers inline.
|
|
1438
|
+
*
|
|
1439
|
+
* Dispatch honors the job's declared retry policy: failed attempts retry with
|
|
1440
|
+
* the policy's delays until the policy is exhausted. Payload validation
|
|
1441
|
+
* failures never retry. Jobs without a retry policy run exactly once.
|
|
587
1442
|
*/
|
|
588
1443
|
export function createInlineJobDispatcher<Ctx>(
|
|
589
1444
|
options: InlineJobDispatcherOptions<Ctx> = {},
|
|
590
1445
|
): InlineJobDispatcher<Ctx> {
|
|
591
|
-
|
|
1446
|
+
const sleep =
|
|
1447
|
+
options.sleep ??
|
|
1448
|
+
((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
1449
|
+
|
|
1450
|
+
async function run<J extends JobDef<string, StandardSchema, Ctx>>(
|
|
1451
|
+
job: J,
|
|
1452
|
+
payload: InferJobPayload<J>,
|
|
1453
|
+
retryEnabled: boolean,
|
|
1454
|
+
dispatchOptions: SingleAttemptJobDispatchOptions = {},
|
|
1455
|
+
): Promise<void> {
|
|
1456
|
+
const fail = (error: unknown): void => {
|
|
1457
|
+
options.onError?.(error, job);
|
|
1458
|
+
if (!options.onError) throw error;
|
|
1459
|
+
};
|
|
1460
|
+
|
|
1461
|
+
let parsed: InferJobPayload<J>;
|
|
1462
|
+
let ctx: Ctx;
|
|
1463
|
+
try {
|
|
1464
|
+
parsed = await parseJobPayload(job, payload);
|
|
1465
|
+
// Resolve once per dispatch so a ctx factory does not run (and cannot
|
|
1466
|
+
// produce different contexts) across retry attempts.
|
|
1467
|
+
ctx = await resolveCtx(options.ctx);
|
|
1468
|
+
} catch (error) {
|
|
1469
|
+
fail(error);
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const policy = retryEnabled ? job.retry : undefined;
|
|
1474
|
+
const maxAttempts = policy ? (getJobRetryMaxAttempts(policy) ?? 1) : 1;
|
|
1475
|
+
|
|
1476
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
1477
|
+
try {
|
|
1478
|
+
await runJobHandler({
|
|
1479
|
+
job,
|
|
1480
|
+
payload: parsed,
|
|
1481
|
+
ctx,
|
|
1482
|
+
hooks: options.hooks,
|
|
1483
|
+
attempt: dispatchOptions.attempt ?? attempt,
|
|
1484
|
+
maxAttempts: dispatchOptions.maxAttempts ?? maxAttempts,
|
|
1485
|
+
});
|
|
1486
|
+
return;
|
|
1487
|
+
} catch (error) {
|
|
1488
|
+
const willRetry = shouldRetryJob(policy, {
|
|
1489
|
+
error,
|
|
1490
|
+
attempt,
|
|
1491
|
+
maxAttempts,
|
|
1492
|
+
jobName: job.name,
|
|
1493
|
+
});
|
|
1494
|
+
if (!willRetry) {
|
|
1495
|
+
fail(error);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const delayMs = getJobRetryDelayMs(policy, {
|
|
1500
|
+
error,
|
|
1501
|
+
attempt,
|
|
1502
|
+
jobName: job.name,
|
|
1503
|
+
});
|
|
1504
|
+
if (delayMs > 0) await sleep(delayMs);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const dispatcher: InlineJobDispatcher<Ctx> = {
|
|
592
1510
|
async dispatch<J extends JobDef<string, StandardSchema, Ctx>>(
|
|
593
1511
|
job: J,
|
|
594
1512
|
payload: InferJobPayload<J>,
|
|
595
1513
|
) {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
1514
|
+
await run(job, payload, options.retry !== false);
|
|
1515
|
+
},
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1518
|
+
// Non-enumerable so spreads and serialization keep treating the dispatcher
|
|
1519
|
+
// as a plain port; the outbox drain discovers it by symbol.
|
|
1520
|
+
Object.defineProperty(dispatcher, SINGLE_ATTEMPT_DISPATCH, {
|
|
1521
|
+
value: <J extends JobDef<string, StandardSchema, Ctx>>(
|
|
1522
|
+
job: J,
|
|
1523
|
+
payload: InferJobPayload<J>,
|
|
1524
|
+
dispatchOptions?: SingleAttemptJobDispatchOptions,
|
|
1525
|
+
) => run(job, payload, false, dispatchOptions),
|
|
1526
|
+
enumerable: false,
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
return dispatcher;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Wrap any job dispatcher with dispatch-time unique job suppression.
|
|
1534
|
+
*
|
|
1535
|
+
* When a job has no `unique` declaration, dispatch passes through unchanged.
|
|
1536
|
+
* When it does, the wrapper validates the payload, resolves the unique key,
|
|
1537
|
+
* acquires the matching lease, and calls the underlying dispatcher only when
|
|
1538
|
+
* the lease is acquired. Successful dispatches intentionally keep the lease
|
|
1539
|
+
* until its TTL expires; failed dispatches release it so callers can retry.
|
|
1540
|
+
*/
|
|
1541
|
+
export function createUniqueJobDispatcher(
|
|
1542
|
+
options: UniqueJobDispatcherOptions,
|
|
1543
|
+
): JobDispatcher {
|
|
1544
|
+
const dispatcher: JobDispatcher = {
|
|
1545
|
+
async dispatch<J extends JobDef>(
|
|
1546
|
+
job: J,
|
|
1547
|
+
payload: InferJobPayload<J>,
|
|
1548
|
+
): Promise<void> {
|
|
1549
|
+
if (!job.unique) {
|
|
1550
|
+
await options.jobs.dispatch(job, payload);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const parsed = await parseJobPayload(job, payload);
|
|
1555
|
+
const unique = await resolveJobUnique(job, parsed, {
|
|
1556
|
+
keyPrefix: options.keyPrefix,
|
|
1557
|
+
});
|
|
1558
|
+
if (!unique) {
|
|
1559
|
+
await options.jobs.dispatch(job, parsed);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const result = await options.locks.acquire(unique.lockKey, {
|
|
1564
|
+
ttlMs: unique.ttlMs,
|
|
1565
|
+
waitMs: 0,
|
|
1566
|
+
metadata: {
|
|
1567
|
+
capability: "jobs",
|
|
1568
|
+
jobName: job.name,
|
|
1569
|
+
uniqueKey: unique.key,
|
|
1570
|
+
},
|
|
1571
|
+
});
|
|
1572
|
+
if (!result.acquired) {
|
|
1573
|
+
await options.onDuplicate?.({
|
|
599
1574
|
job,
|
|
600
1575
|
payload: parsed,
|
|
601
|
-
|
|
1576
|
+
key: unique.key,
|
|
1577
|
+
lockKey: unique.lockKey,
|
|
1578
|
+
ttlMs: unique.ttlMs,
|
|
1579
|
+
reason: result.reason,
|
|
602
1580
|
});
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
try {
|
|
1585
|
+
await options.jobs.dispatch(job, parsed);
|
|
603
1586
|
} catch (error) {
|
|
604
|
-
|
|
605
|
-
|
|
1587
|
+
try {
|
|
1588
|
+
await result.lease.release();
|
|
1589
|
+
} catch {
|
|
1590
|
+
// Preserve the dispatch failure; lease release is best effort and
|
|
1591
|
+
// the TTL still bounds duplicate suppression if release fails.
|
|
1592
|
+
}
|
|
1593
|
+
throw error;
|
|
606
1594
|
}
|
|
607
1595
|
},
|
|
608
1596
|
};
|
|
1597
|
+
|
|
1598
|
+
const singleAttempt = (
|
|
1599
|
+
options.jobs as {
|
|
1600
|
+
[SINGLE_ATTEMPT_DISPATCH]?: SingleAttemptJobDispatch;
|
|
1601
|
+
}
|
|
1602
|
+
)[SINGLE_ATTEMPT_DISPATCH];
|
|
1603
|
+
|
|
1604
|
+
if (singleAttempt) {
|
|
1605
|
+
Object.defineProperty(dispatcher, SINGLE_ATTEMPT_DISPATCH, {
|
|
1606
|
+
value: singleAttempt,
|
|
1607
|
+
enumerable: false,
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
return dispatcher;
|
|
609
1612
|
}
|
|
610
1613
|
|
|
611
1614
|
/**
|
|
@@ -617,9 +1620,9 @@ export function createInlineJobDispatcher<Ctx>(
|
|
|
617
1620
|
* export const { defineJob } = createJobs<AppContext>();
|
|
618
1621
|
* ```
|
|
619
1622
|
*
|
|
620
|
-
* Retry options
|
|
621
|
-
*
|
|
622
|
-
*
|
|
1623
|
+
* Retry options describe the job's retry policy. Inline dispatchers run the
|
|
1624
|
+
* policy in-process with real delays; durable providers map the policy onto
|
|
1625
|
+
* their own runtime and reject options they cannot honor.
|
|
623
1626
|
*/
|
|
624
1627
|
export function createJobs<Ctx>(): Jobs<Ctx> {
|
|
625
1628
|
return {
|