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/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<
|
|
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
|
-
|
|
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:
|
|
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
|
|
410
|
-
|
|
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
|