effect-mq 0.4.2 → 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/dist/Job.d.ts +6 -0
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +1 -0
- 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/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 +1 -1
- package/src/Job.ts +7 -0
- package/src/JobSchedules.ts +223 -0
- package/src/JobStore.ts +8 -0
- package/src/MemoryJobStore.ts +2 -1
- 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
|
@@ -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
|
|
|
@@ -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
|
|
@@ -805,6 +805,7 @@ export const jobStoreConformance = (
|
|
|
805
805
|
backoff: { _tag: "fixed", delayMs: 1_000 },
|
|
806
806
|
keep: undefined,
|
|
807
807
|
timeoutMs: 5_000,
|
|
808
|
+
group: undefined,
|
|
808
809
|
nextRunAt: 60_000
|
|
809
810
|
}
|
|
810
811
|
yield* store.upsertSchedule(schedule)
|
|
@@ -847,9 +848,49 @@ export const jobStoreConformance = (
|
|
|
847
848
|
backoff: undefined,
|
|
848
849
|
keep: undefined,
|
|
849
850
|
timeoutMs: undefined,
|
|
851
|
+
group: undefined,
|
|
850
852
|
nextRunAt: 60_000
|
|
851
853
|
})
|
|
852
854
|
|
|
855
|
+
it.effect("schedule group labels persist and filter listSchedules", () =>
|
|
856
|
+
withStore((store) =>
|
|
857
|
+
Effect.gen(function*() {
|
|
858
|
+
const base = minutelySchedule()
|
|
859
|
+
yield* store.upsertSchedule({
|
|
860
|
+
...base,
|
|
861
|
+
key: JobStore.ScheduleKey("TestJob/labeled"),
|
|
862
|
+
group: "svc-a"
|
|
863
|
+
})
|
|
864
|
+
yield* store.upsertSchedule({
|
|
865
|
+
...base,
|
|
866
|
+
key: JobStore.ScheduleKey("TestJob/other"),
|
|
867
|
+
group: "svc-b"
|
|
868
|
+
})
|
|
869
|
+
yield* store.upsertSchedule({ ...base, key: JobStore.ScheduleKey("TestJob/plain") })
|
|
870
|
+
|
|
871
|
+
const labeled = yield* store.listSchedules({ group: "svc-a" })
|
|
872
|
+
expect(labeled.map((schedule) => schedule.key)).toEqual(["TestJob/labeled"])
|
|
873
|
+
expect(labeled[0]?.group).toBe("svc-a")
|
|
874
|
+
|
|
875
|
+
const all = yield* store.listSchedules()
|
|
876
|
+
expect(all).toHaveLength(3)
|
|
877
|
+
expect(all.find((schedule) => schedule.key === "TestJob/plain")?.group).toBeUndefined()
|
|
878
|
+
|
|
879
|
+
// Re-upsert relabels; an unchanged cadence still keeps its next
|
|
880
|
+
// occurrence (the reconciler re-registers on every startup).
|
|
881
|
+
yield* store.upsertSchedule({
|
|
882
|
+
...base,
|
|
883
|
+
key: JobStore.ScheduleKey("TestJob/labeled"),
|
|
884
|
+
group: "svc-c",
|
|
885
|
+
nextRunAt: 999_999
|
|
886
|
+
})
|
|
887
|
+
const relabeled = yield* store.listSchedules({ group: "svc-c" })
|
|
888
|
+
expect(relabeled.map((schedule) => schedule.key)).toEqual(["TestJob/labeled"])
|
|
889
|
+
expect(relabeled[0]?.nextRunAt).toBe(60_000)
|
|
890
|
+
expect(yield* store.listSchedules({ group: "svc-a" })).toEqual([])
|
|
891
|
+
})
|
|
892
|
+
))
|
|
893
|
+
|
|
853
894
|
it.effect("tickSchedule fires a slot exactly once and advances atomically", () =>
|
|
854
895
|
withStore((store) =>
|
|
855
896
|
Effect.gen(function*() {
|
|
@@ -1226,6 +1267,7 @@ export const jobStoreConformance = (
|
|
|
1226
1267
|
backoff: undefined,
|
|
1227
1268
|
keep: undefined,
|
|
1228
1269
|
timeoutMs: undefined,
|
|
1270
|
+
group: undefined,
|
|
1229
1271
|
nextRunAt: 60_000
|
|
1230
1272
|
}
|
|
1231
1273
|
yield* store.upsertSchedule(base)
|
|
@@ -1261,6 +1303,7 @@ export const jobStoreConformance = (
|
|
|
1261
1303
|
backoff: undefined,
|
|
1262
1304
|
keep: undefined,
|
|
1263
1305
|
timeoutMs: undefined,
|
|
1306
|
+
group: undefined,
|
|
1264
1307
|
nextRunAt: 90_000
|
|
1265
1308
|
}
|
|
1266
1309
|
const cronSchedule: JobStore.ScheduleRecord = {
|
|
@@ -1352,6 +1395,7 @@ export const jobStoreConformance = (
|
|
|
1352
1395
|
backoff: undefined,
|
|
1353
1396
|
keep: undefined,
|
|
1354
1397
|
timeoutMs: undefined,
|
|
1398
|
+
group: undefined,
|
|
1355
1399
|
nextRunAt: 60_000
|
|
1356
1400
|
}
|
|
1357
1401
|
yield* store.upsertSchedule(schedule)
|