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/Worker.ts CHANGED
@@ -14,18 +14,21 @@
14
14
  *
15
15
  * @since 0.1.0
16
16
  */
17
- import { Cause, Clock, Context, Deferred, Duration, Effect, Exit, FiberSet, Layer, Schedule, Schema, type Scope, Scope as Scope_, Tracer } from "effect"
17
+ import { Cause, Clock, Context, Deferred, Duration, Effect, Exit, Fiber, FiberSet, Layer, Option, Result, Schedule, Schema, type Scope, Scope as Scope_, Tracer } from "effect"
18
18
  import {
19
19
  type AckOutcome,
20
20
  type BackoffPolicy,
21
- type JobId,
22
21
  isJobStoreError,
22
+ isMarkedUnrecoverable,
23
+ JobId,
23
24
  type JobNotFoundError,
24
25
  type JobRecord,
25
26
  JobStore,
26
27
  type JobStoreError,
27
28
  type LockLostError,
29
+ nextOccurrence,
28
30
  QueueName,
31
+ type ScheduleRecord,
29
32
  type Service as StoreService
30
33
  } from "./JobStore.ts"
31
34
 
@@ -70,6 +73,8 @@ export interface JobDescriptor<
70
73
  readonly exitSchema: Schema.Top & {
71
74
  readonly Type: Exit.Exit<Success["Type"], Error["Type"]>
72
75
  }
76
+ /** When present and returning false for a typed error, retries are skipped. */
77
+ readonly retryable: ((error: Error["Type"]) => boolean) | undefined
73
78
  }
74
79
 
75
80
  /**
@@ -110,6 +115,8 @@ export interface WorkerOptions<StoreId = JobStore> {
110
115
  readonly maxStalledCount?: number | undefined
111
116
  /** Fallback polling interval when idle and no wake-up arrives (default 5s). */
112
117
  readonly pollInterval?: Duration.Input | undefined
118
+ /** How often to tick due repeatable-job schedules (default 15s). */
119
+ readonly scheduleSweepInterval?: Duration.Input | undefined
113
120
  /** Identifier used in lock tokens (default: random). */
114
121
  readonly id?: string | undefined
115
122
  }
@@ -149,6 +156,18 @@ interface HandlerEntry {
149
156
  readonly encodeExit: (
150
157
  exit: Exit.Exit<unknown, unknown>
151
158
  ) => Effect.Effect<JobRecord["exit"], unknown>
159
+ readonly unrecoverableFailure:
160
+ | ((cause: Cause.Cause<unknown>) => boolean)
161
+ | undefined
162
+ }
163
+
164
+ // A cause is unrecoverable when its error or defect was marked via
165
+ // `Job.unrecoverable` (identity-based, so typed channels stay untouched).
166
+ const causeIsMarkedUnrecoverable = (cause: Cause.Cause<unknown>): boolean => {
167
+ const failure = Cause.findErrorOption(cause)
168
+ if (Option.isSome(failure) && isMarkedUnrecoverable(failure.value)) return true
169
+ const die = Cause.findDie(cause)
170
+ return Result.isSuccess(die) && isMarkedUnrecoverable(die.success.defect)
152
171
  }
153
172
 
