effect-mq 0.4.1 → 0.5.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 (52) hide show
  1. package/README.md +2 -0
  2. package/dist/Job.d.ts +34 -4
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +9 -1
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobSchedules.d.ts +112 -0
  7. package/dist/JobSchedules.d.ts.map +1 -0
  8. package/dist/JobSchedules.js +106 -0
  9. package/dist/JobSchedules.js.map +1 -0
  10. package/dist/JobStore.d.ts +8 -0
  11. package/dist/JobStore.d.ts.map +1 -1
  12. package/dist/JobStore.js.map +1 -1
  13. package/dist/MemoryJobStore.d.ts.map +1 -1
  14. package/dist/MemoryJobStore.js +2 -1
  15. package/dist/MemoryJobStore.js.map +1 -1
  16. package/dist/Worker.d.ts +29 -1
  17. package/dist/Worker.d.ts.map +1 -1
  18. package/dist/Worker.js +51 -0
  19. package/dist/Worker.js.map +1 -1
  20. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  21. package/dist/drizzle-postgres/DrizzleJobStore.js +9 -4
  22. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  23. package/dist/drizzle-postgres/schema.d.ts +17 -0
  24. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  25. package/dist/drizzle-postgres/schema.js +2 -0
  26. package/dist/drizzle-postgres/schema.js.map +1 -1
  27. package/dist/index.d.ts +7 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +7 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  32. package/dist/redis/RedisJobStore.js +2 -1
  33. package/dist/redis/RedisJobStore.js.map +1 -1
  34. package/dist/redis/scripts.d.ts +1 -1
  35. package/dist/redis/scripts.d.ts.map +1 -1
  36. package/dist/redis/scripts.js +5 -3
  37. package/dist/redis/scripts.js.map +1 -1
  38. package/dist/testing/conformance.d.ts.map +1 -1
  39. package/dist/testing/conformance.js +37 -0
  40. package/dist/testing/conformance.js.map +1 -1
  41. package/package.json +2 -1
  42. package/src/Job.ts +45 -5
  43. package/src/JobSchedules.ts +223 -0
  44. package/src/JobStore.ts +8 -0
  45. package/src/MemoryJobStore.ts +2 -1
  46. package/src/Worker.ts +92 -0
  47. package/src/drizzle-postgres/DrizzleJobStore.ts +10 -4
  48. package/src/drizzle-postgres/schema.ts +2 -0
  49. package/src/index.ts +8 -0
  50. package/src/redis/RedisJobStore.ts +2 -0
  51. package/src/redis/scripts.ts +5 -2
  52. package/src/testing/conformance.ts +44 -0
package/src/Job.ts CHANGED
@@ -88,6 +88,28 @@ export interface BackoffInput {
88
88
  readonly factor?: number | undefined
89
89
  }
90
90
 
91
+ /**
92
+ * The `error` option for `Job.make`: one schema, or a list of schemas the
93
+ * definition unions for you — the tagged-error-list style of Effect's
94
+ * `HttpApiEndpoint`:
95
+ *
96
+ * ```ts
97
+ * error: PaymentDeclined
98
+ * error: [InvoiceNotFound, PaymentDeclined, ProviderTimeout]
99
+ * ```
100
+ *
101
+ * @since 0.4.2
102
+ */
103
+ export type ErrorInput = Schema.Top | ReadonlyArray<Schema.Top>
104
+
105
+ /**
106
+ * The schema a job actually carries for its `error` option: lists become a
107
+ * `Schema.Union` of their members, single schemas pass through.
108
+ *
109
+ * @since 0.4.2
110
+ */
111
+ export type ResolvedError<E extends ErrorInput> = E extends ReadonlyArray<Schema.Top> ? Schema.Union<E> : E
112
+
91
113
  /**
92
114
  * User-facing retention configuration for terminal jobs.
93
115
  *
@@ -330,6 +352,12 @@ export interface ScheduleOptions<PayloadInput> {
330
352
  readonly keep?: KeepInput | undefined
331
353
  /** Per-run execution time limit for each occurrence. */
332
354
  readonly timeout?: Duration.Input | undefined
355
+ /**
356
+ * Ownership label for declarative reconciliation: `JobSchedules.layer`
357
+ * prunes only schedules carrying its own group. Unlabeled schedules are
358
+ * never pruned. Usually set by `JobSchedules`, not by hand.
359
+ */
360
+ readonly group?: string | undefined
333
361
  }
