effect-mq 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -14
- package/dist/Job.d.ts +131 -9
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +81 -5
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +63 -2
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +159 -119
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Worker.d.ts +18 -0
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +37 -9
- package/dist/Worker.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +239 -49
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +2 -0
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +1 -0
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +173 -36
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +24 -1
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +126 -21
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +266 -0
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Job.ts +268 -13
- package/src/JobStore.ts +77 -2
- package/src/MemoryJobStore.ts +102 -53
- package/src/Worker.ts +61 -9
- package/src/drizzle-postgres/DrizzleJobStore.ts +273 -66
- package/src/drizzle-postgres/schema.ts +2 -0
- package/src/redis/RedisJobStore.ts +236 -47
- package/src/redis/scripts.ts +153 -21
- package/src/testing/conformance.ts +350 -0
package/src/Job.ts
CHANGED
|
@@ -30,11 +30,12 @@
|
|
|
30
30
|
*
|
|
31
31
|
* @since 0.1.0
|
|
32
32
|
*/
|
|
33
|
-
import { Clock, type Context, Duration, Effect, type Exit, Layer, Metric, Option, Predicate, Schedule, Schema } from "effect"
|
|
33
|
+
import { Clock, type Context, DateTime, Duration, Effect, type Exit, Layer, Metric, Option, Predicate, Schedule, Schema } from "effect"
|
|
34
34
|
import {
|
|
35
35
|
type BackoffPolicy,
|
|
36
36
|
type DedupePolicy,
|
|
37
37
|
type KeepStatePolicy,
|
|
38
|
+
type TraceContext,
|
|
38
39
|
JobCancelledError,
|
|
39
40
|
JobId,
|
|
40
41
|
type JobNotCancellableError,
|
|
@@ -130,6 +131,11 @@ export interface JobOptions {
|
|
|
130
131
|
readonly priority?: number | undefined
|
|
131
132
|
/** Total attempts including the first run. Default 1 (no retries). */
|
|
132
133
|
readonly attempts?: number | undefined
|
|
134
|
+
/**
|
|
135
|
+
* Delay between retry attempts:
|
|
136
|
+
* `{ type: "fixed" | "exponential", delay, factor? }`. Default: retries
|
|
137
|
+
* are immediate.
|
|
138
|
+
*/
|
|
133
139
|
readonly backoff?: BackoffInput | undefined
|
|
134
140
|
/** Retention for terminal records. Default: keep forever. */
|
|
135
141
|
readonly keep?: KeepInput | undefined
|
|
@@ -158,9 +164,28 @@ export interface JobOptions {
|
|
|
158
164
|
* @since 0.3.0
|
|
159
165
|
*/
|
|
160
166
|
export type DedupeInput = string | {
|
|
167
|
+
/**
|
|
168
|
+
* The dedup key, scoped to this job's name (e.g. an employee id). Never
|
|
169
|
+
* changes the job id. Must be non-empty.
|
|
170
|
+
*/
|
|
161
171
|
readonly key: string
|
|
172
|
+
/**
|
|
173
|
+
* Throttle window: at most one job per key per window, even after the
|
|
174
|
+
* keyed job completes. Without `ttl`, dedup lasts while the keyed job is
|
|
175
|
+
* pending (waiting/delayed/active).
|
|
176
|
+
*/
|
|
162
177
|
readonly ttl?: Duration.Input | undefined
|
|
178
|
+
/**
|
|
179
|
+
* Debounce (requires `ttl`): every deduplicated enqueue pushes the window
|
|
180
|
+
* out again.
|
|
181
|
+
*/
|
|
163
182
|
readonly extend?: boolean | undefined
|
|
183
|
+
/**
|
|
184
|
+
* Latest-wins while the keyed job is still delayed: the new enqueue's
|
|
185
|
+
* payload/metadata/priority/attempts/backoff/keep/timeout/delay replace
|
|
186
|
+
* the existing job's (same id, ledger preserved). A landed replace
|
|
187
|
+
* re-arms the `ttl` window. In any other state, normal dedup applies.
|
|
188
|
+
*/
|
|
164
189
|
readonly replace?: boolean | undefined
|
|
165
190
|
}
|
|
166
191
|
|
|
@@ -181,10 +206,51 @@ const normalizeDedupe = (input: DedupeInput | undefined): DedupePolicy | undefin
|
|
|
181
206
|
}
|
|
182
207
|
}
|
|
183
208
|
|
|
209
|
+
/**
|
|
210
|
+
* When the job becomes runnable: a relative `delay` OR an absolute `at`
|
|
211
|
+
* (any `DateTime.Input` — a `DateTime` from `DateTime.makeZonedUnsafe` for
|
|
212
|
+
* wall-clock-in-timezone instants, a `Date`, ISO string, epoch millis, or
|
|
213
|
+
* date parts). Setting both is a compile error; an `at` in the past runs
|
|
214
|
+
* immediately.
|
|
215
|
+
*
|
|
216
|
+
* @since 0.4.0
|
|
217
|
+
*/
|
|
218
|
+
export type RunTimeInput =
|
|
219
|
+
| {
|
|
220
|
+
/**
|
|
221
|
+
* Run this long after enqueue (relative). Any `Duration.Input`:
|
|
222
|
+
* `"5 seconds"`, `Duration.minutes(10)`, millis, ...
|
|
223
|
+
*
|
|
224
|
+
* Mutually exclusive with `at` (setting both is a compile error).
|
|
225
|
+
*/
|
|
226
|
+
readonly delay?: Duration.Input | undefined
|
|
227
|
+
/** Set `delay` for relative times, or `at` (alone) for absolute ones. */
|
|
228
|
+
readonly at?: undefined
|
|
229
|
+
}
|
|
230
|
+
| {
|
|
231
|
+
/**
|
|
232
|
+
* Run at an absolute instant (any `DateTime.Input`) — no duration math:
|
|
233
|
+
*
|
|
234
|
+
* ```ts
|
|
235
|
+
* at: DateTime.makeZonedUnsafe(
|
|
236
|
+
* { year: 2026, month: 8, day: 24, hours: 9 },
|
|
237
|
+
* { timeZone: "America/New_York", adjustForTimeZone: true }
|
|
238
|
+
* )
|
|
239
|
+
* // also: a Date, ISO string, epoch millis, or { year, month, ... } parts
|
|
240
|
+
* ```
|
|
241
|
+
*
|
|
242
|
+
* An `at` in the past runs immediately. Mutually exclusive with `delay`
|
|
243
|
+
* (setting both is a compile error).
|
|
244
|
+
*/
|
|
245
|
+
readonly at?: DateTime.DateTime.Input | undefined
|
|
246
|
+
/** Set `at` for absolute times, or `delay` (alone) for relative ones. */
|
|
247
|
+
readonly delay?: undefined
|
|
248
|
+
}
|
|
249
|
+
|
|
184
250
|
/**
|
|
185
251
|
* @since 0.1.0
|
|
186
252
|
*/
|
|
187
|
-
export
|
|
253
|
+
export type EnqueueOptions = Omit<JobOptions, "delay"> & RunTimeInput & {
|
|
188
254
|
/**
|
|
189
255
|
* Explicit job id. Enqueueing an id that already exists is a no-op that
|
|
190
256
|
* returns the existing id (idempotency). Overrides the definition's
|
|
@@ -199,6 +265,27 @@ export interface EnqueueOptions extends JobOptions {
|
|
|
199
265
|
readonly dedupe?: DedupeInput | undefined
|
|
200
266
|
}
|
|
201
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Options for `enqueueMany` — `EnqueueOptions` minus the per-job fields:
|
|
270
|
+
* `jobId` (a shared id would make every item after the first a duplicate)
|
|
271
|
+
* and `dedupe` (a shared key would collapse the batch into one job; per-item
|
|
272
|
+
* dedup still runs via the definition's `dedupe` callback).
|
|
273
|
+
*
|
|
274
|
+
* Built from parts rather than `Omit<EnqueueOptions, ...>`: a mapped type
|
|
275
|
+
* over the `delay`/`at` union would flatten it and lose their exclusivity.
|
|
276
|
+
*
|
|
277
|
+
* @since 0.4.0
|
|
278
|
+
*/
|
|
279
|
+
export type EnqueueManyOptions = Omit<JobOptions, "delay"> & RunTimeInput & {
|
|
280
|
+
/** Send every item to a different queue than the definition's. */
|
|
281
|
+
readonly queue?: string | undefined
|
|
282
|
+
/**
|
|
283
|
+
* Queryable business context, merged over each item's definition-derived
|
|
284
|
+
* `metadata` (the same overrides apply to every item).
|
|
285
|
+
*/
|
|
286
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
287
|
+
}
|
|
288
|
+
|
|
202
289
|
interface ResolvedDefaults {
|
|
203
290
|
readonly delayMs: number
|
|
204
291
|
readonly priority: number
|
|
@@ -216,16 +303,32 @@ interface ResolvedDefaults {
|
|
|
216
303
|
* @since 0.2.0
|
|
217
304
|
*/
|
|
218
305
|
export interface ScheduleOptions<PayloadInput> {
|
|
306
|
+
/**
|
|
307
|
+
* Cron expression (5-field, e.g. `"0 9 * * 1"` = 9:00 every Monday).
|
|
308
|
+
* First fires at the next matching occurrence. Exactly one of `cron` or
|
|
309
|
+
* `every` must be set.
|
|
310
|
+
*/
|
|
219
311
|
readonly cron?: string | undefined
|
|
312
|
+
/** IANA time zone for `cron` (e.g. `"America/New_York"`). Default UTC. */
|
|
220
313
|
readonly tz?: string | undefined
|
|
314
|
+
/**
|
|
315
|
+
* Fixed interval; first fires one interval from now and stays on that
|
|
316
|
+
* grid. Exactly one of `cron` or `every` must be set.
|
|
317
|
+
*/
|
|
221
318
|
readonly every?: Duration.Input | undefined
|
|
319
|
+
/** The payload every occurrence is enqueued with. */
|
|
222
320
|
readonly payload: PayloadInput
|
|
223
321
|
/** Queryable business context, merged over the definition's `metadata`. */
|
|
224
322
|
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
323
|
+
/** Priority for each occurrence. Higher runs first; default 0. */
|
|
225
324
|
readonly priority?: number | undefined
|
|
325
|
+
/** Attempt budget for each occurrence. Default 1. */
|
|
226
326
|
readonly attempts?: number | undefined
|
|
327
|
+
/** Retry backoff for each occurrence. */
|
|
227
328
|
readonly backoff?: BackoffInput | undefined
|
|
329
|
+
/** Retention for each occurrence's terminal record. */
|
|
228
330
|
readonly keep?: KeepInput | undefined
|
|
331
|
+
/** Per-run execution time limit for each occurrence. */
|
|
229
332
|
readonly timeout?: Duration.Input | undefined
|
|
230
333
|
}
|
|
231
334
|
|
|
@@ -307,6 +410,26 @@ export interface Job<
|
|
|
307
410
|
options?: EnqueueOptions | undefined
|
|
308
411
|
) => Effect.Effect<JobId, never, StoreId | Payload["EncodingServices"]>
|
|
309
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Queue many jobs in bulk — one store round trip per chunk of plain
|
|
415
|
+
* items; items whose definition derives a `dedupe` key fall back to
|
|
416
|
+
* individual enqueues, in order. Returns ids aligned with the payloads.
|
|
417
|
+
* Per-item semantics match `enqueue`: `idempotencyKey`, `dedupe`, and
|
|
418
|
+
* `metadata` callbacks run for each payload, and duplicates are silent
|
|
419
|
+
* no-ops returning the existing id. Options apply to every item
|
|
420
|
+
* (`jobId`/`dedupe` are excluded — a shared id or dedup key would
|
|
421
|
+
* collapse the batch into one job).
|
|
422
|
+
*
|
|
423
|
+
* The batch is not transactional: a store failure mid-batch may leave a
|
|
424
|
+
* subset (not necessarily a prefix) enqueued. Safe under at-least-once —
|
|
425
|
+
* re-running the batch skips already-inserted items when ids are
|
|
426
|
+
* deterministic (`idempotencyKey`); store-assigned ids may re-insert.
|
|
427
|
+
*/
|
|
428
|
+
readonly enqueueMany: (
|
|
429
|
+
payloads: ReadonlyArray<Payload["~type.make.in"]>,
|
|
430
|
+
options?: EnqueueManyOptions | undefined
|
|
431
|
+
) => Effect.Effect<ReadonlyArray<JobId>, never, StoreId | Payload["EncodingServices"]>
|
|
432
|
+
|
|
310
433
|
/** Read the current status of a previously enqueued job. */
|
|
311
434
|
readonly poll: (
|
|
312
435
|
jobId: JobId
|
|
@@ -368,18 +491,23 @@ export interface Job<
|
|
|
368
491
|
jobId: JobId
|
|
369
492
|
) => Effect.Effect<void, JobNotFoundError | JobNotCancellableError, StoreId>
|
|
370
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Cancel whatever pending job holds this dedup key (see `DedupeInput`).
|
|
496
|
+
* Idempotent: returns false when nothing pending holds the key.
|
|
497
|
+
*/
|
|
498
|
+
readonly cancelByKey: (key: string) => Effect.Effect<boolean, never, StoreId>
|
|
499
|
+
|
|
371
500
|
/** Run a delayed job now. */
|
|
372
501
|
readonly promote: (
|
|
373
502
|
jobId: JobId
|
|
374
503
|
) => Effect.Effect<void, JobNotFoundError | JobNotPromotableError, StoreId>
|
|
375
504
|
|
|
376
505
|
/**
|
|
377
|
-
* Create or replace a durable repeatable schedule for this job.
|
|
378
|
-
*
|
|
379
|
-
* occurrence across all workers
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
* into a single catch-up run.
|
|
506
|
+
* Create or replace a durable repeatable schedule for this job. Each
|
|
507
|
+
* occurrence is claimed and enqueued in one atomic store op, so ticks are
|
|
508
|
+
* exactly-once per occurrence across all workers — regardless of history
|
|
509
|
+
* retention. Missed occurrences (downtime) collapse into a single
|
|
510
|
+
* catch-up run.
|
|
383
511
|
*
|
|
384
512
|
* Re-registering with an *unchanged* cadence (same `cron`/`tz`/`every`) is
|
|
385
513
|
* a no-op for the next occurrence — deploy-time re-registration neither
|
|
@@ -490,9 +618,33 @@ const Proto = {
|
|
|
490
618
|
options?.dedupe ?? this.dedupe?.(payload)
|
|
491
619
|
)
|
|
492
620
|
const queue = options?.queue !== undefined ? QueueName(options.queue) : this.queue
|
|
493
|
-
|
|
621
|
+
if (options?.at !== undefined && options.delay !== undefined) {
|
|
622
|
+
// Unrepresentable in TypeScript; guard untyped callers anyway.
|
|
623
|
+
throw new Error("effect-mq: set either `delay` or `at`, not both")
|
|
624
|
+
}
|
|
625
|
+
const delayMs = options?.at !== undefined
|
|
626
|
+
? Effect.map(
|
|
627
|
+
Clock.currentTimeMillis,
|
|
628
|
+
(now) => Math.max(0, DateTime.toEpochMillis(DateTime.makeUnsafe(options.at ?? now)) - now)
|
|
629
|
+
)
|
|
630
|
+
: Effect.succeed(
|
|
631
|
+
options?.delay !== undefined
|
|
632
|
+
? Duration.toMillis(options.delay)
|
|
633
|
+
: this.defaults.delayMs
|
|
634
|
+
)
|
|
635
|
+
// The enqueue span's context rides along on the record, so the
|
|
636
|
+
// handler's span joins the producing trace across processes.
|
|
637
|
+
const spanContext = Effect.currentSpan.pipe(
|
|
638
|
+
Effect.map((span) => ({
|
|
639
|
+
traceId: span.traceId,
|
|
640
|
+
spanId: span.spanId,
|
|
641
|
+
sampled: span.sampled
|
|
642
|
+
})),
|
|
643
|
+
Effect.catchTag("NoSuchElementError", () => Effect.succeed(undefined))
|
|
644
|
+
)
|
|
645
|
+
return Effect.all([Schema.encodeEffect(this.payloadJsonSchema)(payload), delayMs, spanContext]).pipe(
|
|
494
646
|
Effect.orDie,
|
|
495
|
-
Effect.flatMap((encoded) =>
|
|
647
|
+
Effect.flatMap(([encoded, resolvedDelayMs, capturedSpan]) =>
|
|
496
648
|
Effect.flatMap(this.store, (store) =>
|
|
497
649
|
store.enqueue({
|
|
498
650
|
id,
|
|
@@ -512,9 +664,12 @@ const Proto = {
|
|
|
512
664
|
? Duration.toMillis(options.timeout)
|
|
513
665
|
: this.defaults.timeoutMs,
|
|
514
666
|
dedupe,
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
667
|
+
// `delayed` records scheduling INTENT (not queue backlog), so
|
|
668
|
+
// the worker's auto trace-linking stays deterministic.
|
|
669
|
+
trace: capturedSpan === undefined
|
|
670
|
+
? undefined
|
|
671
|
+
: { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
|
|
672
|
+
delayMs: resolvedDelayMs
|
|
518
673
|
}))
|
|
519
674
|
),
|
|
520
675
|
Effect.orDie,
|
|
@@ -537,6 +692,91 @@ const Proto = {
|
|
|
537
692
|
)
|
|
538
693
|
},
|
|
539
694
|
|
|
695
|
+
enqueueMany(this: AnyWithProps, payloads: ReadonlyArray<any>, options?: EnqueueManyOptions) {
|
|
696
|
+
return Effect.suspend(() => {
|
|
697
|
+
const queue = options?.queue !== undefined ? QueueName(options.queue) : this.queue
|
|
698
|
+
if (options?.at !== undefined && options.delay !== undefined) {
|
|
699
|
+
// Unrepresentable in TypeScript; guard untyped callers anyway.
|
|
700
|
+
throw new Error("effect-mq: set either `delay` or `at`, not both")
|
|
701
|
+
}
|
|
702
|
+
const delayMs = options?.at !== undefined
|
|
703
|
+
? Effect.map(
|
|
704
|
+
Clock.currentTimeMillis,
|
|
705
|
+
(now) => Math.max(0, DateTime.toEpochMillis(DateTime.makeUnsafe(options.at ?? now)) - now)
|
|
706
|
+
)
|
|
707
|
+
: Effect.succeed(
|
|
708
|
+
options?.delay !== undefined
|
|
709
|
+
? Duration.toMillis(options.delay)
|
|
710
|
+
: this.defaults.delayMs
|
|
711
|
+
)
|
|
712
|
+
const spanContext = Effect.currentSpan.pipe(
|
|
713
|
+
Effect.map((span) => ({
|
|
714
|
+
traceId: span.traceId,
|
|
715
|
+
spanId: span.spanId,
|
|
716
|
+
sampled: span.sampled
|
|
717
|
+
})),
|
|
718
|
+
Effect.catchTag("NoSuchElementError", () => Effect.succeed(undefined))
|
|
719
|
+
)
|
|
720
|
+
const encodedAll = Effect.forEach(payloads, (fields) => {
|
|
721
|
+
const payload = this.payloadSchema.make(fields)
|
|
722
|
+
return Effect.map(
|
|
723
|
+
Schema.encodeEffect(this.payloadJsonSchema)(payload),
|
|
724
|
+
(encoded) => ({ payload, encoded })
|
|
725
|
+
)
|
|
726
|
+
})
|
|
727
|
+
return Effect.all([encodedAll, delayMs, spanContext]).pipe(
|
|
728
|
+
Effect.orDie,
|
|
729
|
+
Effect.flatMap(([items, resolvedDelayMs, capturedSpan]) =>
|
|
730
|
+
Effect.flatMap(this.store, (store) =>
|
|
731
|
+
store.enqueueMany(items.map(({ encoded, payload }) => ({
|
|
732
|
+
id: this.idempotencyKey !== undefined
|
|
733
|
+
? JobId(`${this._tag}/${this.idempotencyKey(payload)}`)
|
|
734
|
+
: undefined,
|
|
735
|
+
name: this._tag,
|
|
736
|
+
queue,
|
|
737
|
+
payload: encoded,
|
|
738
|
+
metadata: { ...this.metadata?.(payload), ...options?.metadata },
|
|
739
|
+
priority: options?.priority ?? this.defaults.priority,
|
|
740
|
+
attemptsMax: Math.max(1, options?.attempts ?? this.defaults.attempts),
|
|
741
|
+
backoff: options?.backoff !== undefined
|
|
742
|
+
? normalizeBackoff(options.backoff)
|
|
743
|
+
: this.defaults.backoff,
|
|
744
|
+
keep: options?.keep !== undefined
|
|
745
|
+
? normalizeKeep(options.keep)
|
|
746
|
+
: this.defaults.keep,
|
|
747
|
+
timeoutMs: options?.timeout !== undefined
|
|
748
|
+
? Duration.toMillis(options.timeout)
|
|
749
|
+
: this.defaults.timeoutMs,
|
|
750
|
+
dedupe: normalizeDedupe(this.dedupe?.(payload)),
|
|
751
|
+
trace: capturedSpan === undefined
|
|
752
|
+
? undefined
|
|
753
|
+
: { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
|
|
754
|
+
delayMs: resolvedDelayMs
|
|
755
|
+
})))
|
|
756
|
+
)
|
|
757
|
+
),
|
|
758
|
+
Effect.orDie,
|
|
759
|
+
Effect.tap((results) => {
|
|
760
|
+
const duplicates = results.filter((result) => result.duplicate).length
|
|
761
|
+
const update = (duplicate: "true" | "false", count: number) =>
|
|
762
|
+
count === 0 ? Effect.void : Metric.update(
|
|
763
|
+
Metrics.jobsEnqueued.pipe(
|
|
764
|
+
Metric.withAttributes({ name: this._tag, queue, duplicate })
|
|
765
|
+
),
|
|
766
|
+
count
|
|
767
|
+
)
|
|
768
|
+
return Effect.andThen(
|
|
769
|
+
update("false", results.length - duplicates),
|
|
770
|
+
update("true", duplicates)
|
|
771
|
+
)
|
|
772
|
+
}),
|
|
773
|
+
Effect.map((results) => results.map((result) => result.id))
|
|
774
|
+
)
|
|
775
|
+
}).pipe(
|
|
776
|
+
Effect.withSpan(`${this._tag}.enqueueMany`, {}, { captureStackTrace: false })
|
|
777
|
+
)
|
|
778
|
+
},
|
|
779
|
+
|
|
540
780
|
poll(this: AnyWithProps, jobId: JobId) {
|
|
541
781
|
const self = this
|
|
542
782
|
return Effect.flatMap(this.store, (store) =>
|
|
@@ -661,6 +901,15 @@ const Proto = {
|
|
|
661
901
|
)
|
|
662
902
|
},
|
|
663
903
|
|
|
904
|
+
cancelByKey(this: AnyWithProps, key: string) {
|
|
905
|
+
return Effect.flatMap(this.store, (store) =>
|
|
906
|
+
store.cancelByDedupe(this._tag, key).pipe(
|
|
907
|
+
Effect.catchTag("JobStoreError", (error) => Effect.die(error))
|
|
908
|
+
)).pipe(
|
|
909
|
+
Effect.withSpan(`${this._tag}.cancelByKey`, { attributes: { key } }, { captureStackTrace: false })
|
|
910
|
+
)
|
|
911
|
+
},
|
|
912
|
+
|
|
664
913
|
promote(this: AnyWithProps, jobId: JobId) {
|
|
665
914
|
return Effect.flatMap(this.store, (store) =>
|
|
666
915
|
store.promote(jobId).pipe(
|
|
@@ -743,12 +992,14 @@ const Proto = {
|
|
|
743
992
|
|
|
744
993
|
const boundMethods = [
|
|
745
994
|
"enqueue",
|
|
995
|
+
"enqueueMany",
|
|
746
996
|
"poll",
|
|
747
997
|
"attempts",
|
|
748
998
|
"awaitResult",
|
|
749
999
|
"execute",
|
|
750
1000
|
"retry",
|
|
751
1001
|
"cancel",
|
|
1002
|
+
"cancelByKey",
|
|
752
1003
|
"promote",
|
|
753
1004
|
"schedule",
|
|
754
1005
|
"unschedule",
|
|
@@ -805,8 +1056,11 @@ export const make = <
|
|
|
805
1056
|
>(
|
|
806
1057
|
name: Name,
|
|
807
1058
|
options: {
|
|
1059
|
+
/** The payload schema: a `Schema.Struct` or its bare fields object. */
|
|
808
1060
|
readonly payload: Payload
|
|
1061
|
+
/** Schema for the handler's success value (decodable via `awaitResult`/`attempts`). Default `Schema.Void`. */
|
|
809
1062
|
readonly success?: Success | undefined
|
|
1063
|
+
/** Schema for the handler's typed failure (round-trips through storage). Default `Schema.Never`. */
|
|
810
1064
|
readonly error?: Error | undefined
|
|
811
1065
|
/**
|
|
812
1066
|
* Derive a stable job id from the payload. Enqueueing the same key twice
|
|
@@ -855,6 +1109,7 @@ export const make = <
|
|
|
855
1109
|
* Default: the default `JobStore`.
|
|
856
1110
|
*/
|
|
857
1111
|
readonly store?: Context.Key<StoreId, StoreService> | undefined
|
|
1112
|
+
/** Default enqueue options (`delay`, `priority`, `attempts`, `backoff`, `keep`, `timeout`); per-enqueue options override. */
|
|
858
1113
|
readonly defaults?: JobOptions | undefined
|
|
859
1114
|
}
|
|
860
1115
|
): Job<
|
package/src/JobStore.ts
CHANGED
|
@@ -242,6 +242,8 @@ export interface JobRecord {
|
|
|
242
242
|
readonly cancelRequested: boolean
|
|
243
243
|
/** The dedup key this job was enqueued under, if any (see `DedupePolicy`). */
|
|
244
244
|
readonly dedupeKey: string | undefined
|
|
245
|
+
/** The producer's span context, restored as the handler span's parent. */
|
|
246
|
+
readonly trace: TraceContext | undefined
|
|
245
247
|
/** Epoch millis before which the job must not be claimed. */
|
|
246
248
|
readonly runAt: number
|
|
247
249
|
readonly enqueuedAt: number
|
|
@@ -283,6 +285,25 @@ export interface DedupePolicy {
|
|
|
283
285
|
readonly replace: boolean
|
|
284
286
|
}
|
|
285
287
|
|
|
288
|
+
/**
|
|
289
|
+
* The producer's span context, persisted at enqueue so the handler's span
|
|
290
|
+
* can join the producing trace across processes (via `Tracer.externalSpan`).
|
|
291
|
+
*
|
|
292
|
+
* @since 0.4.0
|
|
293
|
+
*/
|
|
294
|
+
export interface TraceContext {
|
|
295
|
+
readonly traceId: string
|
|
296
|
+
readonly spanId: string
|
|
297
|
+
readonly sampled: boolean
|
|
298
|
+
/**
|
|
299
|
+
* Whether the enqueue explicitly scheduled the job for the future
|
|
300
|
+
* (`delay`/`at`). Drives the worker's `traceLinking: "auto"` policy:
|
|
301
|
+
* immediate work continues the producer trace, future work starts its own
|
|
302
|
+
* trace with a causal link.
|
|
303
|
+
*/
|
|
304
|
+
readonly delayed: boolean
|
|
305
|
+
}
|
|
306
|
+
|
|
286
307
|
/**
|
|
287
308
|
* @since 0.1.0
|
|
288
309
|
*/
|
|
@@ -304,6 +325,8 @@ export interface EnqueueRequest {
|
|
|
304
325
|
readonly timeoutMs: number | undefined
|
|
305
326
|
/** Deduplicate against other enqueues sharing `dedupe.key` (same name). */
|
|
306
327
|
readonly dedupe: DedupePolicy | undefined
|
|
328
|
+
/** The producer's span context, for cross-process trace propagation. */
|
|
329
|
+
readonly trace: TraceContext | undefined
|
|
307
330
|
readonly delayMs: number
|
|
308
331
|
}
|
|
309
332
|
|
|
@@ -407,8 +430,9 @@ export interface ListResult {
|
|
|
407
430
|
* A repeatable-job schedule as persisted by the store. Exactly one of `cron`
|
|
408
431
|
* (with optional IANA `tz`) or `everyMs` is set. The payload is stored
|
|
409
432
|
* schema-encoded, like job payloads. `nextRunAt` is maintained by the worker
|
|
410
|
-
* sweep via `
|
|
411
|
-
* `sched/<key>/<slot
|
|
433
|
+
* sweep via the atomic `tickSchedule` (CAS + insert + advance in one op),
|
|
434
|
+
* with tick jobs using the deterministic id `sched/<key>/<slot>`; concurrent
|
|
435
|
+
* sweepers lose the CAS, so each occurrence fires exactly once.
|
|
412
436
|
*
|
|
413
437
|
* @since 0.2.0
|
|
414
438
|
*/
|
|
@@ -599,6 +623,27 @@ export interface Service {
|
|
|
599
623
|
request: EnqueueRequest
|
|
600
624
|
) => Effect.Effect<EnqueueResult, JobStoreError>
|
|
601
625
|
|
|
626
|
+
/**
|
|
627
|
+
* Insert many jobs in bulk: one store round trip per chunk of plain items
|
|
628
|
+
* (drivers chunk large batches); items carrying a dedup key run through
|
|
629
|
+
* the single-enqueue decision tree individually, in order. Results align
|
|
630
|
+
* positionally with the requests; each item carries full single-enqueue
|
|
631
|
+
* semantics (id dedup, dedup keys, delayed routing).
|
|
632
|
+
*
|
|
633
|
+
* The batch is NOT one transaction — items are independent, and a failure
|
|
634
|
+
* may leave a *subset* (not necessarily a prefix) applied. Safe under
|
|
635
|
+
* at-least-once: re-running a batch whose ids are deterministic skips
|
|
636
|
+
* what already landed, but items with store-assigned ids may re-insert.
|
|
637
|
+
* FIFO order within a priority holds except for an item whose
|
|
638
|
+
* auto/generated id collides with an existing row — it re-draws and lands
|
|
639
|
+
* after its batch-mates.
|
|
640
|
+
*
|
|
641
|
+
* @since 0.4.0
|
|
642
|
+
*/
|
|
643
|
+
readonly enqueueMany: (
|
|
644
|
+
requests: ReadonlyArray<EnqueueRequest>
|
|
645
|
+
) => Effect.Effect<ReadonlyArray<EnqueueResult>, JobStoreError>
|
|
646
|
+
|
|
602
647
|
/**
|
|
603
648
|
* Atomically: promote due delayed jobs, then claim the best runnable job
|
|
604
649
|
* matching `queue` + `names` (highest priority first, FIFO within a
|
|
@@ -701,6 +746,20 @@ export interface Service {
|
|
|
701
746
|
JobStoreError | JobNotFoundError | JobNotCancellableError
|
|
702
747
|
>
|
|
703
748
|
|
|
749
|
+
/**
|
|
750
|
+
* Cancel whatever pending job is registered under a dedup key
|
|
751
|
+
* (name-scoped): pending states are cancelled exactly like `cancel`
|
|
752
|
+
* (waiting/delayed become terminal, active gets the heartbeat flag).
|
|
753
|
+
* Returns false when no pending job holds the key — idempotent by design,
|
|
754
|
+
* so "cancel it if anything is scheduled" needs no existence check.
|
|
755
|
+
*
|
|
756
|
+
* @since 0.4.0
|
|
757
|
+
*/
|
|
758
|
+
readonly cancelByDedupe: (
|
|
759
|
+
name: string,
|
|
760
|
+
key: string
|
|
761
|
+
) => Effect.Effect<boolean, JobStoreError>
|
|
762
|
+
|
|
704
763
|
/** Move a delayed job to `waiting` now. */
|
|
705
764
|
readonly promote: (
|
|
706
765
|
id: JobId
|
|
@@ -745,6 +804,22 @@ export interface Service {
|
|
|
745
804
|
JobStoreError
|
|
746
805
|
>
|
|
747
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Atomically claim one schedule occurrence: iff the schedule's `nextRunAt`
|
|
809
|
+
* still equals `expectedRunAt`, insert the tick job AND advance to
|
|
810
|
+
* `nextRunAt` in the same transaction, returning true. A stale sweeper's
|
|
811
|
+
* tick returns false without inserting — exactly-once per occurrence even
|
|
812
|
+
* when the previous slot's job row has been pruned by retention.
|
|
813
|
+
*
|
|
814
|
+
* @since 0.4.0
|
|
815
|
+
*/
|
|
816
|
+
readonly tickSchedule: (
|
|
817
|
+
key: ScheduleKey,
|
|
818
|
+
expectedRunAt: number,
|
|
819
|
+
nextRunAt: number,
|
|
820
|
+
request: EnqueueRequest
|
|
821
|
+
) => Effect.Effect<boolean, JobStoreError>
|
|
822
|
+
|
|
748
823
|
/**
|
|
749
824
|
* Advance a schedule's `nextRunAt` from `expectedRunAt` to `nextRunAt`
|
|
750
825
|
* (conditional, so concurrent sweepers cannot regress it).
|