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/Worker.ts
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker runtime of effect-mq.
|
|
3
|
+
*
|
|
4
|
+
* `Worker` is a service that runs registered job handlers against a
|
|
5
|
+
* `JobStore`. Handlers are registered through `Job.toLayer(handler)`; the
|
|
6
|
+
* worker starts a set of taker fibers per queue (bounded by the queue's
|
|
7
|
+
* concurrency), each running the loop:
|
|
8
|
+
*
|
|
9
|
+
* claim -> decode payload -> run handler -> ack (complete | retry | fail)
|
|
10
|
+
*
|
|
11
|
+
* plus two maintenance fibers: lock renewal for in-flight jobs, and stalled
|
|
12
|
+
* job recovery. On scope close, in-flight handlers are interrupted and their
|
|
13
|
+
* jobs released back to `waiting` without consuming an attempt.
|
|
14
|
+
*
|
|
15
|
+
* @since 0.1.0
|
|
16
|
+
*/
|
|
17
|
+
import { Cause, Clock, Context, Deferred, Duration, Effect, Exit, FiberSet, Layer, Schedule, Schema, type Scope, Scope as Scope_, Tracer } from "effect"
|
|
18
|
+
import {
|
|
19
|
+
type AckOutcome,
|
|
20
|
+
type BackoffPolicy,
|
|
21
|
+
type JobId,
|
|
22
|
+
isJobStoreError,
|
|
23
|
+
type JobNotFoundError,
|
|
24
|
+
type JobRecord,
|
|
25
|
+
JobStore,
|
|
26
|
+
type JobStoreError,
|
|
27
|
+
type LockLostError,
|
|
28
|
+
QueueName,
|
|
29
|
+
type Service as StoreService
|
|
30
|
+
} from "./JobStore.ts"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Information about the currently running attempt, passed to handlers as the
|
|
34
|
+
* second argument.
|
|
35
|
+
*
|
|
36
|
+
* @since 0.1.0
|
|
37
|
+
*/
|
|
38
|
+
export interface JobContext {
|
|
39
|
+
readonly jobId: JobId
|
|
40
|
+
readonly name: string
|
|
41
|
+
readonly queue: QueueName
|
|
42
|
+
/** 1-based attempt number. */
|
|
43
|
+
readonly attempt: number
|
|
44
|
+
/** Total attempts allowed for this job. */
|
|
45
|
+
readonly attemptsMax: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The structural shape of a job definition the worker needs for
|
|
50
|
+
* registration. `Job.Job` satisfies this — the indirection avoids a module
|
|
51
|
+
* cycle between `Job` and `Worker`.
|
|
52
|
+
*
|
|
53
|
+
* @since 0.1.0
|
|
54
|
+
*/
|
|
55
|
+
export interface JobDescriptor<
|
|
56
|
+
Payload extends Schema.Top,
|
|
57
|
+
Success extends Schema.Top,
|
|
58
|
+
Error extends Schema.Top
|
|
59
|
+
> {
|
|
60
|
+
readonly _tag: string
|
|
61
|
+
readonly queue: QueueName
|
|
62
|
+
/** The store this job is bound to; must match the worker's store. */
|
|
63
|
+
readonly store: Context.Key<any, StoreService>
|
|
64
|
+
readonly payloadSchema: Payload
|
|
65
|
+
/** JSON codec for the payload (what is actually stored). */
|
|
66
|
+
readonly payloadJsonSchema: Schema.Top & {
|
|
67
|
+
readonly Type: Payload["Type"]
|
|
68
|
+
}
|
|
69
|
+
/** JSON codec for handler exits (what is actually stored). */
|
|
70
|
+
readonly exitSchema: Schema.Top & {
|
|
71
|
+
readonly Type: Exit.Exit<Success["Type"], Error["Type"]>
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @since 0.1.0
|
|
77
|
+
*/
|
|
78
|
+
export interface RegisterOptions {
|
|
79
|
+
/**
|
|
80
|
+
* Taker fibers for this job's queue. The first registration for a queue
|
|
81
|
+
* decides; later values for the same queue are ignored.
|
|
82
|
+
*/
|
|
83
|
+
readonly concurrency?: number | undefined
|
|
84
|
+
/** Override the queue this handler consumes (defaults to the job's queue). */
|
|
85
|
+
readonly queue?: string | undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @since 0.1.0
|
|
90
|
+
*/
|
|
91
|
+
export interface WorkerOptions<StoreId = JobStore> {
|
|
92
|
+
/**
|
|
93
|
+
* The store this worker claims from (a `JobStore.named(...)` key). Default:
|
|
94
|
+
* the default `JobStore`. To run workers for several stores in one process,
|
|
95
|
+
* provide each `Worker.layer({ store })` *locally* to its handler group via
|
|
96
|
+
* `Layer.provide` (not `provideMerge`).
|
|
97
|
+
*/
|
|
98
|
+
readonly store?: Context.Key<StoreId, StoreService> | undefined
|
|
99
|
+
/** Default taker fibers per queue (default 1). */
|
|
100
|
+
readonly concurrency?: number | undefined
|
|
101
|
+
/** Per-queue configuration, keyed by queue name. */
|
|
102
|
+
readonly queues?: Readonly<Record<string, { readonly concurrency?: number | undefined }>> | undefined
|
|
103
|
+
/** How long a claim's lock lasts before the job counts as stalled (default 30s). */
|
|
104
|
+
readonly lockDuration?: Duration.Input | undefined
|
|
105
|
+
/** Lock heartbeat interval (default half of `lockDuration`). */
|
|
106
|
+
readonly lockRenewInterval?: Duration.Input | undefined
|
|
107
|
+
/** How often to sweep for stalled jobs (default 30s). */
|
|
108
|
+
readonly stalledInterval?: Duration.Input | undefined
|
|
109
|
+
/** Stalls tolerated before a job is failed outright (default 1). */
|
|
110
|
+
readonly maxStalledCount?: number | undefined
|
|
111
|
+
/** Fallback polling interval when idle and no wake-up arrives (default 5s). */
|
|
112
|
+
readonly pollInterval?: Duration.Input | undefined
|
|
113
|
+
/** Identifier used in lock tokens (default: random). */
|
|
114
|
+
readonly id?: string | undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @since 0.1.0
|
|
119
|
+
*/
|
|
120
|
+
export class Worker extends Context.Service<Worker, {
|
|
121
|
+
readonly register: <
|
|
122
|
+
Payload extends Schema.Top,
|
|
123
|
+
Success extends Schema.Top,
|
|
124
|
+
Error extends Schema.Top,
|
|
125
|
+
R
|
|
126
|
+
>(
|
|
127
|
+
job: JobDescriptor<Payload, Success, Error>,
|
|
128
|
+
handler: (
|
|
129
|
+
payload: Payload["Type"],
|
|
130
|
+
context: JobContext
|
|
131
|
+
) => Effect.Effect<Success["Type"], Error["Type"], R>,
|
|
132
|
+
options?: RegisterOptions | undefined
|
|
133
|
+
) => Effect.Effect<
|
|
134
|
+
void,
|
|
135
|
+
never,
|
|
136
|
+
| Scope.Scope
|
|
137
|
+
| R
|
|
138
|
+
| Payload["DecodingServices"]
|
|
139
|
+
| Success["EncodingServices"]
|
|
140
|
+
| Error["EncodingServices"]
|
|
141
|
+
>
|
|
142
|
+
}>()("effect-mq/Worker") {}
|
|
143
|
+
|
|
144
|
+
interface HandlerEntry {
|
|
145
|
+
readonly run: (
|
|
146
|
+
payload: JobRecord["payload"],
|
|
147
|
+
context: JobContext
|
|
148
|
+
) => Effect.Effect<unknown, unknown>
|
|
149
|
+
readonly encodeExit: (
|
|
150
|
+
exit: Exit.Exit<unknown, unknown>
|
|
151
|
+
) => Effect.Effect<JobRecord["exit"], unknown>
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Delay before attempt `attempt` (1-based) re-runs, per the job's policy.
|
|
156
|
+
*
|
|
157
|
+
* @internal
|
|
158
|
+
*/
|
|
159
|
+
export const backoffDelayMs = (
|
|
160
|
+
backoff: BackoffPolicy | undefined,
|
|
161
|
+
attempt: number
|
|
162
|
+
): number => {
|
|
163
|
+
if (backoff === undefined) return 0
|
|
164
|
+
switch (backoff._tag) {
|
|
165
|
+
case "fixed":
|
|
166
|
+
return backoff.delayMs
|
|
167
|
+
case "exponential":
|
|
168
|
+
return Math.round(backoff.delayMs * (backoff.factor ?? 2) ** (attempt - 1))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Bounded so the "retry, then die/drop" paths are actually reachable and a
|
|
173
|
+
// persistently-broken driver cannot hang shutdown forever (worst case ~10s).
|
|
174
|
+
const storeRetryPolicy = Schedule.min([
|
|
175
|
+
Schedule.exponential(200, 1.5),
|
|
176
|
+
Schedule.spaced("30 seconds")
|
|
177
|
+
]).pipe(Schedule.upTo({ times: 8 }))
|
|
178
|
+
|
|
179
|
+
type Restore = <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build the worker service. Requires a `Scope` (fibers live in it) and the
|
|
183
|
+
* `JobStore`.
|
|
184
|
+
*
|
|
185
|
+
* @since 0.1.0
|
|
186
|
+
*/
|
|
187
|
+
export const make = <StoreId = JobStore>(
|
|
188
|
+
options?: WorkerOptions<StoreId> | undefined
|
|
189
|
+
): Effect.Effect<Worker["Service"], never, Scope.Scope | StoreId> =>
|
|
190
|
+
Effect.gen(function*() {
|
|
191
|
+
// Taker fibers are forked from whichever registration arrives first; pin
|
|
192
|
+
// them to the worker's own context so they never inherit one job's
|
|
193
|
+
// locally provided services.
|
|
194
|
+
const workerContext = yield* Effect.context<never>()
|
|
195
|
+
// SAFETY: when `options.store` is omitted the public signature fixes
|
|
196
|
+
// `StoreId` to its default `JobStore`, so the default key is the right
|
|
197
|
+
// `Context.Key<StoreId>`; when it is present the cast is an identity.
|
|
198
|
+
const storeKey = (options?.store ?? JobStore) as Context.Key<StoreId, StoreService>
|
|
199
|
+
const store: StoreService = yield* storeKey
|
|
200
|
+
const fibers = yield* FiberSet.make()
|
|
201
|
+
|
|
202
|
+
const lockDurationMs = Duration.toMillis(options?.lockDuration ?? 30_000)
|
|
203
|
+
const lockRenewMs = options?.lockRenewInterval !== undefined
|
|
204
|
+
? Duration.toMillis(options.lockRenewInterval)
|
|
205
|
+
: Math.max(1, Math.floor(lockDurationMs / 2))
|
|
206
|
+
const stalledMs = Duration.toMillis(options?.stalledInterval ?? 30_000)
|
|
207
|
+
const maxStalledCount = options?.maxStalledCount ?? 1
|
|
208
|
+
const pollMs = Duration.toMillis(options?.pollInterval ?? 5_000)
|
|
209
|
+
const workerId = options?.id ?? `worker-${Math.random().toString(36).slice(2, 10)}`
|
|
210
|
+
|
|
211
|
+
const handlers = new Map<string, HandlerEntry>()
|
|
212
|
+
const queueNames = new Map<QueueName, Set<string>>()
|
|
213
|
+
const startedQueues = new Set<QueueName>()
|
|
214
|
+
const inflight = new Map<JobId, { readonly id: JobId; readonly token: string }>()
|
|
215
|
+
|
|
216
|
+
let tokenCounter = 0
|
|
217
|
+
const nextToken = () => `${workerId}:${++tokenCounter}`
|
|
218
|
+
|
|
219
|
+
// Local wake-up for registration changes. Versioned like the store's
|
|
220
|
+
// wakeToken so a pulse firing between "observe" and "await" is not lost.
|
|
221
|
+
let pulseVersion = 0
|
|
222
|
+
let pulse = Deferred.makeUnsafe<void>()
|
|
223
|
+
const firePulse = Effect.suspend(() => {
|
|
224
|
+
pulseVersion += 1
|
|
225
|
+
const current = pulse
|
|
226
|
+
pulse = Deferred.makeUnsafe<void>()
|
|
227
|
+
return Deferred.succeed(current, void 0)
|
|
228
|
+
})
|
|
229
|
+
const awaitPulse = (observed: number) =>
|
|
230
|
+
Effect.suspend(() =>
|
|
231
|
+
pulseVersion > observed ? Effect.void : Deferred.await(pulse)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
const retryStore = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
235
|
+
effect.pipe(
|
|
236
|
+
Effect.retry({
|
|
237
|
+
// Classified by tag (not instanceof) so errors from a duplicated
|
|
238
|
+
// module instance are still recognized.
|
|
239
|
+
while: (error) => isJobStoreError(error),
|
|
240
|
+
schedule: storeRetryPolicy
|
|
241
|
+
}),
|
|
242
|
+
Effect.orDie
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
// Ack-path safety: lost locks and vanished jobs mean another worker owns
|
|
246
|
+
// the job now — log and move on. Driver errors retry, then are dropped.
|
|
247
|
+
const ackSafely = (
|
|
248
|
+
effect: Effect.Effect<
|
|
249
|
+
void,
|
|
250
|
+
JobStoreError | JobNotFoundError | LockLostError
|
|
251
|
+
>,
|
|
252
|
+
what: string
|
|
253
|
+
) =>
|
|
254
|
+
effect.pipe(
|
|
255
|
+
Effect.retry({
|
|
256
|
+
while: (error) => isJobStoreError(error),
|
|
257
|
+
schedule: storeRetryPolicy
|
|
258
|
+
}),
|
|
259
|
+
Effect.catch((error) =>
|
|
260
|
+
Effect.logWarning(`effect-mq: ${what} dropped (${error._tag})`, error)
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
const routeFailure = (record: JobRecord, exit: JobRecord["exit"]): AckOutcome => {
|
|
265
|
+
const attempt = record.attemptsMade + 1
|
|
266
|
+
if (attempt >= record.attemptsMax) {
|
|
267
|
+
return { _tag: "Fail", exit }
|
|
268
|
+
}
|
|
269
|
+
return { _tag: "Retry", delayMs: backoffDelayMs(record.backoff, attempt), exit }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Runs inside the taker's uninterruptible region; `restore` re-enables
|
|
273
|
+
// interruption only around the handler itself.
|
|
274
|
+
const processJob = (record: JobRecord, token: string, restore: Restore) =>
|
|
275
|
+
Effect.suspend(() => {
|
|
276
|
+
const entry = handlers.get(record.name)
|
|
277
|
+
if (entry === undefined) {
|
|
278
|
+
// Unregistered between claim and processing — hand the job back.
|
|
279
|
+
return ackSafely(store.release(record.id, token), "release")
|
|
280
|
+
}
|
|
281
|
+
const context: JobContext = {
|
|
282
|
+
jobId: record.id,
|
|
283
|
+
name: record.name,
|
|
284
|
+
queue: record.queue,
|
|
285
|
+
attempt: record.attemptsMade + 1,
|
|
286
|
+
attemptsMax: record.attemptsMax
|
|
287
|
+
}
|
|
288
|
+
inflight.set(record.id, { id: record.id, token })
|
|
289
|
+
return Effect.gen(function*() {
|
|
290
|
+
const exit = yield* Effect.exit(restore(entry.run(record.payload, context)))
|
|
291
|
+
inflight.delete(record.id)
|
|
292
|
+
|
|
293
|
+
// Distinguish worker shutdown from a handler that interrupted
|
|
294
|
+
// itself: entering an interruptible region while an external
|
|
295
|
+
// interrupt is pending fails immediately.
|
|
296
|
+
const shutdown = yield* Effect.exit(restore(Effect.void))
|
|
297
|
+
if (Exit.isFailure(shutdown)) {
|
|
298
|
+
// Worker shutdown: give the job back without consuming an attempt.
|
|
299
|
+
yield* ackSafely(store.release(record.id, token), "release")
|
|
300
|
+
return yield* Effect.interrupt
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// A handler that interrupted itself is a failed attempt, not a
|
|
304
|
+
// shutdown — otherwise the job would hot-loop forever.
|
|
305
|
+
const effective: Exit.Exit<unknown, unknown> =
|
|
306
|
+
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
|
|
307
|
+
? Exit.die(
|
|
308
|
+
new Error(`effect-mq: handler for job "${record.name}" interrupted itself`)
|
|
309
|
+
)
|
|
310
|
+
: exit
|
|
311
|
+
|
|
312
|
+
// Never let an encode defect escape: the job would be stuck active.
|
|
313
|
+
let encoded = yield* Effect.exit(entry.encodeExit(effective))
|
|
314
|
+
let encodable = true
|
|
315
|
+
if (Exit.isFailure(encoded)) {
|
|
316
|
+
encodable = false
|
|
317
|
+
yield* Effect.logError(
|
|
318
|
+
`effect-mq: failed to encode handler exit for job "${record.name}"`,
|
|
319
|
+
encoded.cause
|
|
320
|
+
)
|
|
321
|
+
encoded = yield* Effect.exit(entry.encodeExit(Exit.die(
|
|
322
|
+
new Error(`effect-mq: failed to encode handler exit for job "${record.name}"`)
|
|
323
|
+
)))
|
|
324
|
+
}
|
|
325
|
+
const exitValue = Exit.isSuccess(encoded) ? encoded.value : undefined
|
|
326
|
+
|
|
327
|
+
const outcome: AckOutcome = Exit.isSuccess(effective)
|
|
328
|
+
? encodable
|
|
329
|
+
// A success whose value can't be encoded must NOT re-run (its
|
|
330
|
+
// side effects already happened) — fail it with the defect.
|
|
331
|
+
? { _tag: "Complete", exit: exitValue }
|
|
332
|
+
: { _tag: "Fail", exit: exitValue }
|
|
333
|
+
: routeFailure(record, exitValue)
|
|
334
|
+
yield* ackSafely(store.ack(record.id, token, outcome), "ack")
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// One loop iteration. The claim and everything after it run
|
|
339
|
+
// uninterruptibly so shutdown can never orphan a just-claimed job; only
|
|
340
|
+
// the idle waits and the handler run are interruptible.
|
|
341
|
+
const takerIteration = (queue: QueueName) =>
|
|
342
|
+
Effect.uninterruptibleMask((restore) =>
|
|
343
|
+
Effect.gen(function*() {
|
|
344
|
+
const observedPulse = pulseVersion
|
|
345
|
+
const names = queueNames.get(queue)
|
|
346
|
+
if (names === undefined || names.size === 0) {
|
|
347
|
+
return yield* restore(
|
|
348
|
+
Effect.race(awaitPulse(observedPulse), Effect.sleep(pollMs))
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
const token = nextToken()
|
|
352
|
+
const result = yield* retryStore(store.claim({
|
|
353
|
+
queue,
|
|
354
|
+
names: Array.from(names),
|
|
355
|
+
token,
|
|
356
|
+
lockDurationMs
|
|
357
|
+
}))
|
|
358
|
+
if (result._tag === "Empty") {
|
|
359
|
+
const now = yield* Clock.currentTimeMillis
|
|
360
|
+
const timeout = result.nextRunAt !== undefined
|
|
361
|
+
? Math.max(0, Math.min(result.nextRunAt - now, pollMs))
|
|
362
|
+
: pollMs
|
|
363
|
+
return yield* restore(Effect.raceAll([
|
|
364
|
+
retryStore(store.awaitWake([queue], result.wakeToken)),
|
|
365
|
+
Effect.sleep(timeout),
|
|
366
|
+
awaitPulse(observedPulse)
|
|
367
|
+
]))
|
|
368
|
+
}
|
|
369
|
+
yield* processJob(result.job, token, restore)
|
|
370
|
+
})
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
const takerLoop = (queue: QueueName) =>
|
|
374
|
+
takerIteration(queue).pipe(
|
|
375
|
+
Effect.catchCause((cause) =>
|
|
376
|
+
Effect.logError(`effect-mq: worker iteration failed (queue "${queue}")`, cause)
|
|
377
|
+
),
|
|
378
|
+
Effect.forever
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
const queueConcurrency = (queue: QueueName, registered: number | undefined) =>
|
|
382
|
+
Math.max(
|
|
383
|
+
1,
|
|
384
|
+
options?.queues?.[queue]?.concurrency ??
|
|
385
|
+
registered ??
|
|
386
|
+
options?.concurrency ??
|
|
387
|
+
1
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
const ensureQueueLoop = (queue: QueueName, registered: number | undefined) =>
|
|
391
|
+
Effect.suspend(() => {
|
|
392
|
+
if (startedQueues.has(queue)) return Effect.void
|
|
393
|
+
startedQueues.add(queue)
|
|
394
|
+
const takers = queueConcurrency(queue, registered)
|
|
395
|
+
const loop = takerLoop(queue).pipe(
|
|
396
|
+
Effect.updateContext(() => workerContext)
|
|
397
|
+
)
|
|
398
|
+
return Effect.forEach(
|
|
399
|
+
Array.from({ length: takers }, (_, i) => i),
|
|
400
|
+
() => FiberSet.run(fibers, loop),
|
|
401
|
+
{ discard: true }
|
|
402
|
+
)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const renewalLoop = Effect.gen(function*() {
|
|
406
|
+
yield* Effect.sleep(lockRenewMs)
|
|
407
|
+
const entries = Array.from(inflight.values())
|
|
408
|
+
if (entries.length === 0) return
|
|
409
|
+
const lost = yield* retryStore(store.extendLocks(entries, lockDurationMs))
|
|
410
|
+
if (lost.length > 0) {
|
|
411
|
+
yield* Effect.logWarning(
|
|
412
|
+
"effect-mq: failed to renew locks; jobs may run twice",
|
|
413
|
+
lost
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
}).pipe(
|
|
417
|
+
Effect.catchCause((cause) => Effect.logError("effect-mq: lock renewal failed", cause)),
|
|
418
|
+
Effect.forever
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
const stalledLoop = Effect.gen(function*() {
|
|
422
|
+
yield* Effect.sleep(stalledMs)
|
|
423
|
+
const recovered = yield* retryStore(store.recoverStalled({ maxStalledCount }))
|
|
424
|
+
if (recovered.length > 0) {
|
|
425
|
+
yield* Effect.logWarning("effect-mq: recovered stalled jobs", recovered)
|
|
426
|
+
}
|
|
427
|
+
}).pipe(
|
|
428
|
+
Effect.catchCause((cause) => Effect.logError("effect-mq: stalled sweep failed", cause)),
|
|
429
|
+
Effect.forever
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
yield* FiberSet.run(fibers, renewalLoop)
|
|
433
|
+
yield* FiberSet.run(fibers, stalledLoop)
|
|
434
|
+
|
|
435
|
+
// SAFETY: the public `register` signature declares Scope, the handler's R
|
|
436
|
+
// and the codec services as requirements; the implementation erases them
|
|
437
|
+
// (the trailing assertion below) because the handler runs with the
|
|
438
|
+
// context captured at registration time via `provideCaptured`.
|
|
439
|
+
return Worker.of({
|
|
440
|
+
register: (job, handler, registerOptions) =>
|
|
441
|
+
Effect.gen(function*() {
|
|
442
|
+
const name = job._tag
|
|
443
|
+
if (handlers.has(name)) {
|
|
444
|
+
return yield* Effect.die(
|
|
445
|
+
new Error(`effect-mq: duplicate handler registered for job "${name}"`)
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
if (job.store.key !== storeKey.key) {
|
|
449
|
+
return yield* Effect.die(
|
|
450
|
+
new Error(
|
|
451
|
+
`effect-mq: job "${name}" is bound to store "${job.store.key}" but this worker claims from "${storeKey.key}". ` +
|
|
452
|
+
`Provide a Worker.layer({ store }) for the job's store (use Layer.provide locally to run several workers in one process).`
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
// Everything the handler and codecs require was provided to the
|
|
457
|
+
// registration layer; capture it, minus runtime-ambient keys that
|
|
458
|
+
// must always come from the executing fiber.
|
|
459
|
+
const services = (yield* Effect.context<never>()).pipe(
|
|
460
|
+
Context.omit(Scope_.Scope, Tracer.ParentSpan)
|
|
461
|
+
)
|
|
462
|
+
const decodePayload = Schema.decodeUnknownEffect(job.payloadJsonSchema)
|
|
463
|
+
const encodeExit = Schema.encodeEffect(job.exitSchema)
|
|
464
|
+
// SAFETY: per `register`'s public signature the captured context
|
|
465
|
+
// contains the handler's requirements; merging it OVER the runtime
|
|
466
|
+
// context (captured wins on conflicts, so locally provided services
|
|
467
|
+
// are not shadowed by the worker's) restores those requirements.
|
|
468
|
+
const provideCaptured = <A, E>(effect: Effect.Effect<A, E, unknown>): Effect.Effect<A, E> =>
|
|
469
|
+
effect.pipe(
|
|
470
|
+
Effect.updateContext((input) =>
|
|
471
|
+
Context.merge(input, services) as Context.Context<unknown>
|
|
472
|
+
)
|
|
473
|
+
) as Effect.Effect<A, E>
|
|
474
|
+
const entry: HandlerEntry = {
|
|
475
|
+
run: (payload, context) =>
|
|
476
|
+
provideCaptured(
|
|
477
|
+
decodePayload(payload).pipe(
|
|
478
|
+
Effect.orDie,
|
|
479
|
+
Effect.flatMap((decoded) => handler(decoded, context))
|
|
480
|
+
)
|
|
481
|
+
),
|
|
482
|
+
encodeExit: (exit) => provideCaptured(encodeExit(exit))
|
|
483
|
+
}
|
|
484
|
+
handlers.set(name, entry)
|
|
485
|
+
const queue = registerOptions?.queue !== undefined
|
|
486
|
+
? QueueName(registerOptions.queue)
|
|
487
|
+
: job.queue
|
|
488
|
+
let names = queueNames.get(queue)
|
|
489
|
+
if (names === undefined) {
|
|
490
|
+
names = new Set()
|
|
491
|
+
queueNames.set(queue, names)
|
|
492
|
+
}
|
|
493
|
+
names.add(name)
|
|
494
|
+
yield* Effect.addFinalizer(() =>
|
|
495
|
+
Effect.sync(() => {
|
|
496
|
+
handlers.delete(name)
|
|
497
|
+
queueNames.get(queue)?.delete(name)
|
|
498
|
+
})
|
|
499
|
+
)
|
|
500
|
+
yield* ensureQueueLoop(queue, registerOptions?.concurrency)
|
|
501
|
+
yield* firePulse
|
|
502
|
+
}) as Effect.Effect<void, never, never>
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Run a worker as a layer. Provide handler layers (`Job.toLayer(...)`) on top
|
|
508
|
+
* of this, and a `JobStore` below it.
|
|
509
|
+
*
|
|
510
|
+
* @since 0.1.0
|
|
511
|
+
*/
|
|
512
|
+
export const layer = <StoreId = JobStore>(
|
|
513
|
+
options?: WorkerOptions<StoreId> | undefined
|
|
514
|
+
): Layer.Layer<Worker, never, StoreId> => Layer.effect(Worker, make(options))
|