@beignet/core 0.0.32 → 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 +35 -0
- package/README.md +344 -19
- 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/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/jobs/index.d.ts +415 -4
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +392 -3
- package/dist/jobs/index.js.map +1 -1
- package/dist/outbox/index.d.ts +150 -1
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +170 -1
- package/dist/outbox/index.js.map +1 -1
- 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/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/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/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/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 +71 -0
- package/dist/uploads/index.d.ts.map +1 -1
- package/dist/uploads/index.js +344 -15
- package/dist/uploads/index.js.map +1 -1
- package/dist/webhooks/index.d.ts +55 -4
- package/dist/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +106 -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/events/index.ts +27 -14
- package/src/jobs/index.ts +895 -7
- package/src/outbox/index.ts +358 -2
- package/src/ports/index.ts +17 -0
- package/src/providers/instrumentation.ts +4 -0
- package/src/schedules/index.ts +60 -26
- 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/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/tracing/execution.ts +37 -0
- package/src/tracing/index.ts +137 -0
- package/src/uploads/client.ts +65 -6
- package/src/uploads/index.ts +539 -14
- package/src/webhooks/index.ts +238 -9
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
|
*/
|
|
@@ -299,6 +575,11 @@ export interface InlineJobDispatcherOptions<Ctx> {
|
|
|
299
575
|
* through this dispatcher.
|
|
300
576
|
*/
|
|
301
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>[];
|
|
302
583
|
}
|
|
303
584
|
|
|
304
585
|
/**
|
|
@@ -312,6 +593,21 @@ export const SINGLE_ATTEMPT_DISPATCH: unique symbol = Symbol.for(
|
|
|
312
593
|
"beignet.jobs.singleAttemptDispatch",
|
|
313
594
|
);
|
|
314
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
|
+
|
|
315
611
|
/**
|
|
316
612
|
* Shape of the single-attempt dispatch exposed under
|
|
317
613
|
* `SINGLE_ATTEMPT_DISPATCH`.
|
|
@@ -319,8 +615,73 @@ export const SINGLE_ATTEMPT_DISPATCH: unique symbol = Symbol.for(
|
|
|
319
615
|
export type SingleAttemptJobDispatch = <J extends JobDef>(
|
|
320
616
|
job: J,
|
|
321
617
|
payload: InferJobPayload<J>,
|
|
618
|
+
options?: SingleAttemptJobDispatchOptions,
|
|
322
619
|
) => Promise<void>;
|
|
323
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>;
|
|
683
|
+
}
|
|
684
|
+
|
|
324
685
|
/**
|
|
325
686
|
* Local/test job dispatcher that executes job handlers inline.
|
|
326
687
|
*/
|
|
@@ -369,6 +730,66 @@ export class JobValidationError extends Error {
|
|
|
369
730
|
}
|
|
370
731
|
}
|
|
371
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
|
+
|
|
372
793
|
function formatPath(path: StandardSchemaV1.Issue["path"]): string {
|
|
373
794
|
if (!path?.length) return "";
|
|
374
795
|
|
|
@@ -402,12 +823,24 @@ function assertPositiveNumber(name: string, value: number): void {
|
|
|
402
823
|
}
|
|
403
824
|
}
|
|
404
825
|
|
|
405
|
-
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 {
|
|
406
833
|
if (typeof value === "number") {
|
|
407
834
|
assertPositiveInteger(name, value);
|
|
408
835
|
return value;
|
|
409
836
|
}
|
|
410
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
|
+
|
|
411
844
|
const match = /^(\d+)(ms|s|m|h)$/.exec(value);
|
|
412
845
|
if (!match) {
|
|
413
846
|
throw new Error(
|
|
@@ -432,6 +865,36 @@ function durationToMs(name: string, value: JobRetryDuration): number {
|
|
|
432
865
|
}
|
|
433
866
|
}
|
|
434
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
|
+
|
|
435
898
|
const VALIDATED_RETRY_OPTIONS = Symbol("beignet.jobs.validatedRetryOptions");
|
|
436
899
|
|
|
437
900
|
function validateJobRetryOptions(options: JobRetryOptions): JobRetryOptions {
|
|
@@ -567,6 +1030,162 @@ export function getJobRetryDelayMs(
|
|
|
567
1030
|
return delayMs;
|
|
568
1031
|
}
|
|
569
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
|
+
|
|
570
1189
|
async function parsePayload<Schema extends StandardSchemaV1>(
|
|
571
1190
|
schema: Schema,
|
|
572
1191
|
input: unknown,
|
|
@@ -609,6 +1228,8 @@ function defineJobImpl<
|
|
|
609
1228
|
const retryOptions = options.retry
|
|
610
1229
|
? validateJobRetryOptions(options.retry)
|
|
611
1230
|
: undefined;
|
|
1231
|
+
const uniqueOptions = validateJobUniqueConfig(options.unique);
|
|
1232
|
+
const timeout = validateJobTimeout(options.timeout);
|
|
612
1233
|
|
|
613
1234
|
return {
|
|
614
1235
|
kind: "job",
|
|
@@ -616,6 +1237,9 @@ function defineJobImpl<
|
|
|
616
1237
|
payload: options.payload,
|
|
617
1238
|
description: options.description,
|
|
618
1239
|
retry: retryOptions,
|
|
1240
|
+
unique: uniqueOptions,
|
|
1241
|
+
timeout,
|
|
1242
|
+
hooks: options.hooks,
|
|
619
1243
|
handle: options.handle as JobDef<Name, Payload, Ctx>["handle"],
|
|
620
1244
|
};
|
|
621
1245
|
}
|
|
@@ -632,6 +1256,183 @@ export async function parseJobPayload<J extends JobDef>(
|
|
|
632
1256
|
})) as InferJobPayload<J>;
|
|
633
1257
|
}
|
|
634
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
|
+
|
|
635
1436
|
/**
|
|
636
1437
|
* Create a local/test dispatcher that runs job handlers inline.
|
|
637
1438
|
*
|
|
@@ -650,6 +1451,7 @@ export function createInlineJobDispatcher<Ctx>(
|
|
|
650
1451
|
job: J,
|
|
651
1452
|
payload: InferJobPayload<J>,
|
|
652
1453
|
retryEnabled: boolean,
|
|
1454
|
+
dispatchOptions: SingleAttemptJobDispatchOptions = {},
|
|
653
1455
|
): Promise<void> {
|
|
654
1456
|
const fail = (error: unknown): void => {
|
|
655
1457
|
options.onError?.(error, job);
|
|
@@ -673,10 +1475,13 @@ export function createInlineJobDispatcher<Ctx>(
|
|
|
673
1475
|
|
|
674
1476
|
for (let attempt = 1; ; attempt += 1) {
|
|
675
1477
|
try {
|
|
676
|
-
await
|
|
1478
|
+
await runJobHandler({
|
|
677
1479
|
job,
|
|
678
1480
|
payload: parsed,
|
|
679
1481
|
ctx,
|
|
1482
|
+
hooks: options.hooks,
|
|
1483
|
+
attempt: dispatchOptions.attempt ?? attempt,
|
|
1484
|
+
maxAttempts: dispatchOptions.maxAttempts ?? maxAttempts,
|
|
680
1485
|
});
|
|
681
1486
|
return;
|
|
682
1487
|
} catch (error) {
|
|
@@ -716,13 +1521,96 @@ export function createInlineJobDispatcher<Ctx>(
|
|
|
716
1521
|
value: <J extends JobDef<string, StandardSchema, Ctx>>(
|
|
717
1522
|
job: J,
|
|
718
1523
|
payload: InferJobPayload<J>,
|
|
719
|
-
|
|
1524
|
+
dispatchOptions?: SingleAttemptJobDispatchOptions,
|
|
1525
|
+
) => run(job, payload, false, dispatchOptions),
|
|
720
1526
|
enumerable: false,
|
|
721
1527
|
});
|
|
722
1528
|
|
|
723
1529
|
return dispatcher;
|
|
724
1530
|
}
|
|
725
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?.({
|
|
1574
|
+
job,
|
|
1575
|
+
payload: parsed,
|
|
1576
|
+
key: unique.key,
|
|
1577
|
+
lockKey: unique.lockKey,
|
|
1578
|
+
ttlMs: unique.ttlMs,
|
|
1579
|
+
reason: result.reason,
|
|
1580
|
+
});
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
try {
|
|
1585
|
+
await options.jobs.dispatch(job, parsed);
|
|
1586
|
+
} catch (error) {
|
|
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;
|
|
1594
|
+
}
|
|
1595
|
+
},
|
|
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;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
726
1614
|
/**
|
|
727
1615
|
* Create job helper methods bound to an application context type.
|
|
728
1616
|
*
|