effect-mq 0.1.0 → 0.3.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 +305 -25
- package/dist/Job.d.ts +118 -5
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +119 -4
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +260 -9
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js +115 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts +38 -6
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +351 -47
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Worker.d.ts +5 -1
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +117 -10
- package/dist/Worker.js.map +1 -1
- package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +35 -2
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
- package/dist/drizzle-postgres/DrizzleJobStore.js +941 -0
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
- package/dist/drizzle-postgres/index.d.ts.map +1 -0
- package/dist/drizzle-postgres/index.js.map +1 -0
- package/dist/drizzle-postgres/schema.d.ts +670 -0
- package/dist/drizzle-postgres/schema.d.ts.map +1 -0
- package/dist/drizzle-postgres/schema.js +150 -0
- package/dist/drizzle-postgres/schema.js.map +1 -0
- package/dist/redis/RedisJobStore.d.ts +58 -0
- package/dist/redis/RedisJobStore.d.ts.map +1 -0
- package/dist/redis/RedisJobStore.js +424 -0
- package/dist/redis/RedisJobStore.js.map +1 -0
- package/dist/redis/index.d.ts +9 -0
- package/dist/redis/index.d.ts.map +1 -0
- package/dist/redis/index.js +9 -0
- package/dist/redis/index.js.map +1 -0
- package/dist/redis/scripts.d.ts +181 -0
- package/dist/redis/scripts.d.ts.map +1 -0
- package/dist/redis/scripts.js +940 -0
- package/dist/redis/scripts.js.map +1 -0
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +502 -8
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +8 -4
- package/src/Job.ts +301 -10
- package/src/JobStore.ts +373 -9
- package/src/MemoryJobStore.ts +440 -53
- package/src/Worker.ts +153 -10
- package/src/drizzle-postgres/DrizzleJobStore.ts +1311 -0
- package/src/drizzle-postgres/schema.ts +279 -0
- package/src/redis/RedisJobStore.ts +652 -0
- package/src/redis/index.ts +8 -0
- package/src/redis/scripts.ts +1055 -0
- package/src/testing/conformance.ts +665 -8
- package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
- package/dist/drizzle/DrizzleJobStore.js +0 -426
- package/dist/drizzle/DrizzleJobStore.js.map +0 -1
- package/dist/drizzle/index.d.ts.map +0 -1
- package/dist/drizzle/index.js.map +0 -1
- package/dist/drizzle/schema.d.ts +0 -464
- package/dist/drizzle/schema.d.ts.map +0 -1
- package/dist/drizzle/schema.js +0 -68
- package/dist/drizzle/schema.js.map +0 -1
- package/src/drizzle/DrizzleJobStore.ts +0 -599
- package/src/drizzle/schema.ts +0 -116
- /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
- /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
- /package/src/{drizzle → drizzle-postgres}/index.ts +0 -0
package/src/Job.ts
CHANGED
|
@@ -30,18 +30,37 @@
|
|
|
30
30
|
*
|
|
31
31
|
* @since 0.1.0
|
|
32
32
|
*/
|
|
33
|
-
import { type Context, Duration, Effect, type Exit, Layer, Option, Schedule, Schema } from "effect"
|
|
33
|
+
import { Clock, type Context, Duration, Effect, type Exit, Layer, Option, Predicate, Schedule, Schema } from "effect"
|
|
34
34
|
import {
|
|
35
35
|
type BackoffPolicy,
|
|
36
|
+
type DedupePolicy,
|
|
37
|
+
type KeepStatePolicy,
|
|
38
|
+
JobCancelledError,
|
|
36
39
|
JobId,
|
|
40
|
+
type JobNotCancellableError,
|
|
37
41
|
JobNotFoundError,
|
|
42
|
+
type JobNotPromotableError,
|
|
38
43
|
type JobNotRetryableError,
|
|
39
44
|
type JobState,
|
|
40
45
|
JobStore,
|
|
41
46
|
type KeepPolicy,
|
|
47
|
+
nextOccurrence,
|
|
42
48
|
QueueName,
|
|
43
|
-
|
|
49
|
+
ScheduleKey,
|
|
50
|
+
type ScheduleRecord,
|
|
51
|
+
type Service as StoreService,
|
|
52
|
+
unrecoverable
|
|
44
53
|
} from "./JobStore.ts"
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
/**
|
|
57
|
+
* Mark an error value as unrecoverable: the worker skips the remaining
|
|
58
|
+
* retry budget and fails the job immediately. Returns the error unchanged.
|
|
59
|
+
*
|
|
60
|
+
* @since 0.2.0
|
|
61
|
+
*/
|
|
62
|
+
unrecoverable
|
|
63
|
+
}
|
|
45
64
|
import { type JobContext, type RegisterOptions, Worker } from "./Worker.ts"
|
|
46
65
|
|
|
47
66
|
const TypeId = "~effect-mq/Job" as const
|
|
@@ -72,13 +91,32 @@ export interface BackoffInput {
|
|
|
72
91
|
*
|
|
73
92
|
* @since 0.1.0
|
|
74
93
|
*/
|
|
75
|
-
export interface
|
|
94
|
+
export interface KeepStateInput {
|
|
76
95
|
/** Keep at most this many terminal records (per name + state). */
|
|
77
96
|
readonly count?: number | undefined
|
|
78
97
|
/** Remove terminal records older than this. */
|
|
79
98
|
readonly age?: Duration.Input | undefined
|
|
80
99
|
}
|
|
81
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Retention input: a flat `{ count, age }` applies to all terminal states;
|
|
103
|
+
* the split form sets independent rules per state (completed jobs are
|
|
104
|
+
* usually noise, failed ones evidence) — an absent state keeps its records
|
|
105
|
+
* until the store's `historyTtl` ceiling:
|
|
106
|
+
*
|
|
107
|
+
* ```ts
|
|
108
|
+
* keep: { count: 100 } // all states
|
|
109
|
+
* keep: { completed: { age: "1 day" }, failed: { age: "30 days" } }
|
|
110
|
+
* ```
|
|
111
|
+
*
|
|
112
|
+
* @since 0.1.0
|
|
113
|
+
*/
|
|
114
|
+
export type KeepInput = KeepStateInput | {
|
|
115
|
+
readonly completed?: KeepStateInput | undefined
|
|
116
|
+
readonly failed?: KeepStateInput | undefined
|
|
117
|
+
readonly cancelled?: KeepStateInput | undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
82
120
|
/**
|
|
83
121
|
* Options shared between job defaults and per-enqueue overrides.
|
|
84
122
|
*
|
|
@@ -94,6 +132,52 @@ export interface JobOptions {
|
|
|
94
132
|
readonly backoff?: BackoffInput | undefined
|
|
95
133
|
/** Retention for terminal records. Default: keep forever. */
|
|
96
134
|
readonly keep?: KeepInput | undefined
|
|
135
|
+
/**
|
|
136
|
+
* Per-run execution time limit; the worker interrupts the handler fiber
|
|
137
|
+
* past it and the run counts as a failed attempt (retried per backoff).
|
|
138
|
+
*/
|
|
139
|
+
readonly timeout?: Duration.Input | undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @since 0.1.0
|
|
144
|
+
*/
|
|
145
|
+
/**
|
|
146
|
+
* Deduplication input for `Job.make({ dedupe })` and per-enqueue overrides.
|
|
147
|
+
* A bare string is shorthand for `{ key }`. Dedup never changes the job id —
|
|
148
|
+
* it is a separate, name-scoped key:
|
|
149
|
+
*
|
|
150
|
+
* - `{ key }`: dedupe while the keyed job is pending; done jobs free the key.
|
|
151
|
+
* - `{ key, ttl }`: throttle — at most one job per window.
|
|
152
|
+
* - `{ key, ttl, extend: true }`: debounce — dropped enqueues push the
|
|
153
|
+
* window out.
|
|
154
|
+
* - `{ key, replace: true }`: while the keyed job is still delayed, the new
|
|
155
|
+
* enqueue's content replaces it (latest wins).
|
|
156
|
+
*
|
|
157
|
+
* @since 0.3.0
|
|
158
|
+
*/
|
|
159
|
+
export type DedupeInput = string | {
|
|
160
|
+
readonly key: string
|
|
161
|
+
readonly ttl?: Duration.Input | undefined
|
|
162
|
+
readonly extend?: boolean | undefined
|
|
163
|
+
readonly replace?: boolean | undefined
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const normalizeDedupe = (input: DedupeInput | undefined): DedupePolicy | undefined => {
|
|
167
|
+
if (input === undefined) return undefined
|
|
168
|
+
const config = Predicate.isString(input) ? { key: input } : input
|
|
169
|
+
if (config.key === "") {
|
|
170
|
+
throw new Error("effect-mq: dedupe `key` must be non-empty")
|
|
171
|
+
}
|
|
172
|
+
if (config.extend === true && config.ttl === undefined) {
|
|
173
|
+
throw new Error("effect-mq: dedupe `extend` requires a `ttl`")
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
key: config.key,
|
|
177
|
+
ttlMs: config.ttl !== undefined ? Duration.toMillis(config.ttl) : undefined,
|
|
178
|
+
extend: config.extend === true,
|
|
179
|
+
replace: config.replace === true
|
|
180
|
+
}
|
|
97
181
|
}
|
|
98
182
|
|
|
99
183
|
/**
|
|
@@ -110,6 +194,8 @@ export interface EnqueueOptions extends JobOptions {
|
|
|
110
194
|
readonly queue?: string | undefined
|
|
111
195
|
/** Queryable business context, merged over the definition's `metadata`. */
|
|
112
196
|
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
197
|
+
/** Deduplicate by key (see `DedupeInput`); overrides the definition's `dedupe`. */
|
|
198
|
+
readonly dedupe?: DedupeInput | undefined
|
|
113
199
|
}
|
|
114
200
|
|
|
115
201
|
interface ResolvedDefaults {
|
|
@@ -118,6 +204,28 @@ interface ResolvedDefaults {
|
|
|
118
204
|
readonly attempts: number
|
|
119
205
|
readonly backoff: BackoffPolicy | undefined
|
|
120
206
|
readonly keep: KeepPolicy | undefined
|
|
207
|
+
readonly timeoutMs: number | undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Options for `Job.schedule`. Exactly one of `cron` (with optional IANA `tz`)
|
|
212
|
+
* or `every` must be set. `cron` schedules first fire at the next matching
|
|
213
|
+
* occurrence; `every` schedules first fire one interval from now.
|
|
214
|
+
*
|
|
215
|
+
* @since 0.2.0
|
|
216
|
+
*/
|
|
217
|
+
export interface ScheduleOptions<PayloadInput> {
|
|
218
|
+
readonly cron?: string | undefined
|
|
219
|
+
readonly tz?: string | undefined
|
|
220
|
+
readonly every?: Duration.Input | undefined
|
|
221
|
+
readonly payload: PayloadInput
|
|
222
|
+
/** Queryable business context, merged over the definition's `metadata`. */
|
|
223
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
224
|
+
readonly priority?: number | undefined
|
|
225
|
+
readonly attempts?: number | undefined
|
|
226
|
+
readonly backoff?: BackoffInput | undefined
|
|
227
|
+
readonly keep?: KeepInput | undefined
|
|
228
|
+
readonly timeout?: Duration.Input | undefined
|
|
121
229
|
}
|
|
122
230
|
|
|
123
231
|
/**
|
|
@@ -144,8 +252,8 @@ export interface JobAttempt<A, E> {
|
|
|
144
252
|
readonly attempt: number
|
|
145
253
|
readonly startedAt: number | undefined
|
|
146
254
|
readonly finishedAt: number
|
|
147
|
-
readonly outcome: "completed" | "retried" | "failed" | "stalled"
|
|
148
|
-
/** Absent for `stalled` entries. */
|
|
255
|
+
readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
|
|
256
|
+
/** Absent for `stalled` and `cancelled` entries. */
|
|
149
257
|
readonly exit: Option.Option<Exit.Exit<A, E>>
|
|
150
258
|
}
|
|
151
259
|
|
|
@@ -184,7 +292,9 @@ export interface Job<
|
|
|
184
292
|
>
|
|
185
293
|
>
|
|
186
294
|
readonly idempotencyKey: ((payload: Payload["Type"]) => string) | undefined
|
|
295
|
+
readonly dedupe: ((payload: Payload["Type"]) => DedupeInput) | undefined
|
|
187
296
|
readonly metadata: ((payload: Payload["Type"]) => Readonly<Record<string, string>>) | undefined
|
|
297
|
+
readonly retryable: ((error: Error["Type"]) => boolean) | undefined
|
|
188
298
|
readonly defaults: ResolvedDefaults
|
|
189
299
|
|
|
190
300
|
/**
|
|
@@ -248,6 +358,43 @@ export interface Job<
|
|
|
248
358
|
jobId: JobId
|
|
249
359
|
) => Effect.Effect<void, JobNotFoundError | JobNotRetryableError, StoreId>
|
|
250
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Cancel a job: waiting/delayed become terminal (`cancelled`) immediately;
|
|
363
|
+
* a running job's handler fiber is interrupted by its worker on the next
|
|
364
|
+
* heartbeat (latency ≤ `lockRenewInterval`).
|
|
365
|
+
*/
|
|
366
|
+
readonly cancel: (
|
|
367
|
+
jobId: JobId
|
|
368
|
+
) => Effect.Effect<void, JobNotFoundError | JobNotCancellableError, StoreId>
|
|
369
|
+
|
|
370
|
+
/** Run a delayed job now. */
|
|
371
|
+
readonly promote: (
|
|
372
|
+
jobId: JobId
|
|
373
|
+
) => Effect.Effect<void, JobNotFoundError | JobNotPromotableError, StoreId>
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Create or replace a durable repeatable schedule for this job. Ticks
|
|
377
|
+
* enqueue with a slot-deterministic id, so schedules are exactly-once per
|
|
378
|
+
* occurrence across all workers (assuming history retention windows
|
|
379
|
+
* comfortably exceed the sweep interval — a pruned tick job cannot dedup a
|
|
380
|
+
* pathologically stale sweeper). Missed occurrences (downtime) collapse
|
|
381
|
+
* into a single catch-up run.
|
|
382
|
+
*
|
|
383
|
+
* Re-registering with an *unchanged* cadence (same `cron`/`tz`/`every`) is
|
|
384
|
+
* a no-op for the next occurrence — deploy-time re-registration neither
|
|
385
|
+
* re-anchors `every` grids nor drops a pending catch-up run. Changing the
|
|
386
|
+
* cadence resets the next occurrence.
|
|
387
|
+
*/
|
|
388
|
+
readonly schedule: (
|
|
389
|
+
key: string,
|
|
390
|
+
options: ScheduleOptions<Payload["~type.make.in"]>
|
|
391
|
+
) => Effect.Effect<ScheduleKey, never, StoreId | Payload["EncodingServices"]>
|
|
392
|
+
|
|
393
|
+
/** Remove a schedule created by `schedule`. False when it did not exist. */
|
|
394
|
+
readonly unschedule: (
|
|
395
|
+
key: string
|
|
396
|
+
) => Effect.Effect<boolean, never, StoreId>
|
|
397
|
+
|
|
251
398
|
/**
|
|
252
399
|
* Attach the handler that processes this job, as a layer to provide on top
|
|
253
400
|
* of `Worker.layer` (bound to the same store).
|
|
@@ -292,11 +439,36 @@ const normalizeBackoff = (input: BackoffInput | undefined): BackoffPolicy | unde
|
|
|
292
439
|
factor: input.factor
|
|
293
440
|
}
|
|
294
441
|
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
442
|
+
const normalizeKeepState = (input: KeepStateInput): KeepStatePolicy => ({
|
|
443
|
+
count: input.count,
|
|
444
|
+
ageMs: input.age !== undefined ? Duration.toMillis(input.age) : undefined
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
const normalizeKeep = (input: KeepInput | undefined): KeepPolicy | undefined => {
|
|
448
|
+
if (input === undefined) return undefined
|
|
449
|
+
const split = "completed" in input || "failed" in input || "cancelled" in input
|
|
450
|
+
const flat = "count" in input || "age" in input
|
|
451
|
+
if (split && flat) {
|
|
452
|
+
throw new Error("effect-mq: `keep` must be either flat { count, age } or split per state, not both")
|
|
453
|
+
}
|
|
454
|
+
if (split) {
|
|
455
|
+
// SAFETY: the flat branch was excluded above, so the state keys carry
|
|
456
|
+
// KeepStateInput values.
|
|
457
|
+
const perState = input as {
|
|
458
|
+
readonly completed?: KeepStateInput | undefined
|
|
459
|
+
readonly failed?: KeepStateInput | undefined
|
|
460
|
+
readonly cancelled?: KeepStateInput | undefined
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
completed: perState.completed !== undefined ? normalizeKeepState(perState.completed) : undefined,
|
|
464
|
+
failed: perState.failed !== undefined ? normalizeKeepState(perState.failed) : undefined,
|
|
465
|
+
cancelled: perState.cancelled !== undefined ? normalizeKeepState(perState.cancelled) : undefined
|
|
466
|
+
}
|
|
299
467
|
}
|
|
468
|
+
// SAFETY: the split branch was excluded above, so this is the flat form.
|
|
469
|
+
const policy = normalizeKeepState(input as KeepStateInput)
|
|
470
|
+
return { completed: policy, failed: policy, cancelled: policy }
|
|
471
|
+
}
|
|
300
472
|
|
|
301
473
|
const Proto = {
|
|
302
474
|
[TypeId]: TypeId,
|
|
@@ -313,6 +485,9 @@ const Proto = {
|
|
|
313
485
|
...this.metadata?.(payload),
|
|
314
486
|
...options?.metadata
|
|
315
487
|
}
|
|
488
|
+
const dedupe = normalizeDedupe(
|
|
489
|
+
options?.dedupe ?? this.dedupe?.(payload)
|
|
490
|
+
)
|
|
316
491
|
return Schema.encodeEffect(this.payloadJsonSchema)(payload).pipe(
|
|
317
492
|
Effect.orDie,
|
|
318
493
|
Effect.flatMap((encoded) =>
|
|
@@ -333,6 +508,10 @@ const Proto = {
|
|
|
333
508
|
keep: options?.keep !== undefined
|
|
334
509
|
? normalizeKeep(options.keep)
|
|
335
510
|
: this.defaults.keep,
|
|
511
|
+
timeoutMs: options?.timeout !== undefined
|
|
512
|
+
? Duration.toMillis(options.timeout)
|
|
513
|
+
: this.defaults.timeoutMs,
|
|
514
|
+
dedupe,
|
|
336
515
|
delayMs: options?.delay !== undefined
|
|
337
516
|
? Duration.toMillis(options.delay)
|
|
338
517
|
: this.defaults.delayMs
|
|
@@ -418,6 +597,9 @@ const Proto = {
|
|
|
418
597
|
return yield* Effect.die(new JobNotFoundError({ jobId }))
|
|
419
598
|
}
|
|
420
599
|
const { exit, failedReason, state } = status.value
|
|
600
|
+
if (state === "cancelled") {
|
|
601
|
+
return yield* Effect.die(new JobCancelledError({ jobId }))
|
|
602
|
+
}
|
|
421
603
|
if (state === "completed" || state === "failed") {
|
|
422
604
|
if (Option.isSome(exit)) {
|
|
423
605
|
return yield* exit.value
|
|
@@ -458,6 +640,84 @@ const Proto = {
|
|
|
458
640
|
)
|
|
459
641
|
},
|
|
460
642
|
|
|
643
|
+
cancel(this: AnyWithProps, jobId: JobId) {
|
|
644
|
+
return Effect.flatMap(this.store, (store) =>
|
|
645
|
+
store.cancel(jobId).pipe(
|
|
646
|
+
Effect.catchTag("JobStoreError", (error) => Effect.die(error))
|
|
647
|
+
)).pipe(
|
|
648
|
+
Effect.withSpan(`${this._tag}.cancel`, { attributes: { jobId } }, { captureStackTrace: false })
|
|
649
|
+
)
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
promote(this: AnyWithProps, jobId: JobId) {
|
|
653
|
+
return Effect.flatMap(this.store, (store) =>
|
|
654
|
+
store.promote(jobId).pipe(
|
|
655
|
+
Effect.catchTag("JobStoreError", (error) => Effect.die(error))
|
|
656
|
+
)).pipe(
|
|
657
|
+
Effect.withSpan(`${this._tag}.promote`, { attributes: { jobId } }, { captureStackTrace: false })
|
|
658
|
+
)
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
schedule(this: AnyWithProps, key: string, options: ScheduleOptions<never>) {
|
|
662
|
+
const self = this
|
|
663
|
+
return Effect.gen(function*() {
|
|
664
|
+
const hasCron = options.cron !== undefined
|
|
665
|
+
const hasEvery = options.every !== undefined
|
|
666
|
+
if (hasCron === hasEvery) {
|
|
667
|
+
return yield* Effect.die(
|
|
668
|
+
new Error(`effect-mq: schedule "${key}" must set exactly one of \`cron\` or \`every\``)
|
|
669
|
+
)
|
|
670
|
+
}
|
|
671
|
+
const everyMs = options.every !== undefined ? Duration.toMillis(options.every) : undefined
|
|
672
|
+
const now = yield* Clock.currentTimeMillis
|
|
673
|
+
const nextRunAt = nextOccurrence(
|
|
674
|
+
{ cron: options.cron, tz: options.tz, everyMs },
|
|
675
|
+
now,
|
|
676
|
+
now
|
|
677
|
+
)
|
|
678
|
+
if (nextRunAt === undefined) {
|
|
679
|
+
return yield* Effect.die(
|
|
680
|
+
new Error(`effect-mq: schedule "${key}" has an invalid cron expression: "${options.cron}"`)
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
const payload = self.payloadSchema.make(options.payload)
|
|
684
|
+
const encoded = yield* Schema.encodeEffect(self.payloadJsonSchema)(payload).pipe(Effect.orDie)
|
|
685
|
+
const scheduleKey = ScheduleKey(`${self._tag}/${key}`)
|
|
686
|
+
const record: ScheduleRecord = {
|
|
687
|
+
key: scheduleKey,
|
|
688
|
+
jobName: self._tag,
|
|
689
|
+
queue: self.queue,
|
|
690
|
+
cron: options.cron,
|
|
691
|
+
tz: options.tz,
|
|
692
|
+
everyMs,
|
|
693
|
+
payload: encoded,
|
|
694
|
+
metadata: { ...self.metadata?.(payload), ...options.metadata },
|
|
695
|
+
priority: options.priority ?? self.defaults.priority,
|
|
696
|
+
attemptsMax: Math.max(1, options.attempts ?? self.defaults.attempts),
|
|
697
|
+
backoff: options.backoff !== undefined
|
|
698
|
+
? normalizeBackoff(options.backoff)
|
|
699
|
+
: self.defaults.backoff,
|
|
700
|
+
keep: options.keep !== undefined ? normalizeKeep(options.keep) : self.defaults.keep,
|
|
701
|
+
timeoutMs: options.timeout !== undefined
|
|
702
|
+
? Duration.toMillis(options.timeout)
|
|
703
|
+
: self.defaults.timeoutMs,
|
|
704
|
+
nextRunAt
|
|
705
|
+
}
|
|
706
|
+
const store = yield* self.store
|
|
707
|
+
yield* store.upsertSchedule(record).pipe(Effect.orDie)
|
|
708
|
+
return scheduleKey
|
|
709
|
+
}).pipe(
|
|
710
|
+
Effect.withSpan(`${this._tag}.schedule`, { attributes: { key } }, { captureStackTrace: false })
|
|
711
|
+
)
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
unschedule(this: AnyWithProps, key: string) {
|
|
715
|
+
return Effect.flatMap(this.store, (store) =>
|
|
716
|
+
store.removeSchedule(ScheduleKey(`${this._tag}/${key}`)).pipe(Effect.orDie)).pipe(
|
|
717
|
+
Effect.withSpan(`${this._tag}.unschedule`, { attributes: { key } }, { captureStackTrace: false })
|
|
718
|
+
)
|
|
719
|
+
},
|
|
720
|
+
|
|
461
721
|
toLayer(
|
|
462
722
|
this: AnyWithProps,
|
|
463
723
|
handler: (payload: any, context: JobContext) => Effect.Effect<any, any, any>,
|
|
@@ -476,6 +736,10 @@ const boundMethods = [
|
|
|
476
736
|
"awaitResult",
|
|
477
737
|
"execute",
|
|
478
738
|
"retry",
|
|
739
|
+
"cancel",
|
|
740
|
+
"promote",
|
|
741
|
+
"schedule",
|
|
742
|
+
"unschedule",
|
|
479
743
|
"toLayer"
|
|
480
744
|
] as const
|
|
481
745
|
|
|
@@ -489,7 +753,9 @@ const makeProto = (options: {
|
|
|
489
753
|
readonly errorSchema: Schema.Top
|
|
490
754
|
readonly exitSchema: Schema.Top
|
|
491
755
|
readonly idempotencyKey: ((payload: any) => string) | undefined
|
|
756
|
+
readonly dedupe: ((payload: any) => DedupeInput) | undefined
|
|
492
757
|
readonly metadata: ((payload: any) => Readonly<Record<string, string>>) | undefined
|
|
758
|
+
readonly retryable: ((error: any) => boolean) | undefined
|
|
493
759
|
readonly defaults: ResolvedDefaults
|
|
494
760
|
}): any => {
|
|
495
761
|
function JobDefinition() {}
|
|
@@ -540,6 +806,17 @@ export const make = <
|
|
|
540
806
|
: Payload["Type"]
|
|
541
807
|
) => string)
|
|
542
808
|
| undefined
|
|
809
|
+
/**
|
|
810
|
+
* Derive a dedup key (and optional throttle/debounce/replace behavior)
|
|
811
|
+
* from the payload. Unlike `idempotencyKey`, this never changes the job
|
|
812
|
+
* id — see `DedupeInput` for the mode semantics.
|
|
813
|
+
*/
|
|
814
|
+
readonly dedupe?:
|
|
815
|
+
| ((
|
|
816
|
+
payload: Payload extends Schema.Struct.Fields ? Schema.Struct.Type<Payload>
|
|
817
|
+
: Payload["Type"]
|
|
818
|
+
) => DedupeInput)
|
|
819
|
+
| undefined
|
|
543
820
|
/**
|
|
544
821
|
* Derive queryable business context from the payload (flat string map, so
|
|
545
822
|
* every driver can index it). Merged with per-enqueue `metadata`.
|
|
@@ -550,6 +827,15 @@ export const make = <
|
|
|
550
827
|
: Payload["Type"]
|
|
551
828
|
) => Readonly<Record<string, string>>)
|
|
552
829
|
| undefined
|
|
830
|
+
/**
|
|
831
|
+
* When present, a typed handler failure for which this returns false
|
|
832
|
+
* skips the remaining retry budget (see also `Job.unrecoverable`).
|
|
833
|
+
*/
|
|
834
|
+
readonly retryable?:
|
|
835
|
+
| ((
|
|
836
|
+
error: Error["Type"]
|
|
837
|
+
) => boolean)
|
|
838
|
+
| undefined
|
|
553
839
|
/** The queue this job runs on. Default `"default"`. */
|
|
554
840
|
readonly queue?: string | undefined
|
|
555
841
|
/**
|
|
@@ -592,7 +878,9 @@ export const make = <
|
|
|
592
878
|
errorSchema,
|
|
593
879
|
exitSchema,
|
|
594
880
|
idempotencyKey: options.idempotencyKey,
|
|
881
|
+
dedupe: options.dedupe,
|
|
595
882
|
metadata: options.metadata,
|
|
883
|
+
retryable: options.retryable,
|
|
596
884
|
defaults: {
|
|
597
885
|
delayMs: options.defaults?.delay !== undefined
|
|
598
886
|
? Duration.toMillis(options.defaults.delay)
|
|
@@ -600,7 +888,10 @@ export const make = <
|
|
|
600
888
|
priority: options.defaults?.priority ?? 0,
|
|
601
889
|
attempts: Math.max(1, options.defaults?.attempts ?? 1),
|
|
602
890
|
backoff: normalizeBackoff(options.defaults?.backoff),
|
|
603
|
-
keep: normalizeKeep(options.defaults?.keep)
|
|
891
|
+
keep: normalizeKeep(options.defaults?.keep),
|
|
892
|
+
timeoutMs: options.defaults?.timeout !== undefined
|
|
893
|
+
? Duration.toMillis(options.defaults.timeout)
|
|
894
|
+
: undefined
|
|
604
895
|
}
|
|
605
896
|
})
|
|
606
897
|
}
|