effect-mq 0.1.0 → 0.2.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 +167 -22
- package/dist/Job.d.ts +67 -4
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +76 -3
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +174 -3
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js +89 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts +37 -6
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +201 -25
- 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 +116 -10
- package/dist/Worker.js.map +1 -1
- package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +18 -2
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
- package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.js +287 -40
- 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 → drizzle-postgres}/schema.d.ts +306 -4
- package/dist/drizzle-postgres/schema.d.ts.map +1 -0
- package/dist/{drizzle → drizzle-postgres}/schema.js +36 -2
- package/dist/drizzle-postgres/schema.js.map +1 -0
- package/dist/redis/RedisJobStore.d.ts +56 -0
- package/dist/redis/RedisJobStore.d.ts.map +1 -0
- package/dist/redis/RedisJobStore.js +385 -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 +168 -0
- package/dist/redis/scripts.d.ts.map +1 -0
- package/dist/redis/scripts.js +755 -0
- package/dist/redis/scripts.js.map +1 -0
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +332 -3
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +8 -4
- package/src/Job.ts +189 -5
- package/src/JobStore.ts +252 -4
- package/src/MemoryJobStore.ts +273 -26
- package/src/Worker.ts +152 -10
- package/src/{drizzle → drizzle-postgres}/DrizzleJobStore.ts +412 -40
- package/src/{drizzle → drizzle-postgres}/schema.ts +52 -3
- package/src/redis/RedisJobStore.ts +597 -0
- package/src/redis/index.ts +8 -0
- package/src/redis/scripts.ts +862 -0
- package/src/testing/conformance.ts +421 -3
- package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
- 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.map +0 -1
- package/dist/drizzle/schema.js.map +0 -1
- /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,35 @@
|
|
|
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, Schedule, Schema } from "effect"
|
|
34
34
|
import {
|
|
35
35
|
type BackoffPolicy,
|
|
36
|
+
JobCancelledError,
|
|
36
37
|
JobId,
|
|
38
|
+
type JobNotCancellableError,
|
|
37
39
|
JobNotFoundError,
|
|
40
|
+
type JobNotPromotableError,
|
|
38
41
|
type JobNotRetryableError,
|
|
39
42
|
type JobState,
|
|
40
43
|
JobStore,
|
|
41
44
|
type KeepPolicy,
|
|
45
|
+
nextOccurrence,
|
|
42
46
|
QueueName,
|
|
43
|
-
|
|
47
|
+
ScheduleKey,
|
|
48
|
+
type ScheduleRecord,
|
|
49
|
+
type Service as StoreService,
|
|
50
|
+
unrecoverable
|
|
44
51
|
} from "./JobStore.ts"
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
/**
|
|
55
|
+
* Mark an error value as unrecoverable: the worker skips the remaining
|
|
56
|
+
* retry budget and fails the job immediately. Returns the error unchanged.
|
|
57
|
+
*
|
|
58
|
+
* @since 0.2.0
|
|
59
|
+
*/
|
|
60
|
+
unrecoverable
|
|
61
|
+
}
|
|
45
62
|
import { type JobContext, type RegisterOptions, Worker } from "./Worker.ts"
|
|
46
63
|
|
|
47
64
|
const TypeId = "~effect-mq/Job" as const
|
|
@@ -94,6 +111,11 @@ export interface JobOptions {
|
|
|
94
111
|
readonly backoff?: BackoffInput | undefined
|
|
95
112
|
/** Retention for terminal records. Default: keep forever. */
|
|
96
113
|
readonly keep?: KeepInput | undefined
|
|
114
|
+
/**
|
|
115
|
+
* Per-run execution time limit; the worker interrupts the handler fiber
|
|
116
|
+
* past it and the run counts as a failed attempt (retried per backoff).
|
|
117
|
+
*/
|
|
118
|
+
readonly timeout?: Duration.Input | undefined
|
|
97
119
|
}
|
|
98
120
|
|
|
99
121
|
/**
|
|
@@ -118,6 +140,28 @@ interface ResolvedDefaults {
|
|
|
118
140
|
readonly attempts: number
|
|
119
141
|
readonly backoff: BackoffPolicy | undefined
|
|
120
142
|
readonly keep: KeepPolicy | undefined
|
|
143
|
+
readonly timeoutMs: number | undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Options for `Job.schedule`. Exactly one of `cron` (with optional IANA `tz`)
|
|
148
|
+
* or `every` must be set. `cron` schedules first fire at the next matching
|
|
149
|
+
* occurrence; `every` schedules first fire one interval from now.
|
|
150
|
+
*
|
|
151
|
+
* @since 0.2.0
|
|
152
|
+
*/
|
|
153
|
+
export interface ScheduleOptions<PayloadInput> {
|
|
154
|
+
readonly cron?: string | undefined
|
|
155
|
+
readonly tz?: string | undefined
|
|
156
|
+
readonly every?: Duration.Input | undefined
|
|
157
|
+
readonly payload: PayloadInput
|
|
158
|
+
/** Queryable business context, merged over the definition's `metadata`. */
|
|
159
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
160
|
+
readonly priority?: number | undefined
|
|
161
|
+
readonly attempts?: number | undefined
|
|
162
|
+
readonly backoff?: BackoffInput | undefined
|
|
163
|
+
readonly keep?: KeepInput | undefined
|
|
164
|
+
readonly timeout?: Duration.Input | undefined
|
|
121
165
|
}
|
|
122
166
|
|
|
123
167
|
/**
|
|
@@ -144,8 +188,8 @@ export interface JobAttempt<A, E> {
|
|
|
144
188
|
readonly attempt: number
|
|
145
189
|
readonly startedAt: number | undefined
|
|
146
190
|
readonly finishedAt: number
|
|
147
|
-
readonly outcome: "completed" | "retried" | "failed" | "stalled"
|
|
148
|
-
/** Absent for `stalled` entries. */
|
|
191
|
+
readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
|
|
192
|
+
/** Absent for `stalled` and `cancelled` entries. */
|
|
149
193
|
readonly exit: Option.Option<Exit.Exit<A, E>>
|
|
150
194
|
}
|
|
151
195
|
|
|
@@ -185,6 +229,7 @@ export interface Job<
|
|
|
185
229
|
>
|
|
186
230
|
readonly idempotencyKey: ((payload: Payload["Type"]) => string) | undefined
|
|
187
231
|
readonly metadata: ((payload: Payload["Type"]) => Readonly<Record<string, string>>) | undefined
|
|
232
|
+
readonly retryable: ((error: Error["Type"]) => boolean) | undefined
|
|
188
233
|
readonly defaults: ResolvedDefaults
|
|
189
234
|
|
|
190
235
|
/**
|
|
@@ -248,6 +293,43 @@ export interface Job<
|
|
|
248
293
|
jobId: JobId
|
|
249
294
|
) => Effect.Effect<void, JobNotFoundError | JobNotRetryableError, StoreId>
|
|
250
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Cancel a job: waiting/delayed become terminal (`cancelled`) immediately;
|
|
298
|
+
* a running job's handler fiber is interrupted by its worker on the next
|
|
299
|
+
* heartbeat (latency ≤ `lockRenewInterval`).
|
|
300
|
+
*/
|
|
301
|
+
readonly cancel: (
|
|
302
|
+
jobId: JobId
|
|
303
|
+
) => Effect.Effect<void, JobNotFoundError | JobNotCancellableError, StoreId>
|
|
304
|
+
|
|
305
|
+
/** Run a delayed job now. */
|
|
306
|
+
readonly promote: (
|
|
307
|
+
jobId: JobId
|
|
308
|
+
) => Effect.Effect<void, JobNotFoundError | JobNotPromotableError, StoreId>
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Create or replace a durable repeatable schedule for this job. Ticks
|
|
312
|
+
* enqueue with a slot-deterministic id, so schedules are exactly-once per
|
|
313
|
+
* occurrence across all workers (assuming history retention windows
|
|
314
|
+
* comfortably exceed the sweep interval — a pruned tick job cannot dedup a
|
|
315
|
+
* pathologically stale sweeper). Missed occurrences (downtime) collapse
|
|
316
|
+
* into a single catch-up run.
|
|
317
|
+
*
|
|
318
|
+
* Re-registering with an *unchanged* cadence (same `cron`/`tz`/`every`) is
|
|
319
|
+
* a no-op for the next occurrence — deploy-time re-registration neither
|
|
320
|
+
* re-anchors `every` grids nor drops a pending catch-up run. Changing the
|
|
321
|
+
* cadence resets the next occurrence.
|
|
322
|
+
*/
|
|
323
|
+
readonly schedule: (
|
|
324
|
+
key: string,
|
|
325
|
+
options: ScheduleOptions<Payload["~type.make.in"]>
|
|
326
|
+
) => Effect.Effect<ScheduleKey, never, StoreId | Payload["EncodingServices"]>
|
|
327
|
+
|
|
328
|
+
/** Remove a schedule created by `schedule`. False when it did not exist. */
|
|
329
|
+
readonly unschedule: (
|
|
330
|
+
key: string
|
|
331
|
+
) => Effect.Effect<boolean, never, StoreId>
|
|
332
|
+
|
|
251
333
|
/**
|
|
252
334
|
* Attach the handler that processes this job, as a layer to provide on top
|
|
253
335
|
* of `Worker.layer` (bound to the same store).
|
|
@@ -333,6 +415,9 @@ const Proto = {
|
|
|
333
415
|
keep: options?.keep !== undefined
|
|
334
416
|
? normalizeKeep(options.keep)
|
|
335
417
|
: this.defaults.keep,
|
|
418
|
+
timeoutMs: options?.timeout !== undefined
|
|
419
|
+
? Duration.toMillis(options.timeout)
|
|
420
|
+
: this.defaults.timeoutMs,
|
|
336
421
|
delayMs: options?.delay !== undefined
|
|
337
422
|
? Duration.toMillis(options.delay)
|
|
338
423
|
: this.defaults.delayMs
|
|
@@ -418,6 +503,9 @@ const Proto = {
|
|
|
418
503
|
return yield* Effect.die(new JobNotFoundError({ jobId }))
|
|
419
504
|
}
|
|
420
505
|
const { exit, failedReason, state } = status.value
|
|
506
|
+
if (state === "cancelled") {
|
|
507
|
+
return yield* Effect.die(new JobCancelledError({ jobId }))
|
|
508
|
+
}
|
|
421
509
|
if (state === "completed" || state === "failed") {
|
|
422
510
|
if (Option.isSome(exit)) {
|
|
423
511
|
return yield* exit.value
|
|
@@ -458,6 +546,84 @@ const Proto = {
|
|
|
458
546
|
)
|
|
459
547
|
},
|
|
460
548
|
|
|
549
|
+
cancel(this: AnyWithProps, jobId: JobId) {
|
|
550
|
+
return Effect.flatMap(this.store, (store) =>
|
|
551
|
+
store.cancel(jobId).pipe(
|
|
552
|
+
Effect.catchTag("JobStoreError", (error) => Effect.die(error))
|
|
553
|
+
)).pipe(
|
|
554
|
+
Effect.withSpan(`${this._tag}.cancel`, { attributes: { jobId } }, { captureStackTrace: false })
|
|
555
|
+
)
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
promote(this: AnyWithProps, jobId: JobId) {
|
|
559
|
+
return Effect.flatMap(this.store, (store) =>
|
|
560
|
+
store.promote(jobId).pipe(
|
|
561
|
+
Effect.catchTag("JobStoreError", (error) => Effect.die(error))
|
|
562
|
+
)).pipe(
|
|
563
|
+
Effect.withSpan(`${this._tag}.promote`, { attributes: { jobId } }, { captureStackTrace: false })
|
|
564
|
+
)
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
schedule(this: AnyWithProps, key: string, options: ScheduleOptions<never>) {
|
|
568
|
+
const self = this
|
|
569
|
+
return Effect.gen(function*() {
|
|
570
|
+
const hasCron = options.cron !== undefined
|
|
571
|
+
const hasEvery = options.every !== undefined
|
|
572
|
+
if (hasCron === hasEvery) {
|
|
573
|
+
return yield* Effect.die(
|
|
574
|
+
new Error(`effect-mq: schedule "${key}" must set exactly one of \`cron\` or \`every\``)
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
const everyMs = options.every !== undefined ? Duration.toMillis(options.every) : undefined
|
|
578
|
+
const now = yield* Clock.currentTimeMillis
|
|
579
|
+
const nextRunAt = nextOccurrence(
|
|
580
|
+
{ cron: options.cron, tz: options.tz, everyMs },
|
|
581
|
+
now,
|
|
582
|
+
now
|
|
583
|
+
)
|
|
584
|
+
if (nextRunAt === undefined) {
|
|
585
|
+
return yield* Effect.die(
|
|
586
|
+
new Error(`effect-mq: schedule "${key}" has an invalid cron expression: "${options.cron}"`)
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
const payload = self.payloadSchema.make(options.payload)
|
|
590
|
+
const encoded = yield* Schema.encodeEffect(self.payloadJsonSchema)(payload).pipe(Effect.orDie)
|
|
591
|
+
const scheduleKey = ScheduleKey(`${self._tag}/${key}`)
|
|
592
|
+
const record: ScheduleRecord = {
|
|
593
|
+
key: scheduleKey,
|
|
594
|
+
jobName: self._tag,
|
|
595
|
+
queue: self.queue,
|
|
596
|
+
cron: options.cron,
|
|
597
|
+
tz: options.tz,
|
|
598
|
+
everyMs,
|
|
599
|
+
payload: encoded,
|
|
600
|
+
metadata: { ...self.metadata?.(payload), ...options.metadata },
|
|
601
|
+
priority: options.priority ?? self.defaults.priority,
|
|
602
|
+
attemptsMax: Math.max(1, options.attempts ?? self.defaults.attempts),
|
|
603
|
+
backoff: options.backoff !== undefined
|
|
604
|
+
? normalizeBackoff(options.backoff)
|
|
605
|
+
: self.defaults.backoff,
|
|
606
|
+
keep: options.keep !== undefined ? normalizeKeep(options.keep) : self.defaults.keep,
|
|
607
|
+
timeoutMs: options.timeout !== undefined
|
|
608
|
+
? Duration.toMillis(options.timeout)
|
|
609
|
+
: self.defaults.timeoutMs,
|
|
610
|
+
nextRunAt
|
|
611
|
+
}
|
|
612
|
+
const store = yield* self.store
|
|
613
|
+
yield* store.upsertSchedule(record).pipe(Effect.orDie)
|
|
614
|
+
return scheduleKey
|
|
615
|
+
}).pipe(
|
|
616
|
+
Effect.withSpan(`${this._tag}.schedule`, { attributes: { key } }, { captureStackTrace: false })
|
|
617
|
+
)
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
unschedule(this: AnyWithProps, key: string) {
|
|
621
|
+
return Effect.flatMap(this.store, (store) =>
|
|
622
|
+
store.removeSchedule(ScheduleKey(`${this._tag}/${key}`)).pipe(Effect.orDie)).pipe(
|
|
623
|
+
Effect.withSpan(`${this._tag}.unschedule`, { attributes: { key } }, { captureStackTrace: false })
|
|
624
|
+
)
|
|
625
|
+
},
|
|
626
|
+
|
|
461
627
|
toLayer(
|
|
462
628
|
this: AnyWithProps,
|
|
463
629
|
handler: (payload: any, context: JobContext) => Effect.Effect<any, any, any>,
|
|
@@ -476,6 +642,10 @@ const boundMethods = [
|
|
|
476
642
|
"awaitResult",
|
|
477
643
|
"execute",
|
|
478
644
|
"retry",
|
|
645
|
+
"cancel",
|
|
646
|
+
"promote",
|
|
647
|
+
"schedule",
|
|
648
|
+
"unschedule",
|
|
479
649
|
"toLayer"
|
|
480
650
|
] as const
|
|
481
651
|
|
|
@@ -490,6 +660,7 @@ const makeProto = (options: {
|
|
|
490
660
|
readonly exitSchema: Schema.Top
|
|
491
661
|
readonly idempotencyKey: ((payload: any) => string) | undefined
|
|
492
662
|
readonly metadata: ((payload: any) => Readonly<Record<string, string>>) | undefined
|
|
663
|
+
readonly retryable: ((error: any) => boolean) | undefined
|
|
493
664
|
readonly defaults: ResolvedDefaults
|
|
494
665
|
}): any => {
|
|
495
666
|
function JobDefinition() {}
|
|
@@ -550,6 +721,15 @@ export const make = <
|
|
|
550
721
|
: Payload["Type"]
|
|
551
722
|
) => Readonly<Record<string, string>>)
|
|
552
723
|
| undefined
|
|
724
|
+
/**
|
|
725
|
+
* When present, a typed handler failure for which this returns false
|
|
726
|
+
* skips the remaining retry budget (see also `Job.unrecoverable`).
|
|
727
|
+
*/
|
|
728
|
+
readonly retryable?:
|
|
729
|
+
| ((
|
|
730
|
+
error: Error["Type"]
|
|
731
|
+
) => boolean)
|
|
732
|
+
| undefined
|
|
553
733
|
/** The queue this job runs on. Default `"default"`. */
|
|
554
734
|
readonly queue?: string | undefined
|
|
555
735
|
/**
|
|
@@ -593,6 +773,7 @@ export const make = <
|
|
|
593
773
|
exitSchema,
|
|
594
774
|
idempotencyKey: options.idempotencyKey,
|
|
595
775
|
metadata: options.metadata,
|
|
776
|
+
retryable: options.retryable,
|
|
596
777
|
defaults: {
|
|
597
778
|
delayMs: options.defaults?.delay !== undefined
|
|
598
779
|
? Duration.toMillis(options.defaults.delay)
|
|
@@ -600,7 +781,10 @@ export const make = <
|
|
|
600
781
|
priority: options.defaults?.priority ?? 0,
|
|
601
782
|
attempts: Math.max(1, options.defaults?.attempts ?? 1),
|
|
602
783
|
backoff: normalizeBackoff(options.defaults?.backoff),
|
|
603
|
-
keep: normalizeKeep(options.defaults?.keep)
|
|
784
|
+
keep: normalizeKeep(options.defaults?.keep),
|
|
785
|
+
timeoutMs: options.defaults?.timeout !== undefined
|
|
786
|
+
? Duration.toMillis(options.defaults.timeout)
|
|
787
|
+
: undefined
|
|
604
788
|
}
|
|
605
789
|
})
|
|
606
790
|
}
|
package/src/JobStore.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @since 0.1.0
|
|
18
18
|
*/
|
|
19
|
-
import { Brand, Context, Data, type Effect, type Option, Predicate } from "effect"
|
|
19
|
+
import { Brand, Context, Cron, Data, type Effect, type Option, Predicate, Result } from "effect"
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* The identifier of an enqueued job. Produced by `enqueue` (either
|
|
@@ -47,6 +47,20 @@ export type QueueName = Brand.Branded<string, "effect-mq/QueueName">
|
|
|
47
47
|
*/
|
|
48
48
|
export const QueueName: Brand.Constructor<QueueName> = Brand.nominal<QueueName>()
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The identifier of a repeatable-job schedule.
|
|
52
|
+
*
|
|
53
|
+
* @since 0.2.0
|
|
54
|
+
*/
|
|
55
|
+
export type ScheduleKey = Brand.Branded<string, "effect-mq/ScheduleKey">
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Brand a raw string as a `ScheduleKey`.
|
|
59
|
+
*
|
|
60
|
+
* @since 0.2.0
|
|
61
|
+
*/
|
|
62
|
+
export const ScheduleKey: Brand.Constructor<ScheduleKey> = Brand.nominal<ScheduleKey>()
|
|
63
|
+
|
|
50
64
|
/**
|
|
51
65
|
* The lifecycle states of a job.
|
|
52
66
|
*
|
|
@@ -57,7 +71,13 @@ export const QueueName: Brand.Constructor<QueueName> = Brand.nominal<QueueName>(
|
|
|
57
71
|
*
|
|
58
72
|
* @since 0.1.0
|
|
59
73
|
*/
|
|
60
|
-
export type JobState =
|
|
74
|
+
export type JobState =
|
|
75
|
+
| "waiting"
|
|
76
|
+
| "delayed"
|
|
77
|
+
| "active"
|
|
78
|
+
| "completed"
|
|
79
|
+
| "failed"
|
|
80
|
+
| "cancelled"
|
|
61
81
|
|
|
62
82
|
/**
|
|
63
83
|
* Retry backoff policy, persisted on the job record so any worker can route
|
|
@@ -103,7 +123,7 @@ export interface AttemptRecord {
|
|
|
103
123
|
readonly startedAt: number | undefined
|
|
104
124
|
/** Ack/recovery time of this run (epoch millis). */
|
|
105
125
|
readonly finishedAt: number
|
|
106
|
-
readonly outcome: "completed" | "retried" | "failed" | "stalled"
|
|
126
|
+
readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
|
|
107
127
|
/** Schema-encoded `Exit`; undefined for `stalled`. */
|
|
108
128
|
readonly exit: unknown
|
|
109
129
|
}
|
|
@@ -131,6 +151,13 @@ export interface JobRecord {
|
|
|
131
151
|
readonly stalledCount: number
|
|
132
152
|
readonly backoff: BackoffPolicy | undefined
|
|
133
153
|
readonly keep: KeepPolicy | undefined
|
|
154
|
+
/** Per-run execution time limit; the worker interrupts the handler past it. */
|
|
155
|
+
readonly timeoutMs: number | undefined
|
|
156
|
+
/**
|
|
157
|
+
* Set by `cancel` on an active job; the owning worker interrupts the
|
|
158
|
+
* handler on its next heartbeat and acks `Cancelled`.
|
|
159
|
+
*/
|
|
160
|
+
readonly cancelRequested: boolean
|
|
134
161
|
/** Epoch millis before which the job must not be claimed. */
|
|
135
162
|
readonly runAt: number
|
|
136
163
|
readonly enqueuedAt: number
|
|
@@ -160,9 +187,27 @@ export interface EnqueueRequest {
|
|
|
160
187
|
readonly attemptsMax: number
|
|
161
188
|
readonly backoff: BackoffPolicy | undefined
|
|
162
189
|
readonly keep: KeepPolicy | undefined
|
|
190
|
+
readonly timeoutMs: number | undefined
|
|
163
191
|
readonly delayMs: number
|
|
164
192
|
}
|
|
165
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Generator for store-assigned job ids, used when `EnqueueRequest.id` is
|
|
196
|
+
* undefined (custom ids and idempotency keys always win, and schedule tick
|
|
197
|
+
* ids stay slot-deterministic). May be effectful (e.g. draw from Effect's
|
|
198
|
+
* `Random`). The store retries a bounded number of times when a generated id
|
|
199
|
+
* collides with an existing job, then fails the enqueue with
|
|
200
|
+
* `JobStoreError` — generators must have enough entropy that collisions are
|
|
201
|
+
* pathological, not routine.
|
|
202
|
+
*
|
|
203
|
+
* @example `({ name }) => \`${name}_${ulid()}\``
|
|
204
|
+
*
|
|
205
|
+
* @since 0.2.0
|
|
206
|
+
*/
|
|
207
|
+
export type IdGenerator = (
|
|
208
|
+
request: EnqueueRequest
|
|
209
|
+
) => string | Effect.Effect<string>
|
|
210
|
+
|
|
166
211
|
/**
|
|
167
212
|
* @since 0.1.0
|
|
168
213
|
*/
|
|
@@ -213,6 +258,7 @@ export type AckOutcome =
|
|
|
213
258
|
| { readonly _tag: "Complete"; readonly exit: unknown }
|
|
214
259
|
| { readonly _tag: "Retry"; readonly delayMs: number; readonly exit: unknown }
|
|
215
260
|
| { readonly _tag: "Fail"; readonly exit: unknown }
|
|
261
|
+
| { readonly _tag: "Cancelled" }
|
|
216
262
|
|
|
217
263
|
/**
|
|
218
264
|
* Filters and pagination for `list`. Results are ordered newest-first
|
|
@@ -241,6 +287,45 @@ export interface ListResult {
|
|
|
241
287
|
readonly cursor: string | undefined
|
|
242
288
|
}
|
|
243
289
|
|
|
290
|
+
/**
|
|
291
|
+
* A repeatable-job schedule as persisted by the store. Exactly one of `cron`
|
|
292
|
+
* (with optional IANA `tz`) or `everyMs` is set. The payload is stored
|
|
293
|
+
* schema-encoded, like job payloads. `nextRunAt` is maintained by the worker
|
|
294
|
+
* sweep via `advanceSchedule`; ticks enqueue with the deterministic id
|
|
295
|
+
* `sched/<key>/<slot>`, so concurrent sweepers dedup naturally.
|
|
296
|
+
*
|
|
297
|
+
* @since 0.2.0
|
|
298
|
+
*/
|
|
299
|
+
export interface ScheduleRecord {
|
|
300
|
+
readonly key: ScheduleKey
|
|
301
|
+
readonly jobName: string
|
|
302
|
+
readonly queue: QueueName
|
|
303
|
+
readonly cron: string | undefined
|
|
304
|
+
readonly tz: string | undefined
|
|
305
|
+
readonly everyMs: number | undefined
|
|
306
|
+
readonly payload: unknown
|
|
307
|
+
readonly metadata: Readonly<Record<string, string>>
|
|
308
|
+
readonly priority: number
|
|
309
|
+
readonly attemptsMax: number
|
|
310
|
+
readonly backoff: BackoffPolicy | undefined
|
|
311
|
+
readonly keep: KeepPolicy | undefined
|
|
312
|
+
readonly timeoutMs: number | undefined
|
|
313
|
+
/** Epoch millis of the next occurrence to enqueue. */
|
|
314
|
+
readonly nextRunAt: number
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Result of a heartbeat: locks that could not be extended (lost to stall
|
|
319
|
+
* recovery or another worker) and active jobs with a pending cancel request
|
|
320
|
+
* (the worker must interrupt them and ack `Cancelled`).
|
|
321
|
+
*
|
|
322
|
+
* @since 0.2.0
|
|
323
|
+
*/
|
|
324
|
+
export interface ExtendLocksResult {
|
|
325
|
+
readonly lost: ReadonlyArray<JobId>
|
|
326
|
+
readonly cancelRequested: ReadonlyArray<JobId>
|
|
327
|
+
}
|
|
328
|
+
|
|
244
329
|
/**
|
|
245
330
|
* A transient or fatal driver error (connection loss, serialization, etc.).
|
|
246
331
|
*
|
|
@@ -260,6 +345,73 @@ export class JobStoreError extends Data.TaggedError("JobStoreError")<{
|
|
|
260
345
|
export const isJobStoreError = (u: unknown): u is JobStoreError =>
|
|
261
346
|
Predicate.hasProperty(u, "_tag") && u._tag === "JobStoreError"
|
|
262
347
|
|
|
348
|
+
// Identity-based marking (WeakSet, works on frozen error instances) so the
|
|
349
|
+
// error keeps its declared type and schema round-trip untouched.
|
|
350
|
+
const unrecoverableRegistry = new WeakSet<object>()
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Mark an error value as unrecoverable: when a handler fails with it, the
|
|
354
|
+
* worker skips the remaining retry budget and fails the job immediately. The
|
|
355
|
+
* error itself is returned unchanged, so typed error channels and schemas
|
|
356
|
+
* are unaffected. Also exported as `Job.unrecoverable`.
|
|
357
|
+
*
|
|
358
|
+
* Marking is identity-based, so it only works for object errors — a
|
|
359
|
+
* primitive (string/number) failure is returned unmarked and retries
|
|
360
|
+
* normally; use the definition-level `retryable` predicate for those.
|
|
361
|
+
*
|
|
362
|
+
* @since 0.2.0
|
|
363
|
+
*/
|
|
364
|
+
export const unrecoverable = <E>(error: E): E => {
|
|
365
|
+
if (Object(error) === error) {
|
|
366
|
+
// SAFETY: `Object(x) === x` is true exactly for object values, which is
|
|
367
|
+
// what WeakSet membership requires.
|
|
368
|
+
unrecoverableRegistry.add(error as object)
|
|
369
|
+
}
|
|
370
|
+
return error
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Whether `unrecoverable` marked this value.
|
|
375
|
+
*
|
|
376
|
+
* @internal
|
|
377
|
+
*/
|
|
378
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- boundary classifier over arbitrary error values
|
|
379
|
+
export const isMarkedUnrecoverable = (u: unknown): boolean =>
|
|
380
|
+
// SAFETY: `Object(u) === u` short-circuits to false for primitives, so the
|
|
381
|
+
// assertion only ever passes object values to the WeakSet.
|
|
382
|
+
Object(u) === u && unrecoverableRegistry.has(u as object)
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The next occurrence of a schedule strictly after `now`. `fromSlot` anchors
|
|
386
|
+
* `everyMs` schedules (occurrences stay on the `slot + k * every` grid).
|
|
387
|
+
* Returns undefined for an invalid cron expression.
|
|
388
|
+
*
|
|
389
|
+
* @internal
|
|
390
|
+
*/
|
|
391
|
+
export const nextOccurrence = (
|
|
392
|
+
schedule: Pick<ScheduleRecord, "cron" | "tz" | "everyMs">,
|
|
393
|
+
fromSlot: number,
|
|
394
|
+
now: number
|
|
395
|
+
): number | undefined => {
|
|
396
|
+
if (schedule.cron !== undefined) {
|
|
397
|
+
const parsed = Cron.parse(schedule.cron, schedule.tz)
|
|
398
|
+
if (Result.isFailure(parsed)) return undefined
|
|
399
|
+
// Cron.next throws for parseable-but-unsatisfiable expressions (e.g.
|
|
400
|
+
// "0 0 30 2 *"); treat those as invalid rather than defecting the sweep.
|
|
401
|
+
try {
|
|
402
|
+
return Cron.next(parsed.success, new Date(Math.max(fromSlot, now))).getTime()
|
|
403
|
+
} catch {
|
|
404
|
+
return undefined
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (schedule.everyMs !== undefined && schedule.everyMs > 0) {
|
|
408
|
+
const behind = Math.max(0, now - fromSlot)
|
|
409
|
+
const steps = Math.floor(behind / schedule.everyMs) + 1
|
|
410
|
+
return fromSlot + steps * schedule.everyMs
|
|
411
|
+
}
|
|
412
|
+
return undefined
|
|
413
|
+
}
|
|
414
|
+
|
|
263
415
|
|
|
264
416
|
/**
|
|
265
417
|
* The presented lock token no longer owns the job (it stalled and was
|
|
@@ -288,6 +440,35 @@ export class JobNotRetryableError extends Data.TaggedError("JobNotRetryableError
|
|
|
288
440
|
readonly state: JobState
|
|
289
441
|
}> {}
|
|
290
442
|
|
|
443
|
+
/**
|
|
444
|
+
* `cancel` was called on a job that is already terminal.
|
|
445
|
+
*
|
|
446
|
+
* @since 0.2.0
|
|
447
|
+
*/
|
|
448
|
+
export class JobNotCancellableError extends Data.TaggedError("JobNotCancellableError")<{
|
|
449
|
+
readonly jobId: JobId
|
|
450
|
+
readonly state: JobState
|
|
451
|
+
}> {}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* `promote` was called on a job that is not in the `delayed` state.
|
|
455
|
+
*
|
|
456
|
+
* @since 0.2.0
|
|
457
|
+
*/
|
|
458
|
+
export class JobNotPromotableError extends Data.TaggedError("JobNotPromotableError")<{
|
|
459
|
+
readonly jobId: JobId
|
|
460
|
+
readonly state: JobState
|
|
461
|
+
}> {}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Raised (as a defect) by `awaitResult` when the awaited job was cancelled.
|
|
465
|
+
*
|
|
466
|
+
* @since 0.2.0
|
|
467
|
+
*/
|
|
468
|
+
export class JobCancelledError extends Data.TaggedError("JobCancelledError")<{
|
|
469
|
+
readonly jobId: JobId
|
|
470
|
+
}> {}
|
|
471
|
+
|
|
291
472
|
/**
|
|
292
473
|
* The service shape every store implements.
|
|
293
474
|
*
|
|
@@ -339,7 +520,7 @@ export interface Service {
|
|
|
339
520
|
readonly extendLocks: (
|
|
340
521
|
locks: ReadonlyArray<{ readonly id: JobId; readonly token: string }>,
|
|
341
522
|
durationMs: number
|
|
342
|
-
) => Effect.Effect<
|
|
523
|
+
) => Effect.Effect<ExtendLocksResult, JobStoreError>
|
|
343
524
|
|
|
344
525
|
/**
|
|
345
526
|
* Sweep active jobs whose lock has expired. Each recovered job gets
|
|
@@ -391,6 +572,73 @@ export interface Service {
|
|
|
391
572
|
JobStoreError | JobNotFoundError | JobNotRetryableError
|
|
392
573
|
>
|
|
393
574
|
|
|
575
|
+
/**
|
|
576
|
+
* Cancel a job. Waiting/delayed jobs become terminal (`cancelled`)
|
|
577
|
+
* immediately; active jobs get `cancelRequested` set, and the owning
|
|
578
|
+
* worker interrupts the handler on its next heartbeat. Terminal jobs fail
|
|
579
|
+
* with `JobNotCancellableError`.
|
|
580
|
+
*/
|
|
581
|
+
readonly cancel: (
|
|
582
|
+
id: JobId
|
|
583
|
+
) => Effect.Effect<
|
|
584
|
+
void,
|
|
585
|
+
JobStoreError | JobNotFoundError | JobNotCancellableError
|
|
586
|
+
>
|
|
587
|
+
|
|
588
|
+
/** Move a delayed job to `waiting` now. */
|
|
589
|
+
readonly promote: (
|
|
590
|
+
id: JobId
|
|
591
|
+
) => Effect.Effect<
|
|
592
|
+
void,
|
|
593
|
+
JobStoreError | JobNotFoundError | JobNotPromotableError
|
|
594
|
+
>
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Durably pause a queue: claims return `Empty` until `resume` (delayed
|
|
598
|
+
* jobs still promote to `waiting`, they just aren't handed out). Affects
|
|
599
|
+
* every worker on the store.
|
|
600
|
+
*/
|
|
601
|
+
readonly pause: (queue: QueueName) => Effect.Effect<void, JobStoreError>
|
|
602
|
+
|
|
603
|
+
/** Undo `pause` and wake idle workers. */
|
|
604
|
+
readonly resume: (queue: QueueName) => Effect.Effect<void, JobStoreError>
|
|
605
|
+
|
|
606
|
+
readonly pausedQueues: () => Effect.Effect<
|
|
607
|
+
ReadonlyArray<QueueName>,
|
|
608
|
+
JobStoreError
|
|
609
|
+
>
|
|
610
|
+
|
|
611
|
+
/** Create or replace a repeatable-job schedule (keyed by `schedule.key`). */
|
|
612
|
+
readonly upsertSchedule: (
|
|
613
|
+
schedule: ScheduleRecord
|
|
614
|
+
) => Effect.Effect<void, JobStoreError>
|
|
615
|
+
|
|
616
|
+
/** Remove a schedule. Returns false when the key does not exist. */
|
|
617
|
+
readonly removeSchedule: (
|
|
618
|
+
key: ScheduleKey
|
|
619
|
+
) => Effect.Effect<boolean, JobStoreError>
|
|
620
|
+
|
|
621
|
+
readonly listSchedules: (options?: {
|
|
622
|
+
readonly jobName?: string | undefined
|
|
623
|
+
readonly queue?: QueueName | undefined
|
|
624
|
+
}) => Effect.Effect<ReadonlyArray<ScheduleRecord>, JobStoreError>
|
|
625
|
+
|
|
626
|
+
/** Schedules whose `nextRunAt` is due (per the Effect `Clock`). */
|
|
627
|
+
readonly dueSchedules: () => Effect.Effect<
|
|
628
|
+
ReadonlyArray<ScheduleRecord>,
|
|
629
|
+
JobStoreError
|
|
630
|
+
>
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Advance a schedule's `nextRunAt` from `expectedRunAt` to `nextRunAt`
|
|
634
|
+
* (conditional, so concurrent sweepers cannot regress it).
|
|
635
|
+
*/
|
|
636
|
+
readonly advanceSchedule: (
|
|
637
|
+
key: ScheduleKey,
|
|
638
|
+
expectedRunAt: number,
|
|
639
|
+
nextRunAt: number
|
|
640
|
+
) => Effect.Effect<void, JobStoreError>
|
|
641
|
+
|
|
394
642
|
readonly counts: (
|
|
395
643
|
queue?: QueueName
|
|
396
644
|
) => Effect.Effect<Record<JobState, number>, JobStoreError>
|