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.
Files changed (62) hide show
  1. package/README.md +167 -22
  2. package/dist/Job.d.ts +67 -4
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +76 -3
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +174 -3
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +89 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +37 -6
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +201 -25
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts +5 -1
  15. package/dist/Worker.d.ts.map +1 -1
  16. package/dist/Worker.js +116 -10
  17. package/dist/Worker.js.map +1 -1
  18. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +18 -2
  19. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
  20. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.js +287 -40
  21. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
  22. package/dist/drizzle-postgres/index.d.ts.map +1 -0
  23. package/dist/drizzle-postgres/index.js.map +1 -0
  24. package/dist/{drizzle → drizzle-postgres}/schema.d.ts +306 -4
  25. package/dist/drizzle-postgres/schema.d.ts.map +1 -0
  26. package/dist/{drizzle → drizzle-postgres}/schema.js +36 -2
  27. package/dist/drizzle-postgres/schema.js.map +1 -0
  28. package/dist/redis/RedisJobStore.d.ts +56 -0
  29. package/dist/redis/RedisJobStore.d.ts.map +1 -0
  30. package/dist/redis/RedisJobStore.js +385 -0
  31. package/dist/redis/RedisJobStore.js.map +1 -0
  32. package/dist/redis/index.d.ts +9 -0
  33. package/dist/redis/index.d.ts.map +1 -0
  34. package/dist/redis/index.js +9 -0
  35. package/dist/redis/index.js.map +1 -0
  36. package/dist/redis/scripts.d.ts +168 -0
  37. package/dist/redis/scripts.d.ts.map +1 -0
  38. package/dist/redis/scripts.js +755 -0
  39. package/dist/redis/scripts.js.map +1 -0
  40. package/dist/testing/conformance.d.ts.map +1 -1
  41. package/dist/testing/conformance.js +332 -3
  42. package/dist/testing/conformance.js.map +1 -1
  43. package/package.json +8 -4
  44. package/src/Job.ts +189 -5
  45. package/src/JobStore.ts +252 -4
  46. package/src/MemoryJobStore.ts +273 -26
  47. package/src/Worker.ts +152 -10
  48. package/src/{drizzle → drizzle-postgres}/DrizzleJobStore.ts +412 -40
  49. package/src/{drizzle → drizzle-postgres}/schema.ts +52 -3
  50. package/src/redis/RedisJobStore.ts +597 -0
  51. package/src/redis/index.ts +8 -0
  52. package/src/redis/scripts.ts +862 -0
  53. package/src/testing/conformance.ts +421 -3
  54. package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
  55. package/dist/drizzle/DrizzleJobStore.js.map +0 -1
  56. package/dist/drizzle/index.d.ts.map +0 -1
  57. package/dist/drizzle/index.js.map +0 -1
  58. package/dist/drizzle/schema.d.ts.map +0 -1
  59. package/dist/drizzle/schema.js.map +0 -1
  60. /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
  61. /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
  62. /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
- type Service as StoreService
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 = "waiting" | "delayed" | "active" | "completed" | "failed"
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<ReadonlyArray<JobId>, JobStoreError>
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>