334
362
 
335
363
  /**
@@ -962,6 +990,7 @@ const Proto = {
962
990
  timeoutMs: options.timeout !== undefined
963
991
  ? Duration.toMillis(options.timeout)
964
992
  : self.defaults.timeoutMs,
993
+ group: options.group,
965
994
  nextRunAt
966
995
  }
967
996
  const store = yield* self.store
@@ -1051,7 +1080,7 @@ export const make = <
1051
1080
  const Name extends string,
1052
1081
  Payload extends Schema.Struct.Fields | AnyStructSchema,
1053
1082
  Success extends Schema.Top = Schema.Void,
1054
- Error extends Schema.Top = Schema.Never,
1083
+ Error extends ErrorInput = Schema.Never,
1055
1084
  StoreId = JobStore
1056
1085
  >(
1057
1086
  name: Name,
@@ -1060,7 +1089,11 @@ export const make = <
1060
1089
  readonly payload: Payload
1061
1090
  /** Schema for the handler's success value (decodable via `awaitResult`/`attempts`). Default `Schema.Void`. */
1062
1091
  readonly success?: Success | undefined
1063
- /** Schema for the handler's typed failure (round-trips through storage). Default `Schema.Never`. */
1092
+ /**
1093
+ * Schema for the handler's typed failure — one schema, or a list of
1094
+ * schemas unioned for you (round-trips through storage). Default
1095
+ * `Schema.Never`.
1096
+ */
1064
1097
  readonly error?: Error | undefined
1065
1098
  /**
1066
1099
  * Derive a stable job id from the payload. Enqueueing the same key twice
@@ -1099,7 +1132,7 @@ export const make = <
1099
1132
  */
1100
1133
  readonly retryable?:
1101
1134
  | ((
1102
- error: Error["Type"]
1135
+ error: ResolvedError<Error>["Type"]
1103
1136
  ) => boolean)
1104
1137
  | undefined
1105
1138
  /** The queue this job runs on. Default `"default"`. */
@@ -1116,7 +1149,7 @@ export const make = <
1116
1149
  Name,
1117
1150
  Payload extends Schema.Struct.Fields ? Schema.Struct<Payload> : Payload,
1118
1151
  Success,
1119
- Error,
1152
+ ResolvedError<Error>,
1120
1153
  StoreId
