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.
- package/README.md +2 -0
- package/dist/Job.d.ts +34 -4
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +9 -1
- package/dist/Job.js.map +1 -1
- package/dist/JobSchedules.d.ts +112 -0
- package/dist/JobSchedules.d.ts.map +1 -0
- package/dist/JobSchedules.js +106 -0
- package/dist/JobSchedules.js.map +1 -0
- package/dist/JobStore.d.ts +8 -0
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +2 -1
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Worker.d.ts +29 -1
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +51 -0
- package/dist/Worker.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +9 -4
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +17 -0
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +2 -0
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +2 -1
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +1 -1
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +5 -3
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +37 -0
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +2 -1
- package/src/Job.ts +45 -5
- package/src/JobSchedules.ts +223 -0
- package/src/JobStore.ts +8 -0
- package/src/MemoryJobStore.ts +2 -1
- package/src/Worker.ts +92 -0
- package/src/drizzle-postgres/DrizzleJobStore.ts +10 -4
- package/src/drizzle-postgres/schema.ts +2 -0
- package/src/index.ts +8 -0
- package/src/redis/RedisJobStore.ts +2 -0
- package/src/redis/scripts.ts +5 -2
- 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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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`). */
|
package/src/MemoryJobStore.ts
CHANGED
|
@@ -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")),
|
package/src/redis/scripts.ts
CHANGED
|
@@ -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[
|
|
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
|