154
173
  /**
@@ -204,6 +223,7 @@ export const make = <StoreId = JobStore>(
204
223
  ? Duration.toMillis(options.lockRenewInterval)
205
224
  : Math.max(1, Math.floor(lockDurationMs / 2))
206
225
  const stalledMs = Duration.toMillis(options?.stalledInterval ?? 30_000)
226
+ const scheduleSweepMs = Duration.toMillis(options?.scheduleSweepInterval ?? 15_000)
207
227
  const maxStalledCount = options?.maxStalledCount ?? 1
208
228
  const pollMs = Duration.toMillis(options?.pollInterval ?? 5_000)
209
229
  const workerId = options?.id ?? `worker-${Math.random().toString(36).slice(2, 10)}`
@@ -211,7 +231,13 @@ export const make = <StoreId = JobStore>(
211
231
  const handlers = new Map<string, HandlerEntry>()
212
232
  const queueNames = new Map<QueueName, Set<string>>()
213
233
  const startedQueues = new Set<QueueName>()
214
- const inflight = new Map<JobId, { readonly id: JobId; readonly token: string }>()
234
+ const inflight = new Map<
235
+ JobId,
236
+ { readonly id: JobId; readonly token: string; readonly fiber: Fiber.Fiber<unknown, unknown> }
237
+ >()
238
+ // Jobs this worker is interrupting because of a cancel request; their
239
+ // interrupt-only exits ack as Cancelled instead of shutdown-release.
240
+ const cancelling = new Set<JobId>()
215
241
 
216
242
  let tokenCounter = 0
217
243
  const nextToken = () => `${workerId}:${++tokenCounter}`
@@ -285,17 +311,49 @@ export const make = <StoreId = JobStore>(
285
311
  attempt: record.attemptsMade + 1,
286
312
  attemptsMax: record.attemptsMax
287
313
  }
288
- inflight.set(record.id, { id: record.id, token })
289
314
  return Effect.gen(function*() {
290
- const exit = yield* Effect.exit(restore(entry.run(record.payload, context)))
315
+ // The per-run time limit interrupts the handler internally; surface
316
+ // it as a defect so it flows through normal retry accounting.
317
+ // timeoutOrElse (not timeout + a catch on TimeoutError) so a
318
+ // handler's OWN typed TimeoutError failure stays a typed failure.
319
+ const handlerEffect = record.timeoutMs === undefined
320
+ ? entry.run(record.payload, context)
321
+ : entry.run(record.payload, context).pipe(
322
+ Effect.timeoutOrElse({
323
+ duration: record.timeoutMs,
324
+ orElse: () =>
325
+ Effect.die(
326
+ new Cause.TimeoutError(
327
+ `effect-mq: job "${record.name}" timed out after ${record.timeoutMs}ms`
328
+ )
329
+ )
330
+ })
331
+ )
332
+ // The handler runs in a child fiber so a cross-process cancel can
333
+ // interrupt THIS job without killing the taker loop; `restore`
334
+ // keeps the child interruptible while the fork itself is masked.
335
+ const fiber = yield* Effect.forkChild(restore(handlerEffect))
336
+ inflight.set(record.id, { id: record.id, token, fiber })
337
+ const exit = yield* Effect.exit(restore(Fiber.join(fiber)))
291
338
  inflight.delete(record.id)
339
+ const wasCancelled = cancelling.delete(record.id)
340
+
341
+ // A cancel-request interrupt is terminal: ack Cancelled (even if a
342
+ // shutdown races it — cancellation wins, the job must not revive).
343
+ if (wasCancelled && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
344
+ yield* ackSafely(store.ack(record.id, token, { _tag: "Cancelled" }), "ack")
345
+ return
346
+ }
292
347
 
293
348
  // Distinguish worker shutdown from a handler that interrupted
294
349
  // itself: entering an interruptible region while an external
295
350
  // interrupt is pending fails immediately.
296
351
  const shutdown = yield* Effect.exit(restore(Effect.void))
297
352
  if (Exit.isFailure(shutdown)) {
298
- // Worker shutdown: give the job back without consuming an attempt.
353
+ // Worker shutdown: stop the handler (the join above may have been
354
+ // interrupted before the child finished), then give the job back
355
+ // without consuming an attempt.
356
+ yield* Fiber.interrupt(fiber)
299
357
  yield* ackSafely(store.release(record.id, token), "release")
300
358
  return yield* Effect.interrupt
301
359
  }
@@ -324,12 +382,18 @@ export const make = <StoreId = JobStore>(
324
382
  }
325
383
  const exitValue = Exit.isSuccess(encoded) ? encoded.value : undefined
326
384
 
385
+ const unrecoverable = Exit.isFailure(effective) && (
386
+ causeIsMarkedUnrecoverable(effective.cause) ||
387
+ entry.unrecoverableFailure?.(effective.cause) === true
388
+ )
327
389
  const outcome: AckOutcome = Exit.isSuccess(effective)
328
390
  ? encodable
329
391
  // A success whose value can't be encoded must NOT re-run (its
330
392
  // side effects already happened) — fail it with the defect.
331
393
  ? { _tag: "Complete", exit: exitValue }
332
394
  : { _tag: "Fail", exit: exitValue }
395
+ : unrecoverable
396
+ ? { _tag: "Fail", exit: exitValue }
333
397
  : routeFailure(record, exitValue)
334
398
  yield* ackSafely(store.ack(record.id, token, outcome), "ack")
335
399
  })
@@ -406,13 +470,29 @@ export const make = <StoreId = JobStore>(
406
470
  yield* Effect.sleep(lockRenewMs)
407
471
  const entries = Array.from(inflight.values())
408
472
  if (entries.length === 0) return
409
- const lost = yield* retryStore(store.extendLocks(entries, lockDurationMs))
410
- if (lost.length > 0) {
473
+ const result = yield* retryStore(store.extendLocks(
474
+ entries.map((entry) => ({ id: entry.id, token: entry.token })),
475
+ lockDurationMs
476
+ ))
477
+ if (result.lost.length > 0) {
411
478
  yield* Effect.logWarning(
412
479
  "effect-mq: failed to renew locks; jobs may run twice",
413
- lost
480
+ result.lost
414
481
  )
415
482
  }
483
+ // Honour cross-process cancel requests: interrupt the handler fiber;
484
+ // processJob acks the job as Cancelled.
485
+ for (const id of result.cancelRequested) {
486
+ const flight = inflight.get(id)
487
+ if (flight !== undefined) {
488
+ cancelling.add(id)
489
+ // Delivery only — awaiting the handler's exit here would park the
490
+ // heartbeat behind arbitrary user finalizers and starve every other
491
+ // in-flight job's lock renewal. processJob's join observes the exit
492
+ // and acks Cancelled.
493
+ yield* Effect.sync(() => flight.fiber.interruptUnsafe())
494
+ }
495
+ }
416
496
  }).pipe(
417
497
  Effect.catchCause((cause) => Effect.logError("effect-mq: lock renewal failed", cause)),
418
498
  Effect.forever
@@ -429,8 +509,56 @@ export const make = <StoreId = JobStore>(
429
509
  Effect.forever
430
510
  )
431
511
 
512
+ // Tick one due repeatable-job schedule. The enqueue id is deterministic
513
+ // per (schedule, slot), so concurrent sweepers across workers dedup
514
+ // naturally; advanceSchedule is conditional, so double-advances are
515
+ // no-ops.
516
+ const sweepSchedule = (schedule: ScheduleRecord) =>
517
+ Effect.gen(function*() {
518
+ const now = yield* Clock.currentTimeMillis
519
+ const slot = schedule.nextRunAt
520
+ const next = nextOccurrence(schedule, slot, now)
521
+ if (next === undefined) {
522
+ return yield* Effect.logError(
523
+ `effect-mq: schedule "${schedule.key}" has an invalid cron/every configuration; skipping`
524
+ )
525
+ }
526
+ yield* retryStore(store.enqueue({
527
+ id: JobId(`sched/${schedule.key}/${slot}`),
528
+ name: schedule.jobName,
529
+ queue: schedule.queue,
530
+ payload: schedule.payload,
531
+ metadata: { ...schedule.metadata, scheduledFor: new Date(slot).toISOString() },
532
+ priority: schedule.priority,
533
+ attemptsMax: schedule.attemptsMax,
534
+ backoff: schedule.backoff,
535
+ keep: schedule.keep,
536
+ timeoutMs: schedule.timeoutMs,
537
+ delayMs: 0
538
+ }))
539
+ yield* retryStore(store.advanceSchedule(schedule.key, slot, next))
540
+ })
541
+
542
+ // Each due schedule is swept in isolation so one poison row (bad cron,
543
+ // storage error) can never starve the rest of the sweep.
544
+ const scheduleLoop = Effect.gen(function*() {
545
+ yield* Effect.sleep(scheduleSweepMs)
546
+ const due = yield* retryStore(store.dueSchedules())
547
+ for (const schedule of due) {
548
+ yield* sweepSchedule(schedule).pipe(
549
+ Effect.catchCause((cause) =>
550
+ Effect.logError(`effect-mq: sweep of schedule "${schedule.key}" failed`, cause)
551
+ )
552
+ )
553
+ }
554
+ }).pipe(
555
+ Effect.catchCause((cause) => Effect.logError("effect-mq: schedule sweep failed", cause)),
556
+ Effect.forever
557
+ )
558
+
432
559
  yield* FiberSet.run(fibers, renewalLoop)
433
560
  yield* FiberSet.run(fibers, stalledLoop)
561
+ yield* FiberSet.run(fibers, scheduleLoop)
434
562
 
435
563
  // SAFETY: the public `register` signature declares Scope, the handler's R
436
564
  // and the codec services as requirements; the implementation erases them
@@ -471,6 +599,7 @@ export const make = <StoreId = JobStore>(
471
599
  Context.merge(input, services) as Context.Context<unknown>
472
600
  )
473
601
  ) as Effect.Effect<A, E>
602
+ const retryable = job.retryable
474
603
  const entry: HandlerEntry = {
475
604
  run: (payload, context) =>
476
605
  provideCaptured(
@@ -479,7 +608,20 @@ export const make = <StoreId = JobStore>(
479
608
  Effect.flatMap((decoded) => handler(decoded, context))
480
609
  )
481
610
  ),
482
- encodeExit: (exit) => provideCaptured(encodeExit(exit))
611
+ encodeExit: (exit) => provideCaptured(encodeExit(exit)),
612
+ unrecoverableFailure: retryable === undefined ? undefined : (cause) => {
613
+ const failure = Cause.findErrorOption(cause)
614
+ if (Option.isNone(failure)) return false
615
+ try {
616
+ // SAFETY: the only typed failures a handler can produce are
617
+ // its declared error type, which is what `retryable` accepts.
618
+ return !retryable(failure.value as Parameters<typeof retryable>[0])
619
+ } catch {
620
+ // A throwing predicate must never leave the job un-acked:
621
+ // treat the failure as retryable and let the budget decide.
622
+ return false
623
+ }
624
+ }
483
625
  }
484
626
  handlers.set(name, entry)
485
627
  const queue = registerOptions?.queue !== undefined