effect-mq 0.1.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/LICENSE +21 -0
- package/README.md +235 -0
- package/dist/Job.d.ts +222 -0
- package/dist/Job.d.ts.map +1 -0
- package/dist/Job.js +218 -0
- package/dist/Job.js.map +1 -0
- package/dist/JobStore.d.ts +401 -0
- package/dist/JobStore.d.ts.map +1 -0
- package/dist/JobStore.js +89 -0
- package/dist/JobStore.js.map +1 -0
- package/dist/MemoryJobStore.d.ts +34 -0
- package/dist/MemoryJobStore.d.ts.map +1 -0
- package/dist/MemoryJobStore.js +381 -0
- package/dist/MemoryJobStore.js.map +1 -0
- package/dist/Worker.d.ts +127 -0
- package/dist/Worker.d.ts.map +1 -0
- package/dist/Worker.js +274 -0
- package/dist/Worker.js.map +1 -0
- package/dist/drizzle/DrizzleJobStore.d.ts +59 -0
- package/dist/drizzle/DrizzleJobStore.d.ts.map +1 -0
- package/dist/drizzle/DrizzleJobStore.js +426 -0
- package/dist/drizzle/DrizzleJobStore.js.map +1 -0
- package/dist/drizzle/index.d.ts +19 -0
- package/dist/drizzle/index.d.ts.map +1 -0
- package/dist/drizzle/index.js +19 -0
- package/dist/drizzle/index.js.map +1 -0
- package/dist/drizzle/schema.d.ts +464 -0
- package/dist/drizzle/schema.d.ts.map +1 -0
- package/dist/drizzle/schema.js +68 -0
- package/dist/drizzle/schema.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/testing/conformance.d.ts +27 -0
- package/dist/testing/conformance.d.ts.map +1 -0
- package/dist/testing/conformance.js +451 -0
- package/dist/testing/conformance.js.map +1 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +8 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +71 -0
- package/src/Job.ts +606 -0
- package/src/JobStore.ts +446 -0
- package/src/MemoryJobStore.ts +467 -0
- package/src/Worker.ts +514 -0
- package/src/drizzle/DrizzleJobStore.ts +599 -0
- package/src/drizzle/index.ts +20 -0
- package/src/drizzle/schema.ts +116 -0
- package/src/index.ts +33 -0
- package/src/testing/conformance.ts +654 -0
- package/src/testing/index.ts +7 -0
package/src/Job.ts
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-first background job definitions.
|
|
3
|
+
*
|
|
4
|
+
* A `Job` is defined once (name, payload/success/error schemas, defaults) and
|
|
5
|
+
* used from both sides:
|
|
6
|
+
*
|
|
7
|
+
* - producers call `MyJob.enqueue(payload, options)` (requires the job's store)
|
|
8
|
+
* - runners provide `MyJob.toLayer(handler)` on top of a `Worker.layer`
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { Job, JobStore } from "effect-mq"
|
|
12
|
+
* import { Effect, Schema } from "effect"
|
|
13
|
+
*
|
|
14
|
+
* const Durable = JobStore.named("durable")
|
|
15
|
+
*
|
|
16
|
+
* class SendEmail extends Job.make("SendEmail", {
|
|
17
|
+
* payload: { to: Schema.String, body: Schema.String },
|
|
18
|
+
* queue: "email",
|
|
19
|
+
* store: Durable,
|
|
20
|
+
* metadata: ({ to }) => ({ to }),
|
|
21
|
+
* defaults: { attempts: 5, backoff: { type: "exponential", delay: "1 second" } }
|
|
22
|
+
* }) {}
|
|
23
|
+
*
|
|
24
|
+
* // producer — requires the Durable store in context, enforced at compile time
|
|
25
|
+
* const jobId = yield* SendEmail.enqueue({ to: "a@b.c", body: "hi" }, { delay: "5 seconds" })
|
|
26
|
+
*
|
|
27
|
+
* // runner
|
|
28
|
+
* const SendEmailWorker = SendEmail.toLayer((payload) => Effect.log(`sending to ${payload.to}`))
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @since 0.1.0
|
|
32
|
+
*/
|
|
33
|
+
import { type Context, Duration, Effect, type Exit, Layer, Option, Schedule, Schema } from "effect"
|
|
34
|
+
import {
|
|
35
|
+
type BackoffPolicy,
|
|
36
|
+
JobId,
|
|
37
|
+
JobNotFoundError,
|
|
38
|
+
type JobNotRetryableError,
|
|
39
|
+
type JobState,
|
|
40
|
+
JobStore,
|
|
41
|
+
type KeepPolicy,
|
|
42
|
+
QueueName,
|
|
43
|
+
type Service as StoreService
|
|
44
|
+
} from "./JobStore.ts"
|
|
45
|
+
import { type JobContext, type RegisterOptions, Worker } from "./Worker.ts"
|
|
46
|
+
|
|
47
|
+
const TypeId = "~effect-mq/Job" as const
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A struct schema (or anything with struct fields).
|
|
51
|
+
*
|
|
52
|
+
* @since 0.1.0
|
|
53
|
+
*/
|
|
54
|
+
export interface AnyStructSchema extends Schema.Top {
|
|
55
|
+
readonly fields: Schema.Struct.Fields
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* User-facing backoff configuration.
|
|
60
|
+
*
|
|
61
|
+
* @since 0.1.0
|
|
62
|
+
*/
|
|
63
|
+
export interface BackoffInput {
|
|
64
|
+
readonly type: "fixed" | "exponential"
|
|
65
|
+
readonly delay: Duration.Input
|
|
66
|
+
/** Exponential growth factor (default 2). */
|
|
67
|
+
readonly factor?: number | undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* User-facing retention configuration for terminal jobs.
|
|
72
|
+
*
|
|
73
|
+
* @since 0.1.0
|
|
74
|
+
*/
|
|
75
|
+
export interface KeepInput {
|
|
76
|
+
/** Keep at most this many terminal records (per name + state). */
|
|
77
|
+
readonly count?: number | undefined
|
|
78
|
+
/** Remove terminal records older than this. */
|
|
79
|
+
readonly age?: Duration.Input | undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Options shared between job defaults and per-enqueue overrides.
|
|
84
|
+
*
|
|
85
|
+
* @since 0.1.0
|
|
86
|
+
*/
|
|
87
|
+
export interface JobOptions {
|
|
88
|
+
/** Do not run before this long from enqueue time. */
|
|
89
|
+
readonly delay?: Duration.Input | undefined
|
|
90
|
+
/** Higher runs first; ties are FIFO. Default 0. */
|
|
91
|
+
readonly priority?: number | undefined
|
|
92
|
+
/** Total attempts including the first run. Default 1 (no retries). */
|
|
93
|
+
readonly attempts?: number | undefined
|
|
94
|
+
readonly backoff?: BackoffInput | undefined
|
|
95
|
+
/** Retention for terminal records. Default: keep forever. */
|
|
96
|
+
readonly keep?: KeepInput | undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @since 0.1.0
|
|
101
|
+
*/
|
|
102
|
+
export interface EnqueueOptions extends JobOptions {
|
|
103
|
+
/**
|
|
104
|
+
* Explicit job id. Enqueueing an id that already exists is a no-op that
|
|
105
|
+
* returns the existing id (idempotency). Overrides the definition's
|
|
106
|
+
* `idempotencyKey`.
|
|
107
|
+
*/
|
|
108
|
+
readonly jobId?: string | undefined
|
|
109
|
+
/** Send to a different queue than the definition's. */
|
|
110
|
+
readonly queue?: string | undefined
|
|
111
|
+
/** Queryable business context, merged over the definition's `metadata`. */
|
|
112
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface ResolvedDefaults {
|
|
116
|
+
readonly delayMs: number
|
|
117
|
+
readonly priority: number
|
|
118
|
+
readonly attempts: number
|
|
119
|
+
readonly backoff: BackoffPolicy | undefined
|
|
120
|
+
readonly keep: KeepPolicy | undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The status of a job as seen by `poll`. Fetch the full run ledger with
|
|
125
|
+
* `Job.attempts`.
|
|
126
|
+
*
|
|
127
|
+
* @since 0.1.0
|
|
128
|
+
*/
|
|
129
|
+
export interface JobStatus<A, E> {
|
|
130
|
+
readonly state: JobState
|
|
131
|
+
readonly attemptsMade: number
|
|
132
|
+
readonly metadata: Readonly<Record<string, string>>
|
|
133
|
+
/** Present for completed/failed jobs (except store-side failures like stalling). */
|
|
134
|
+
readonly exit: Option.Option<Exit.Exit<A, E>>
|
|
135
|
+
readonly failedReason: string | undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One decoded entry of a job's run ledger.
|
|
140
|
+
*
|
|
141
|
+
* @since 0.1.0
|
|
142
|
+
*/
|
|
143
|
+
export interface JobAttempt<A, E> {
|
|
144
|
+
readonly attempt: number
|
|
145
|
+
readonly startedAt: number | undefined
|
|
146
|
+
readonly finishedAt: number
|
|
147
|
+
readonly outcome: "completed" | "retried" | "failed" | "stalled"
|
|
148
|
+
/** Absent for `stalled` entries. */
|
|
149
|
+
readonly exit: Option.Option<Exit.Exit<A, E>>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @since 0.1.0
|
|
154
|
+
*/
|
|
155
|
+
export interface Job<
|
|
156
|
+
Name extends string,
|
|
157
|
+
Payload extends AnyStructSchema,
|
|
158
|
+
Success extends Schema.Top,
|
|
159
|
+
Error extends Schema.Top,
|
|
160
|
+
StoreId = JobStore
|
|
161
|
+
> {
|
|
162
|
+
new(_: never): {}
|
|
163
|
+
|
|
164
|
+
readonly [TypeId]: typeof TypeId
|
|
165
|
+
/**
|
|
166
|
+
* The job's unique name. (Named `_tag` rather than `name` because a
|
|
167
|
+
* `class X extends Job.make(...) {}` subclass shadows `Function.name`.)
|
|
168
|
+
*/
|
|
169
|
+
readonly _tag: Name
|
|
170
|
+
readonly queue: QueueName
|
|
171
|
+
/** The store this job's runs live on. */
|
|
172
|
+
readonly store: Context.Key<StoreId, StoreService>
|
|
173
|
+
readonly payloadSchema: Payload
|
|
174
|
+
readonly successSchema: Success
|
|
175
|
+
readonly errorSchema: Error
|
|
176
|
+
/** JSON codec for the payload — what is actually persisted. */
|
|
177
|
+
readonly payloadJsonSchema: Schema.toCodecJson<Payload>
|
|
178
|
+
/** JSON codec for handler exits — what is actually persisted. */
|
|
179
|
+
readonly exitSchema: Schema.toCodecJson<
|
|
180
|
+
Schema.Exit<
|
|
181
|
+
Schema.toCodecJson<Success>,
|
|
182
|
+
Schema.toCodecJson<Error>,
|
|
183
|
+
Schema.Defect
|
|
184
|
+
>
|
|
185
|
+
>
|
|
186
|
+
readonly idempotencyKey: ((payload: Payload["Type"]) => string) | undefined
|
|
187
|
+
readonly metadata: ((payload: Payload["Type"]) => Readonly<Record<string, string>>) | undefined
|
|
188
|
+
readonly defaults: ResolvedDefaults
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Queue this job. Returns the job id. Duplicate ids (via `jobId` or
|
|
192
|
+
* `idempotencyKey`) are a silent no-op returning the existing id.
|
|
193
|
+
*/
|
|
194
|
+
readonly enqueue: (
|
|
195
|
+
payload: Payload["~type.make.in"],
|
|
196
|
+
options?: EnqueueOptions | undefined
|
|
197
|
+
) => Effect.Effect<JobId, never, StoreId | Payload["EncodingServices"]>
|
|
198
|
+
|
|
199
|
+
/** Read the current status of a previously enqueued job. */
|
|
200
|
+
readonly poll: (
|
|
201
|
+
jobId: JobId
|
|
202
|
+
) => Effect.Effect<
|
|
203
|
+
Option.Option<JobStatus<Success["Type"], Error["Type"]>>,
|
|
204
|
+
never,
|
|
205
|
+
StoreId | Success["DecodingServices"] | Error["DecodingServices"]
|
|
206
|
+
>
|
|
207
|
+
|
|
208
|
+
/** The job's decoded run ledger, oldest first. */
|
|
209
|
+
readonly attempts: (
|
|
210
|
+
jobId: JobId
|
|
211
|
+
) => Effect.Effect<
|
|
212
|
+
ReadonlyArray<JobAttempt<Success["Type"], Error["Type"]>>,
|
|
213
|
+
never,
|
|
214
|
+
StoreId | Success["DecodingServices"] | Error["DecodingServices"]
|
|
215
|
+
>
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Wait (by polling) until the job finishes, then return its result. Dies
|
|
219
|
+
* if the job id does not exist or the job was failed by the store itself.
|
|
220
|
+
*/
|
|
221
|
+
readonly awaitResult: (
|
|
222
|
+
jobId: JobId,
|
|
223
|
+
options?: { readonly pollSchedule?: Schedule.Schedule<unknown> | undefined } | undefined
|
|
224
|
+
) => Effect.Effect<
|
|
225
|
+
Success["Type"],
|
|
226
|
+
Error["Type"],
|
|
227
|
+
StoreId | Success["DecodingServices"] | Error["DecodingServices"]
|
|
228
|
+
>
|
|
229
|
+
|
|
230
|
+
/** `enqueue` + `awaitResult` in one call. */
|
|
231
|
+
readonly execute: (
|
|
232
|
+
payload: Payload["~type.make.in"],
|
|
233
|
+
options?: EnqueueOptions | undefined
|
|
234
|
+
) => Effect.Effect<
|
|
235
|
+
Success["Type"],
|
|
236
|
+
Error["Type"],
|
|
237
|
+
| StoreId
|
|
238
|
+
| Payload["EncodingServices"]
|
|
239
|
+
| Success["DecodingServices"]
|
|
240
|
+
| Error["DecodingServices"]
|
|
241
|
+
>
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Re-run a failed job with a fresh attempt budget. The run ledger is
|
|
245
|
+
* preserved. (The admin op behind a dashboard's "retry" button.)
|
|
246
|
+
*/
|
|
247
|
+
readonly retry: (
|
|
248
|
+
jobId: JobId
|
|
249
|
+
) => Effect.Effect<void, JobNotFoundError | JobNotRetryableError, StoreId>
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Attach the handler that processes this job, as a layer to provide on top
|
|
253
|
+
* of `Worker.layer` (bound to the same store).
|
|
254
|
+
*/
|
|
255
|
+
readonly toLayer: <R>(
|
|
256
|
+
handler: (
|
|
257
|
+
payload: Payload["Type"],
|
|
258
|
+
context: JobContext
|
|
259
|
+
) => Effect.Effect<Success["Type"], Error["Type"], R>,
|
|
260
|
+
options?: RegisterOptions | undefined
|
|
261
|
+
) => Layer.Layer<
|
|
262
|
+
never,
|
|
263
|
+
never,
|
|
264
|
+
| Worker
|
|
265
|
+
| R
|
|
266
|
+
| Payload["DecodingServices"]
|
|
267
|
+
| Success["EncodingServices"]
|
|
268
|
+
| Error["EncodingServices"]
|
|
269
|
+
>
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @since 0.1.0
|
|
274
|
+
*/
|
|
275
|
+
export interface Any {
|
|
276
|
+
readonly [TypeId]: typeof TypeId
|
|
277
|
+
readonly _tag: string
|
|
278
|
+
readonly queue: QueueName
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
interface AnyWithProps extends Job<string, AnyStructSchema, Schema.Top, Schema.Top, any> {}
|
|
282
|
+
|
|
283
|
+
const defaultPollSchedule = Schedule.min([
|
|
284
|
+
Schedule.exponential(10, 2),
|
|
285
|
+
Schedule.spaced("1 second")
|
|
286
|
+
])
|
|
287
|
+
|
|
288
|
+
const normalizeBackoff = (input: BackoffInput | undefined): BackoffPolicy | undefined =>
|
|
289
|
+
input === undefined ? undefined : {
|
|
290
|
+
_tag: input.type,
|
|
291
|
+
delayMs: Duration.toMillis(input.delay),
|
|
292
|
+
factor: input.factor
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const normalizeKeep = (input: KeepInput | undefined): KeepPolicy | undefined =>
|
|
296
|
+
input === undefined ? undefined : {
|
|
297
|
+
count: input.count,
|
|
298
|
+
ageMs: input.age !== undefined ? Duration.toMillis(input.age) : undefined
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const Proto = {
|
|
302
|
+
[TypeId]: TypeId,
|
|
303
|
+
|
|
304
|
+
enqueue(this: AnyWithProps, fields: any, options?: EnqueueOptions) {
|
|
305
|
+
return Effect.suspend(() => {
|
|
306
|
+
const payload = this.payloadSchema.make(fields)
|
|
307
|
+
const id = options?.jobId !== undefined
|
|
308
|
+
? JobId(options.jobId)
|
|
309
|
+
: this.idempotencyKey !== undefined
|
|
310
|
+
? JobId(`${this._tag}/${this.idempotencyKey(payload)}`)
|
|
311
|
+
: undefined
|
|
312
|
+
const metadata = {
|
|
313
|
+
...this.metadata?.(payload),
|
|
314
|
+
...options?.metadata
|
|
315
|
+
}
|
|
316
|
+
return Schema.encodeEffect(this.payloadJsonSchema)(payload).pipe(
|
|
317
|
+
Effect.orDie,
|
|
318
|
+
Effect.flatMap((encoded) =>
|
|
319
|
+
Effect.flatMap(this.store, (store) =>
|
|
320
|
+
store.enqueue({
|
|
321
|
+
id,
|
|
322
|
+
name: this._tag,
|
|
323
|
+
queue: options?.queue !== undefined
|
|
324
|
+
? QueueName(options.queue)
|
|
325
|
+
: this.queue,
|
|
326
|
+
payload: encoded,
|
|
327
|
+
metadata,
|
|
328
|
+
priority: options?.priority ?? this.defaults.priority,
|
|
329
|
+
attemptsMax: Math.max(1, options?.attempts ?? this.defaults.attempts),
|
|
330
|
+
backoff: options?.backoff !== undefined
|
|
331
|
+
? normalizeBackoff(options.backoff)
|
|
332
|
+
: this.defaults.backoff,
|
|
333
|
+
keep: options?.keep !== undefined
|
|
334
|
+
? normalizeKeep(options.keep)
|
|
335
|
+
: this.defaults.keep,
|
|
336
|
+
delayMs: options?.delay !== undefined
|
|
337
|
+
? Duration.toMillis(options.delay)
|
|
338
|
+
: this.defaults.delayMs
|
|
339
|
+
}))
|
|
340
|
+
),
|
|
341
|
+
Effect.orDie,
|
|
342
|
+
Effect.map((result) => result.id)
|
|
343
|
+
)
|
|
344
|
+
}).pipe(
|
|
345
|
+
Effect.withSpan(`${this._tag}.enqueue`, {}, { captureStackTrace: false })
|
|
346
|
+
)
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
poll(this: AnyWithProps, jobId: JobId) {
|
|
350
|
+
const self = this
|
|
351
|
+
return Effect.flatMap(this.store, (store) =>
|
|
352
|
+
store.getJob(jobId).pipe(
|
|
353
|
+
Effect.orDie,
|
|
354
|
+
Effect.flatMap(Option.match({
|
|
355
|
+
onNone: () => Effect.succeedNone,
|
|
356
|
+
onSome: (record) =>
|
|
357
|
+
Effect.gen(function*() {
|
|
358
|
+
const exit = record.exit === undefined
|
|
359
|
+
? Option.none()
|
|
360
|
+
: Option.some(
|
|
361
|
+
yield* Schema.decodeUnknownEffect(self.exitSchema)(record.exit).pipe(
|
|
362
|
+
Effect.orDie
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
return Option.some({
|
|
366
|
+
state: record.state,
|
|
367
|
+
attemptsMade: record.attemptsMade,
|
|
368
|
+
metadata: record.metadata,
|
|
369
|
+
exit,
|
|
370
|
+
failedReason: record.failedReason
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
}))
|
|
374
|
+
)).pipe(
|
|
375
|
+
Effect.withSpan(`${this._tag}.poll`, { attributes: { jobId } }, { captureStackTrace: false })
|
|
376
|
+
)
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
attempts(this: AnyWithProps, jobId: JobId) {
|
|
380
|
+
const self = this
|
|
381
|
+
return Effect.flatMap(this.store, (store) =>
|
|
382
|
+
store.getAttempts(jobId).pipe(
|
|
383
|
+
Effect.orDie,
|
|
384
|
+
Effect.flatMap(Effect.forEach((attempt) =>
|
|
385
|
+
Effect.map(
|
|
386
|
+
attempt.exit === undefined
|
|
387
|
+
? Effect.succeedNone
|
|
388
|
+
: Schema.decodeUnknownEffect(self.exitSchema)(attempt.exit).pipe(
|
|
389
|
+
Effect.orDie,
|
|
390
|
+
Effect.map(Option.some)
|
|
391
|
+
),
|
|
392
|
+
(exit) => ({
|
|
393
|
+
attempt: attempt.attempt,
|
|
394
|
+
startedAt: attempt.startedAt,
|
|
395
|
+
finishedAt: attempt.finishedAt,
|
|
396
|
+
outcome: attempt.outcome,
|
|
397
|
+
exit
|
|
398
|
+
})
|
|
399
|
+
)
|
|
400
|
+
))
|
|
401
|
+
)).pipe(
|
|
402
|
+
Effect.withSpan(`${this._tag}.attempts`, { attributes: { jobId } }, { captureStackTrace: false })
|
|
403
|
+
)
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
awaitResult(
|
|
407
|
+
this: AnyWithProps,
|
|
408
|
+
jobId: JobId,
|
|
409
|
+
options?: { readonly pollSchedule?: Schedule.Schedule<unknown> | undefined }
|
|
410
|
+
) {
|
|
411
|
+
const self = this
|
|
412
|
+
return Effect.gen(function*() {
|
|
413
|
+
const schedule = options?.pollSchedule ?? defaultPollSchedule
|
|
414
|
+
let sleep: Effect.Effect<unknown> | undefined
|
|
415
|
+
while (true) {
|
|
416
|
+
const status = yield* self.poll(jobId)
|
|
417
|
+
if (Option.isNone(status)) {
|
|
418
|
+
return yield* Effect.die(new JobNotFoundError({ jobId }))
|
|
419
|
+
}
|
|
420
|
+
const { exit, failedReason, state } = status.value
|
|
421
|
+
if (state === "completed" || state === "failed") {
|
|
422
|
+
if (Option.isSome(exit)) {
|
|
423
|
+
return yield* exit.value
|
|
424
|
+
}
|
|
425
|
+
return yield* Effect.die(
|
|
426
|
+
new Error(
|
|
427
|
+
`effect-mq: job "${jobId}" failed without a result${
|
|
428
|
+
failedReason === undefined ? "" : `: ${failedReason}`
|
|
429
|
+
}`
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
sleep ??= (yield* Schedule.toStepWithSleep(schedule))(void 0).pipe(
|
|
434
|
+
Effect.catch(() =>
|
|
435
|
+
Effect.die(`${self._tag}.awaitResult: poll schedule exhausted`)
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
yield* sleep
|
|
439
|
+
}
|
|
440
|
+
}).pipe(
|
|
441
|
+
Effect.withSpan(`${this._tag}.awaitResult`, { attributes: { jobId } }, { captureStackTrace: false })
|
|
442
|
+
)
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
execute(this: AnyWithProps, fields: any, options?: EnqueueOptions) {
|
|
446
|
+
return Effect.flatMap(
|
|
447
|
+
this.enqueue(fields, options),
|
|
448
|
+
(jobId) => this.awaitResult(jobId)
|
|
449
|
+
)
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
retry(this: AnyWithProps, jobId: JobId) {
|
|
453
|
+
return Effect.flatMap(this.store, (store) =>
|
|
454
|
+
store.retry(jobId).pipe(
|
|
455
|
+
Effect.catchTag("JobStoreError", (error) => Effect.die(error))
|
|
456
|
+
)).pipe(
|
|
457
|
+
Effect.withSpan(`${this._tag}.retry`, { attributes: { jobId } }, { captureStackTrace: false })
|
|
458
|
+
)
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
toLayer(
|
|
462
|
+
this: AnyWithProps,
|
|
463
|
+
handler: (payload: any, context: JobContext) => Effect.Effect<any, any, any>,
|
|
464
|
+
options?: RegisterOptions
|
|
465
|
+
) {
|
|
466
|
+
return Layer.effectDiscard(
|
|
467
|
+
Effect.flatMap(Worker, (worker) => worker.register(this, handler, options))
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const boundMethods = [
|
|
473
|
+
"enqueue",
|
|
474
|
+
"poll",
|
|
475
|
+
"attempts",
|
|
476
|
+
"awaitResult",
|
|
477
|
+
"execute",
|
|
478
|
+
"retry",
|
|
479
|
+
"toLayer"
|
|
480
|
+
] as const
|
|
481
|
+
|
|
482
|
+
const makeProto = (options: {
|
|
483
|
+
readonly _tag: string
|
|
484
|
+
readonly queue: QueueName
|
|
485
|
+
readonly store: Context.Key<any, StoreService>
|
|
486
|
+
readonly payloadSchema: Schema.Top
|
|
487
|
+
readonly payloadJsonSchema: Schema.Top
|
|
488
|
+
readonly successSchema: Schema.Top
|
|
489
|
+
readonly errorSchema: Schema.Top
|
|
490
|
+
readonly exitSchema: Schema.Top
|
|
491
|
+
readonly idempotencyKey: ((payload: any) => string) | undefined
|
|
492
|
+
readonly metadata: ((payload: any) => Readonly<Record<string, string>>) | undefined
|
|
493
|
+
readonly defaults: ResolvedDefaults
|
|
494
|
+
}): any => {
|
|
495
|
+
function JobDefinition() {}
|
|
496
|
+
Object.setPrototypeOf(JobDefinition, Proto)
|
|
497
|
+
Object.assign(JobDefinition, options)
|
|
498
|
+
// Cosmetic only (job identity is `_tag`): function `name` is read-only for
|
|
499
|
+
// assignment but configurable.
|
|
500
|
+
Object.defineProperty(JobDefinition, "name", { value: options._tag, configurable: true })
|
|
501
|
+
// Bind the API as own properties so methods survive destructuring
|
|
502
|
+
// (`const { enqueue } = MyJob`) and passing as values.
|
|
503
|
+
for (const key of boundMethods) {
|
|
504
|
+
// SAFETY: every entry in `boundMethods` is a `this`-dependent function on
|
|
505
|
+
// `Proto`; binding only fixes the receiver. The precise signatures are
|
|
506
|
+
// re-declared by the public `Job` interface.
|
|
507
|
+
const method = Proto[key] as (...args: ReadonlyArray<never>) => object
|
|
508
|
+
Object.defineProperty(JobDefinition, key, {
|
|
509
|
+
value: method.bind(JobDefinition),
|
|
510
|
+
configurable: true
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
return JobDefinition
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Define a job.
|
|
518
|
+
*
|
|
519
|
+
* @since 0.1.0
|
|
520
|
+
*/
|
|
521
|
+
export const make = <
|
|
522
|
+
const Name extends string,
|
|
523
|
+
Payload extends Schema.Struct.Fields | AnyStructSchema,
|
|
524
|
+
Success extends Schema.Top = Schema.Void,
|
|
525
|
+
Error extends Schema.Top = Schema.Never,
|
|
526
|
+
StoreId = JobStore
|
|
527
|
+
>(
|
|
528
|
+
name: Name,
|
|
529
|
+
options: {
|
|
530
|
+
readonly payload: Payload
|
|
531
|
+
readonly success?: Success | undefined
|
|
532
|
+
readonly error?: Error | undefined
|
|
533
|
+
/**
|
|
534
|
+
* Derive a stable job id from the payload. Enqueueing the same key twice
|
|
535
|
+
* while the first job still exists is a no-op (returns the existing id).
|
|
536
|
+
*/
|
|
537
|
+
readonly idempotencyKey?:
|
|
538
|
+
| ((
|
|
539
|
+
payload: Payload extends Schema.Struct.Fields ? Schema.Struct.Type<Payload>
|
|
540
|
+
: Payload["Type"]
|
|
541
|
+
) => string)
|
|
542
|
+
| undefined
|
|
543
|
+
/**
|
|
544
|
+
* Derive queryable business context from the payload (flat string map, so
|
|
545
|
+
* every driver can index it). Merged with per-enqueue `metadata`.
|
|
546
|
+
*/
|
|
547
|
+
readonly metadata?:
|
|
548
|
+
| ((
|
|
549
|
+
payload: Payload extends Schema.Struct.Fields ? Schema.Struct.Type<Payload>
|
|
550
|
+
: Payload["Type"]
|
|
551
|
+
) => Readonly<Record<string, string>>)
|
|
552
|
+
| undefined
|
|
553
|
+
/** The queue this job runs on. Default `"default"`. */
|
|
554
|
+
readonly queue?: string | undefined
|
|
555
|
+
/**
|
|
556
|
+
* The store this job's runs live on (a `JobStore.named(...)` key).
|
|
557
|
+
* Default: the default `JobStore`.
|
|
558
|
+
*/
|
|
559
|
+
readonly store?: Context.Key<StoreId, StoreService> | undefined
|
|
560
|
+
readonly defaults?: JobOptions | undefined
|
|
561
|
+
}
|
|
562
|
+
): Job<
|
|
563
|
+
Name,
|
|
564
|
+
Payload extends Schema.Struct.Fields ? Schema.Struct<Payload> : Payload,
|
|
565
|
+
Success,
|
|
566
|
+
Error,
|
|
567
|
+
StoreId
|
|
568
|
+
> => {
|
|
569
|
+
// SAFETY: `Schema.isSchema` discriminates the `Payload` union at runtime;
|
|
570
|
+
// TypeScript cannot narrow an unresolved generic, so each branch asserts
|
|
571
|
+
// the side the guard just proved.
|
|
572
|
+
const payloadSchema = Schema.isSchema(options.payload)
|
|
573
|
+
? options.payload as AnyStructSchema
|
|
574
|
+
: Schema.Struct(options.payload as Schema.Struct.Fields)
|
|
575
|
+
const successSchema = options.success ?? Schema.Void
|
|
576
|
+
const errorSchema = options.error ?? Schema.Never
|
|
577
|
+
// Wrapping the whole Exit schema in the JSON codec makes the *encoded* side
|
|
578
|
+
// plain JSON (a live Exit/Cause instance would not survive serializing
|
|
579
|
+
// drivers like Redis/Postgres).
|
|
580
|
+
const exitSchema = Schema.toCodecJson(Schema.Exit(
|
|
581
|
+
Schema.toCodecJson(successSchema),
|
|
582
|
+
Schema.toCodecJson(errorSchema),
|
|
583
|
+
Schema.Defect()
|
|
584
|
+
))
|
|
585
|
+
return makeProto({
|
|
586
|
+
_tag: name,
|
|
587
|
+
queue: QueueName(options.queue ?? "default"),
|
|
588
|
+
store: options.store ?? JobStore,
|
|
589
|
+
payloadSchema,
|
|
590
|
+
payloadJsonSchema: Schema.toCodecJson(payloadSchema),
|
|
591
|
+
successSchema,
|
|
592
|
+
errorSchema,
|
|
593
|
+
exitSchema,
|
|
594
|
+
idempotencyKey: options.idempotencyKey,
|
|
595
|
+
metadata: options.metadata,
|
|
596
|
+
defaults: {
|
|
597
|
+
delayMs: options.defaults?.delay !== undefined
|
|
598
|
+
? Duration.toMillis(options.defaults.delay)
|
|
599
|
+
: 0,
|
|
600
|
+
priority: options.defaults?.priority ?? 0,
|
|
601
|
+
attempts: Math.max(1, options.defaults?.attempts ?? 1),
|
|
602
|
+
backoff: normalizeBackoff(options.defaults?.backoff),
|
|
603
|
+
keep: normalizeKeep(options.defaults?.keep)
|
|
604
|
+
}
|
|
605
|
+
})
|
|
606
|
+
}
|