1121
1154
  > => {
1122
1155
  // SAFETY: `Schema.isSchema` discriminates the `Payload` union at runtime;
@@ -1126,7 +1159,14 @@ export const make = <
1126
1159
  ? options.payload as AnyStructSchema
1127
1160
  : Schema.Struct(options.payload as Schema.Struct.Fields)
1128
1161
  const successSchema = options.success ?? Schema.Void
1129
- const errorSchema = options.error ?? Schema.Never
1162
+ // SAFETY: `Array.isArray` discriminates the `ErrorInput` union at runtime;
1163
+ // TypeScript cannot narrow an unresolved generic, so each branch asserts
1164
+ // the side the guard just proved.
1165
+ const errorSchema = options.error === undefined
1166
+ ? Schema.Never
1167
+ : Array.isArray(options.error)
1168
+ ? Schema.Union(options.error as ReadonlyArray<Schema.Top>)
1169
+ : options.error as Schema.Top
1130
1170
  // Wrapping the whole Exit schema in the JSON codec makes the *encoded* side
1131
1171
  // plain JSON (a live Exit/Cause instance would not survive serializing
1132
1172
  // drivers like Redis/Postgres).
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Declarative schedule reconciliation.
3
+ *
4
+ * `.schedule()` is imperative: it creates and updates, and a schedule whose
5
+ * call was deleted from code keeps firing forever (deletion drift). This
6
+ * module declares the FULL schedule set for a service as a layer; on
7
+ * startup it upserts everything declared (idempotent, cadence-preserving)
8
+ * and detects group members that are no longer declared:
9
+ *
10
+ * ```ts
11
+ * const SchedulesLive = JobSchedules.layer({
12
+ * group: "billing-service",
13
+ * schedules: [
14
+ * JobSchedules.schedule(SendDigest, "daily", { cron: "0 9 * * *", payload: {} }),
15
+ * JobSchedules.schedule(GenerateInvoice, "monthly", { cron: "0 0 1 * *", payload: {} })
16
+ * ],
17
+ * removal: "group", // default "warn": log drift, prune nothing
18
+ * removeAfter: "10 minutes" // optional grace window for rolling deploys
19
+ * })
20
+ * ```
21
+ *
22
+ * Safety model: pruning is scoped to the ownership `group`. Schedules
23
+ * created by plain `.schedule()` calls carry no group and are NEVER pruned;
24
+ * other groups' schedules are never touched. The default `removal: "warn"`
25
+ * only logs — destructive pruning is an explicit opt-in.
26
+ *
27
+ * @since 0.5.0
28
+ */
29
+ import { type Context, Duration, Effect, Layer } from "effect"
30
+ import type { ScheduleOptions } from "./Job.ts"
31
+ import type { ScheduleKey, Service as StoreService } from "./JobStore.ts"
32
+
33
+ /**
34
+ * The structural view of a `Job.make` class that `schedule` needs: its tag,
35
+ * its store key, and its bound `schedule` verb.
36
+ *
37
+ * @since 0.5.0
38
+ */
39
+ export interface SchedulableJob<PayloadInput, R> {
40
+ readonly _tag: string
41
+ readonly store: Context.Key<any, StoreService>
42
+ readonly schedule: (
43
+ key: string,
44
+ options: ScheduleOptions<PayloadInput>
45
+ ) => Effect.Effect<ScheduleKey, never, R>
46
+ }
47
+
48
+ /**
49
+ * One declared schedule: a job, its key, and its cadence/options. Built
50
+ * with `JobSchedules.schedule`; consumed by `JobSchedules.layer`.
51
+ *
52
+ * @since 0.5.0
53
+ */
54
+ export interface ScheduleEntry<R> {
55
+ readonly jobName: string
56
+ readonly key: string
57
+ readonly store: Context.Key<any, StoreService>
58
+ readonly register: (group: string) => Effect.Effect<ScheduleKey, never, R>
59
+ }
60
+
61
+ /**
62
+ * Declare one schedule for the reconciled set. Identical semantics to
63
+ * `MyJob.schedule(key, options)` — including cadence preservation on
64
+ * unchanged `cron`/`tz`/`every` — plus the layer's ownership `group`.
65
+ *
66
+ * @since 0.5.0
67
+ */
68
+ export const schedule = <PayloadInput, R>(
69
+ job: SchedulableJob<PayloadInput, R>,
70
+ key: string,
71
+ options: Omit<ScheduleOptions<PayloadInput>, "group">
72
+ ): ScheduleEntry<R> => ({
73
+ jobName: job._tag,
74
+ key,
75
+ store: job.store,
76
+ register: (group) => job.schedule(key, { ...options, group })
77
+ })
78
+
79
+ /**
80
+ * Options for `JobSchedules.layer`.
81
+ *
82
+ * @since 0.5.0
83
+ */
84
+ export interface ReconcileOptions<
85
+ Entries extends ReadonlyArray<ScheduleEntry<any>>,
86
+ Stores extends ReadonlyArray<Context.Key<any, StoreService>>
87
+ > {
88
+ /**
89
+ * Ownership label. Everything this layer declares is upserted with this
90
+ * group, and only schedules carrying this group are candidates for drift
91
+ * detection and pruning. Use one group per service/deployable.
92
+ */
93
+ readonly group: string
94
+ /** The full declared schedule set for this group. */
95
+ readonly schedules: Entries
96
+ /**
97
+ * What to do with group members that are no longer declared:
98
+ * - `"warn"` (default): log them at warning level, prune nothing.
99
+ * - `"group"`: remove them (never touches unlabeled or other-group rows).
100
+ */
101
+ readonly removal?: "warn" | "group" | undefined
102
+ /**
103
+ * Grace window before pruning (requires `removal: "group"`). During a
104
+ * rolling deploy, replicas on the previous release re-declare schedules
105
+ * the new release dropped; pruning immediately and re-adding would
106
+ * re-anchor `every` grids and double-fire crons. With a window, the prune
107
+ * runs this long after startup, re-checks the store, and removes only
108
+ * members still undeclared. Shutting down before the window fires skips
109
+ * the prune (the next startup re-evaluates).
110
+ */
111
+ readonly removeAfter?: Duration.Input | undefined
112
+ /**
113
+ * Extra store keys to reconcile even when no declared entry references
114
+ * them — needed when a release drops the LAST schedule a store had, since
115
+ * drift detection only reaches stores it can see.
116
+ */
117
+ readonly stores?: Stores | undefined
118
+ }
119
+
120
+ const reconcile = (options: {
121
+ readonly group: string
122
+ readonly schedules: ReadonlyArray<ScheduleEntry<any>>
123
+ readonly removal?: "warn" | "group" | undefined
124
+ readonly removeAfter?: Duration.Input | undefined
125
+ readonly stores?: ReadonlyArray<Context.Key<any, StoreService>> | undefined
126
+ }) =>
127
+ Effect.gen(function*() {
128
+ const removal = options.removal ?? "warn"
129
+ if (options.removeAfter !== undefined && removal !== "group") {
130
+ return yield* Effect.die(
131
+ new Error(`effect-mq: \`removeAfter\` requires \`removal: "group"\``)
132
+ )
133
+ }
134
+ // Duplicate declarations are a config bug: the second would silently
135
+ // overwrite the first's cadence/payload on every startup.
136
+ const seen = new Set<string>()
137
+ for (const entry of options.schedules) {
138
+ const id = `${entry.jobName}/${entry.key}`
139
+ if (seen.has(id)) {
140
+ return yield* Effect.die(
141
+ new Error(`effect-mq: schedule "${id}" is declared twice in group "${options.group}"`)
142
+ )
143
+ }
144
+ seen.add(id)
145
+ }
146
+
147
+ // Upsert every declared schedule, collecting the declared key set per
148
+ // store (Context.Key identity groups entries onto their stores).
149
+ const byStore = new Map<Context.Key<any, StoreService>, Set<string>>()
150
+ for (const storeKey of options.stores ?? []) {
151
+ byStore.set(storeKey, new Set())
152
+ }
153
+ for (const entry of options.schedules) {
154
+ const registered = yield* entry.register(options.group)
155
+ const declared = byStore.get(entry.store) ?? new Set<string>()
156
+ declared.add(registered)
157
+ byStore.set(entry.store, declared)
158
+ }
159
+
160
+ // Drift detection per store: group members not in the declared set.
161
+ for (const [storeKey, declared] of byStore) {
162
+ const store = yield* storeKey
163
+ const prune = Effect.gen(function*() {
164
+ const members = yield* store.listSchedules({ group: options.group }).pipe(
165
+ Effect.retry({ times: 5 }),
166
+ Effect.orDie
167
+ )
168
+ const undeclared = members.filter((member) => !declared.has(member.key))
169
+ if (undeclared.length === 0) return
170
+ if (removal === "warn") {
171
+ return yield* Effect.logWarning(
172
+ `effect-mq: ${undeclared.length} schedule(s) in group "${options.group}" ` +
173
+ `are no longer declared and keep firing; \`removal: "group"\` would prune them`,
174
+ undeclared.map((member) => member.key)
175
+ )
176
+ }
177
+ for (const member of undeclared) {
178
+ yield* store.removeSchedule(member.key).pipe(Effect.retry({ times: 5 }), Effect.orDie)
179
+ }
180
+ yield* Effect.logInfo(
181
+ `effect-mq: pruned ${undeclared.length} undeclared schedule(s) from group "${options.group}"`,
182
+ undeclared.map((member) => member.key)
183
+ )
184
+ })
185
+ if (removal === "group" && options.removeAfter !== undefined) {
186
+ // Deferred prune, tied to the layer scope: it re-lists at fire time,
187
+ // so anything re-declared during the window survives.
188
+ yield* prune.pipe(
189
+ Effect.delay(Duration.toMillis(options.removeAfter)),
190
+ Effect.catchCause((cause) =>
191
+ Effect.logError(
192
+ `effect-mq: deferred schedule prune failed (group "${options.group}")`,
193
+ cause
194
+ )
195
+ ),
196
+ Effect.forkScoped
197
+ )
198
+ } else {
199
+ yield* prune
200
+ }
201
+ }
202
+ })
203
+
204
+ /**
205
+ * Reconcile the declared schedule set on startup: upsert everything
206
+ * declared, then warn about (or, with `removal: "group"`, prune) group
207
+ * members that are no longer declared. See the module docs for the safety
208
+ * model.
209
+ *
210
+ * @since 0.5.0
211
+ */
212
+ export const layer = <
213
+ const Entries extends ReadonlyArray<ScheduleEntry<any>>,
214
+ const Stores extends ReadonlyArray<Context.Key<any, StoreService>> = readonly []
215
+ >(
216
+ options: ReconcileOptions<Entries, Stores>
217
+ ): Layer.Layer<never, never, EntryServices<Entries[number]> | StoreId<Stores[number]>> =>
218
+ Layer.effectDiscard(reconcile(options))
219
+
220
+ // Naked-parameter conditionals so empty tuples distribute to `never` instead
221
+ // of inferring `unknown` from nothing.
222
+ type EntryServices<E> = E extends ScheduleEntry<infer R> ? R : never
223
+ type StoreId<K> = K extends Context.Key<infer Id, StoreService> ? Id : never
package/src/JobStore.ts CHANGED
@@ -450,6 +450,12 @@ export interface ScheduleRecord {
450
450
  readonly backoff: BackoffPolicy | undefined
451
451
  readonly keep: KeepPolicy | undefined
452
452
  readonly timeoutMs: number | undefined
453
+ /**
454
+ * Ownership label for declarative reconciliation (`JobSchedules.layer`):
455
+ * a reconciler only ever prunes schedules carrying ITS group. Unlabeled
456
+ * schedules (plain `.schedule()` calls) are never pruned.
457
+ */
458
+ readonly group: string | undefined
453
459
  /** Epoch millis of the next occurrence to enqueue. */
454
460
  readonly nextRunAt: number
455
461
  }
@@ -796,6 +802,8 @@ export interface Service {
796
802
  readonly listSchedules: (options?: {
797
803
  readonly jobName?: string | undefined
798
804
  readonly queue?: QueueName | undefined
805
+ /** Only schedules carrying this ownership group. */
806
+ readonly group?: string | undefined
799
807
  }) => Effect.Effect<ReadonlyArray<ScheduleRecord>, JobStoreError>
800
808
 
801
809
  /** Schedules whose `nextRunAt` is due (per the Effect `Clock`). */
@@ -753,7 +753,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
753
753
  Effect.sync(() =>
754
754
  Array.from(schedules.values()).filter((schedule) =>
755
755
  (options?.jobName === undefined || schedule.jobName === options.jobName) &&
756
- (options?.queue === undefined || schedule.queue === options.queue)
756
+ (options?.queue === undefined || schedule.queue === options.queue) &&
757
+ (options?.group === undefined || schedule.group === options.group)
757
758
  )
758
759
  ),
759
760
 
package/src/Worker.ts CHANGED
@@ -142,10 +142,39 @@ export interface WorkerOptions<StoreId = JobStore> {
142
142
  * - `"none"`: spans and attributes only, no cross-trace edge.
143
143
  */
144
144
  readonly traceLinking?: "auto" | "parent" | "link" | "none" | undefined
145
+ /**
146
+ * Called after a failed run is acked — both retryable attempts and
147
+ * terminal failures (`JobFailure.willRetry` tells them apart), including
148
+ * jobs failed by stall exhaustion. Runs isolated on the worker fiber: a
149
+ * failing hook is logged and never disturbs job processing. The worker
150
+ * also logs failures itself (`logWarning` for retries, `logError` for
151
+ * terminal failures), so the hook is for custom reporting (error
152
+ * trackers, paging), not a logging prerequisite.
153
+ */
154
+ readonly onJobFailure?: ((failure: JobFailure) => Effect.Effect<void>) | undefined
145
155
  /** Identifier used in lock tokens (default: random). */
146
156
  readonly id?: string | undefined
147
157
  }
148
158
 
159
+ /**
160
+ * What `Worker.layer({ onJobFailure })` receives after a failed run is
161
+ * acked: job identity, attempt accounting, whether the store will retry,
162
+ * and the failure cause.
163
+ *
164
+ * @since 0.4.2
165
+ */
166
+ export interface JobFailure {
167
+ readonly jobId: JobId
168
+ readonly name: string
169
+ readonly queue: QueueName
170
+ /** The attempt that failed (1-based). */
171
+ readonly attempt: number
172
+ readonly attemptsMax: number
173
+ /** True when the store will re-run the job after its backoff. */
174
+ readonly willRetry: boolean
175
+ readonly cause: Cause.Cause<unknown>
176
+ }
177
+
149
178
  /**
150
179
  * @since 0.1.0
151
180
  */
@@ -312,6 +341,34 @@ export const make = <StoreId = JobStore>(
312
341
  )
313
342
  )
314
343
 
344
+ // Every failed run is logged (warning while retries remain, error once
345
+ // terminal) and handed to the onJobFailure hook. The hook runs isolated:
346
+ // whatever it does, job processing proceeds.
347
+ const reportFailure = (failure: JobFailure, retryDelayMs: number | undefined) =>
348
+ Effect.gen(function*() {
349
+ yield* (failure.willRetry
350
+ ? Effect.logWarning(
351
+ `effect-mq: job "${failure.name}" failed (attempt ${failure.attempt}/${failure.attemptsMax}); ` +
352
+ `retrying in ${retryDelayMs ?? 0}ms`,
353
+ failure.cause
354
+ )
355
+ : Effect.logError(
356
+ `effect-mq: job "${failure.name}" failed terminally ` +
357
+ `(attempt ${failure.attempt}/${failure.attemptsMax})`,
358
+ failure.cause
359
+ )).pipe(Effect.annotateLogs({
360
+ effectMqJobId: failure.jobId,
361
+ effectMqQueue: failure.queue,
362
+ effectMqAttempt: failure.attempt
363
+ }))
364
+ const hook = options?.onJobFailure
365
+ if (hook !== undefined) {
366
+ yield* hook(failure).pipe(
367
+ Effect.catchCause((cause) => Effect.logError("effect-mq: onJobFailure hook failed", cause))
368
+ )
369
+ }
370
+ })
371
+
315
372
  const routeFailure = (record: JobRecord, exit: JobRecord["exit"]): AckOutcome => {
316
373
  const attempt = record.attemptsMade + 1
317
374
  if (attempt >= record.attemptsMax) {
@@ -471,6 +528,22 @@ export const make = <StoreId = JobStore>(
471
528
  ? { _tag: "Fail", exit: exitValue }
472
529
  : routeFailure(record, exitValue)
473
530
  yield* ackSafely(store.ack(record.id, token, outcome), "ack")
531
+ if (outcome._tag === "Fail" || outcome._tag === "Retry") {
532
+ const cause = Exit.isFailure(effective)
533
+ ? effective.cause
534
+ : Cause.die(
535
+ new Error(`effect-mq: success value for job "${record.name}" could not be encoded`)
536
+ )
537
+ yield* reportFailure({
538
+ jobId: record.id,
539
+ name: record.name,
540
+ queue: record.queue,
541
+ attempt: context.attempt,
542
+ attemptsMax: record.attemptsMax,
543
+ willRetry: outcome._tag === "Retry",
544
+ cause
545
+ }, outcome._tag === "Retry" ? outcome.delayMs : undefined)
546
+ }
474
547
  yield* recordRun(
475
548
  outcome._tag === "Complete"
476
549
  ? "completed"
@@ -627,6 +700,25 @@ export const make = <StoreId = JobStore>(
627
700
  )
628
701
  }
629
702
  yield* Effect.logWarning("effect-mq: recovered stalled jobs", recovered)
703
+ // Stall exhaustion lands jobs in terminal `failed` without an ack, so
704
+ // report those here like any other terminal failure (whichever
705
+ // worker's sweep recovered them reports them, exactly once).
706
+ for (const entry of recovered) {
707
+ if (!entry.failed) continue
708
+ const job = yield* retryStore(store.getJob(entry.id))
709
+ if (Option.isNone(job)) continue
710
+ yield* reportFailure({
711
+ jobId: job.value.id,
712
+ name: job.value.name,
713
+ queue: job.value.queue,
714
+ attempt: job.value.attemptsMade + 1,
715
+ attemptsMax: job.value.attemptsMax,
716
+ willRetry: false,
717
+ cause: Cause.die(
718
+ new Error(job.value.failedReason ?? "effect-mq: job stalled past maxStalledCount")
719
+ )
720
+ }, undefined)
721
+ }
630
722
  }
631
723
  }).pipe(
632
724
  Effect.catchCause((cause) => Effect.logError("effect-mq: stalled sweep failed", cause)),
@@ -136,6 +136,7 @@ type ScheduleRow = {
136
136
  readonly backoff: JobStore.BackoffPolicy | null
137
137
  readonly keep: JobStore.KeepPolicy | null
138
138
  readonly timeoutMs: number | string | null
139
+ readonly group: string | null
139
140
  readonly nextRunAt: Date
140
141
  }
141
142
 
@@ -153,6 +154,7 @@ const toSchedule = (row: ScheduleRow): JobStore.ScheduleRecord => ({
153
154
  backoff: row.backoff ?? undefined,
154
155
  keep: row.keep ?? undefined,
155
156
  timeoutMs: row.timeoutMs === null ? undefined : Number(row.timeoutMs),
157
+ group: row.group ?? undefined,
156
158
  nextRunAt: row.nextRunAt.getTime()
157
159
  })
158
160
 
@@ -1340,20 +1342,21 @@ export const make = (
1340
1342
  upsertSchedule: (schedule) =>
1341
1343
  db.execute(sql`
1342
1344
  INSERT INTO ${schedules} (key, job_name, queue, cron, tz, every_ms, payload, metadata,
1343
- priority, attempts_max, backoff, keep, timeout_ms, next_run_at)
1345
+ priority, attempts_max, backoff, keep, timeout_ms, group_name, next_run_at)
1344
1346
  VALUES (${schedule.key}, ${schedule.jobName}, ${schedule.queue},
1345
1347
  ${schedule.cron ?? null}, ${schedule.tz ?? null}, ${schedule.everyMs ?? null},
1346
1348
  ${JSON.stringify(schedule.payload ?? null)}::jsonb, ${JSON.stringify(schedule.metadata)}::jsonb,
1347
1349
  ${schedule.priority}, ${schedule.attemptsMax},
1348
1350
  ${schedule.backoff === undefined ? null : JSON.stringify(schedule.backoff)}::jsonb,
1349
1351
  ${schedule.keep === undefined ? null : JSON.stringify(schedule.keep)}::jsonb,
1350
- ${schedule.timeoutMs ?? null}, ${new Date(schedule.nextRunAt)})
1352
+ ${schedule.timeoutMs ?? null}, ${schedule.group ?? null}, ${new Date(schedule.nextRunAt)})
1351
1353
  ON CONFLICT (key) DO UPDATE SET
1352
1354
  job_name = EXCLUDED.job_name, queue = EXCLUDED.queue, cron = EXCLUDED.cron,
1353
1355
  tz = EXCLUDED.tz, every_ms = EXCLUDED.every_ms, payload = EXCLUDED.payload,
1354
1356
  metadata = EXCLUDED.metadata, priority = EXCLUDED.priority,
1355
1357
  attempts_max = EXCLUDED.attempts_max, backoff = EXCLUDED.backoff,
1356
1358
  keep = EXCLUDED.keep, timeout_ms = EXCLUDED.timeout_ms,
1359
+ group_name = EXCLUDED.group_name,
1357
1360
  next_run_at = CASE
1358
1361
  WHEN ${schedules.cron} IS NOT DISTINCT FROM EXCLUDED.cron
1359
1362
  AND ${schedules.tz} IS NOT DISTINCT FROM EXCLUDED.tz
@@ -1383,6 +1386,9 @@ export const make = (
1383
1386
  if (listOptions?.queue !== undefined) {
1384
1387
  conditions.push(sql`${schedules.queue} = ${listOptions.queue}`)
1385
1388
  }
1389
+ if (listOptions?.group !== undefined) {
1390
+ conditions.push(sql`${schedules.group} = ${listOptions.group}`)
1391
+ }
1386
1392
  const rows = rowsOf(yield* db.execute<ScheduleRow>(sql`
1387
1393
  SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
1388
1394
  ${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
@@ -1390,7 +1396,7 @@ export const make = (
1390
1396
  ${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
1391
1397
  ${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
1392
1398
  ${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
1393
- ${schedules.nextRunAt} AS "nextRunAt"
1399
+ ${schedules.group} AS "group", ${schedules.nextRunAt} AS "nextRunAt"
1394
1400
  FROM ${schedules}
1395
1401
  WHERE ${sql.join(conditions, sql` AND `)}
1396
1402
  ORDER BY ${schedules.key}
@@ -1408,7 +1414,7 @@ export const make = (
1408
1414
  ${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
1409
1415
  ${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
1410
1416
  ${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
1411
- ${schedules.nextRunAt} AS "nextRunAt"
1417
+ ${schedules.group} AS "group", ${schedules.nextRunAt} AS "nextRunAt"
1412
1418
  FROM ${schedules}
1413
1419
  WHERE ${schedules.nextRunAt} <= ${now}
1414
1420
  ORDER BY ${schedules.nextRunAt} ASC
@@ -201,6 +201,8 @@ const schedulesColumns = <JobName extends string, Queue extends string>() => ({
201
201
  backoff: jsonb("backoff").$type<BackoffPolicy>(),
202
202
  keep: jsonb("keep").$type<KeepPolicy>(),
203
203
  timeoutMs: bigint("timeout_ms", { mode: "number" }),
204
+ /** Ownership label for declarative reconciliation; NULL = never pruned. */
205
+ group: text("group_name"),
204
206
  nextRunAt: timestamp("next_run_at", { withTimezone: true, mode: "date" }).notNull()
205
207
  })
206
208
 
package/src/index.ts CHANGED
@@ -11,6 +11,14 @@
11
11
  */
12
12
  export * as Job from "./Job.ts"
13
13
 
14
+ /**
15
+ * Declarative schedule reconciliation: declare a service's full schedule
16
+ * set as a layer; startup upserts it and detects deletion drift.
17
+ *
18
+ * @since 0.5.0
19
+ */
20
+ export * as JobSchedules from "./JobSchedules.ts"
21
+
14
22
  /**
15
23
  * The storage seam: the `JobStore` service, job records and typed errors.
16
24
  *
@@ -107,6 +107,7 @@ const toSchedule = (hash: ReadonlyMap<string, string>): JobStore.ScheduleRecord
107
107
  backoff: optionalJson<JobStore.BackoffPolicy>(hash.get("backoff")),
108
108
  keep: optionalJson<JobStore.KeepPolicy>(hash.get("keep")),
109
109
  timeoutMs: optionalNumber(hash.get("timeoutMs")),
110
+ group: optionalString(hash.get("group")),
110
111
  nextRunAt: Number(hash.get("nextRunAt") ?? 0)
111
112
  })
112
113
 
@@ -746,6 +747,7 @@ export const make = (
746
747
  schedule.backoff === undefined ? "" : JSON.stringify(schedule.backoff),
747
748
  schedule.keep === undefined ? "" : JSON.stringify(schedule.keep),
748
749
  schedule.timeoutMs === undefined ? "" : String(schedule.timeoutMs),
750
+ schedule.group ?? "",
749
751
  schedule.nextRunAt
750
752
  ).pipe(
751
753
  Effect.mapError(storeError("upsertSchedule failed")),
@@ -833,6 +833,7 @@ export const upsertSchedule = Redis.script(
833
833
  backoffJson: string,
834
834
  keepJson: string,
835
835
  timeoutMs: string,
836
+ group: string,
836
837
  nextRunAt: number
837
838
  ) => [
838
839
  prefix,
@@ -849,6 +850,7 @@ export const upsertSchedule = Redis.script(
849
850
  backoffJson,
850
851
  keepJson,
851
852
  timeoutMs,
853
+ group,
852
854
  nextRunAt
853
855
  ],
854
856
  {
@@ -856,7 +858,7 @@ export const upsertSchedule = Redis.script(
856
858
  lua: `${HELPERS}
857
859
  local key = ARGV[2]
858
860
  local sk = prefix .. ":schedule:" .. key
859
- local nextRunAt = tonumber(ARGV[15])
861
+ local nextRunAt = tonumber(ARGV[16])
860
862
  local prevCron = redis.call("HGET", sk, "cron")
861
863
  if prevCron ~= false
862
864
  and prevCron == ARGV[5]
@@ -872,7 +874,7 @@ redis.call("HSET", sk,
872
874
  "payload", ARGV[8], "metadata", ARGV[9],
873
875
  "priority", ARGV[10], "attemptsMax", ARGV[11],
874
876
  "backoff", ARGV[12], "keep", ARGV[13], "timeoutMs", ARGV[14],
875
- "nextRunAt", fmt(nextRunAt))
877
+ "group", ARGV[15], "nextRunAt", fmt(nextRunAt))
876
878
  redis.call("ZADD", prefix .. ":schedules", nextRunAt, key)
877
879
  return '{"ok":true}'
878
880
  `
@@ -908,6 +910,7 @@ for _, key in ipairs(keys) do
908
910
  local matches = true
909
911
  if filters.jobName ~= nil and byName.jobName ~= filters.jobName then matches = false end
910
912
  if matches and filters.queue ~= nil and byName.queue ~= filters.queue then matches = false end
913
+ if matches and filters.group ~= nil and byName.group ~= filters.group then matches = false end
911
914
  if matches then out[#out + 1] = record end
912
915
  end
913
916
  if #out == 0 then return "[]" end