effect-mq 0.4.2 → 0.6.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 +85 -17
- package/dist/Flow.d.ts +381 -0
- package/dist/Flow.d.ts.map +1 -0
- package/dist/Flow.js +340 -0
- package/dist/Flow.js.map +1 -0
- package/dist/Job.d.ts +37 -6
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +17 -2
- 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 +320 -10
- 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 +336 -8
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Metrics.d.ts +31 -0
- package/dist/Metrics.d.ts.map +1 -1
- package/dist/Metrics.js +39 -0
- package/dist/Metrics.js.map +1 -1
- package/dist/Worker.d.ts +120 -11
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +452 -26
- package/dist/Worker.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +662 -81
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +310 -3
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +68 -1
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +221 -19
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +118 -11
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +497 -28
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts +6 -0
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +765 -1
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Flow.ts +778 -0
- package/src/Job.ts +42 -11
- package/src/JobSchedules.ts +223 -0
- package/src/JobStore.ts +347 -9
- package/src/MemoryJobStore.ts +372 -8
- package/src/Metrics.ts +43 -0
- package/src/Worker.ts +726 -37
- package/src/drizzle-postgres/DrizzleJobStore.ts +827 -82
- package/src/drizzle-postgres/schema.ts +94 -0
- package/src/index.ts +16 -0
- package/src/redis/RedisJobStore.ts +291 -8
- package/src/redis/scripts.ts +529 -26
- package/src/testing/conformance.ts +989 -1
package/src/Job.ts
CHANGED
|
@@ -63,7 +63,7 @@ export {
|
|
|
63
63
|
*/
|
|
64
64
|
unrecoverable
|
|
65
65
|
}
|
|
66
|
-
import { type
|
|
66
|
+
import { type CurrentJob, type RegisterOptions, Worker } from "./Worker.ts"
|
|
67
67
|
|
|
68
68
|
const TypeId = "~effect-mq/Job" as const
|
|
69
69
|
|
|
@@ -308,7 +308,12 @@ export type EnqueueManyOptions = Omit<JobOptions, "delay"> & RunTimeInput & {
|
|
|
308
308
|
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
|
|
311
|
+
/**
|
|
312
|
+
* A definition's `defaults`, normalized to store units.
|
|
313
|
+
*
|
|
314
|
+
* @since 0.6.0
|
|
315
|
+
*/
|
|
316
|
+
export interface ResolvedDefaults {
|
|
312
317
|
readonly delayMs: number
|
|
313
318
|
readonly priority: number
|
|
314
319
|
readonly attempts: number
|
|
@@ -352,6 +357,12 @@ export interface ScheduleOptions<PayloadInput> {
|
|
|
352
357
|
readonly keep?: KeepInput | undefined
|
|
353
358
|
/** Per-run execution time limit for each occurrence. */
|
|
354
359
|
readonly timeout?: Duration.Input | undefined
|
|
360
|
+
/**
|
|
361
|
+
* Ownership label for declarative reconciliation: `JobSchedules.layer`
|
|
362
|
+
* prunes only schedules carrying its own group. Unlabeled schedules are
|
|
363
|
+
* never pruned. Usually set by `JobSchedules`, not by hand.
|
|
364
|
+
*/
|
|
365
|
+
readonly group?: string | undefined
|
|
355
366
|
}
|
|
356
367
|
|
|
357
368
|
/**
|
|
@@ -378,8 +389,12 @@ export interface JobAttempt<A, E> {
|
|
|
378
389
|
readonly attempt: number
|
|
379
390
|
readonly startedAt: number | undefined
|
|
380
391
|
readonly finishedAt: number
|
|
381
|
-
readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
|
|
382
|
-
/**
|
|
392
|
+
readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled" | "fanned-out"
|
|
393
|
+
/**
|
|
394
|
+
* Absent for `stalled`, `cancelled`, and `fanned-out` entries — and for
|
|
395
|
+
* `failed` ones settled store-side without a handler exit (e.g. a
|
|
396
|
+
* fail-fast flow settle).
|
|
397
|
+
*/
|
|
383
398
|
readonly exit: Option.Option<Exit.Exit<A, E>>
|
|
384
399
|
}
|
|
385
400
|
|
|
@@ -548,19 +563,20 @@ export interface Job<
|
|
|
548
563
|
|
|
549
564
|
/**
|
|
550
565
|
* Attach the handler that processes this job, as a layer to provide on top
|
|
551
|
-
* of `Worker.layer` (bound to the same store).
|
|
566
|
+
* of `Worker.layer` (bound to the same store). The handler reads the
|
|
567
|
+
* running attempt from the `Worker.CurrentJob` service — the worker
|
|
568
|
+
* provides it per run, so it never appears in the layer's requirements.
|
|
552
569
|
*/
|
|
553
570
|
readonly toLayer: <R>(
|
|
554
571
|
handler: (
|
|
555
|
-
payload: Payload["Type"]
|
|
556
|
-
context: JobContext
|
|
572
|
+
payload: Payload["Type"]
|
|
557
573
|
) => Effect.Effect<Success["Type"], Error["Type"], R>,
|
|
558
574
|
options?: RegisterOptions | undefined
|
|
559
575
|
) => Layer.Layer<
|
|
560
576
|
never,
|
|
561
577
|
never,
|
|
562
578
|
| Worker
|
|
563
|
-
| R
|
|
579
|
+
| Exclude<R, CurrentJob>
|
|
564
580
|
| Payload["DecodingServices"]
|
|
565
581
|
| Success["EncodingServices"]
|
|
566
582
|
| Error["EncodingServices"]
|
|
@@ -583,7 +599,13 @@ const defaultPollSchedule = Schedule.min([
|
|
|
583
599
|
Schedule.spaced("1 second")
|
|
584
600
|
])
|
|
585
601
|
|
|
586
|
-
|
|
602
|
+
/**
|
|
603
|
+
* Normalize a user-facing `BackoffInput` to the persisted policy. Shared
|
|
604
|
+
* with the flow runtime's child-spec builder.
|
|
605
|
+
*
|
|
606
|
+
* @internal
|
|
607
|
+
*/
|
|
608
|
+
export const normalizeBackoff = (input: BackoffInput | undefined): BackoffPolicy | undefined =>
|
|
587
609
|
input === undefined ? undefined : {
|
|
588
610
|
_tag: input.type,
|
|
589
611
|
delayMs: Duration.toMillis(input.delay),
|
|
@@ -595,7 +617,13 @@ const normalizeKeepState = (input: KeepStateInput): KeepStatePolicy => ({
|
|
|
595
617
|
ageMs: input.age !== undefined ? Duration.toMillis(input.age) : undefined
|
|
596
618
|
})
|
|
597
619
|
|
|
598
|
-
|
|
620
|
+
/**
|
|
621
|
+
* Normalize a user-facing `KeepInput` to the persisted policy. Shared with
|
|
622
|
+
* the flow runtime's child-spec builder.
|
|
623
|
+
*
|
|
624
|
+
* @internal
|
|
625
|
+
*/
|
|
626
|
+
export const normalizeKeep = (input: KeepInput | undefined): KeepPolicy | undefined => {
|
|
599
627
|
if (input === undefined) return undefined
|
|
600
628
|
const split = "completed" in input || "failed" in input || "cancelled" in input
|
|
601
629
|
const flat = "count" in input || "age" in input
|
|
@@ -691,6 +719,7 @@ const Proto = {
|
|
|
691
719
|
trace: capturedSpan === undefined
|
|
692
720
|
? undefined
|
|
693
721
|
: { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
|
|
722
|
+
parent: undefined,
|
|
694
723
|
delayMs: resolvedDelayMs
|
|
695
724
|
}))
|
|
696
725
|
),
|
|
@@ -773,6 +802,7 @@ const Proto = {
|
|
|
773
802
|
trace: capturedSpan === undefined
|
|
774
803
|
? undefined
|
|
775
804
|
: { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
|
|
805
|
+
parent: undefined,
|
|
776
806
|
delayMs: resolvedDelayMs
|
|
777
807
|
})))
|
|
778
808
|
)
|
|
@@ -984,6 +1014,7 @@ const Proto = {
|
|
|
984
1014
|
timeoutMs: options.timeout !== undefined
|
|
985
1015
|
? Duration.toMillis(options.timeout)
|
|
986
1016
|
: self.defaults.timeoutMs,
|
|
1017
|
+
group: options.group,
|
|
987
1018
|
nextRunAt
|
|
988
1019
|
}
|
|
989
1020
|
const store = yield* self.store
|
|
@@ -1003,7 +1034,7 @@ const Proto = {
|
|
|
1003
1034
|
|
|
1004
1035
|
toLayer(
|
|
1005
1036
|
this: AnyWithProps,
|
|
1006
|
-
handler: (payload: any
|
|
1037
|
+
handler: (payload: any) => Effect.Effect<any, any, any>,
|
|
1007
1038
|
options?: RegisterOptions
|
|
1008
1039
|
) {
|
|
1009
1040
|
return Layer.effectDiscard(
|
|
@@ -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
|