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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +235 -0
  3. package/dist/Job.d.ts +222 -0
  4. package/dist/Job.d.ts.map +1 -0
  5. package/dist/Job.js +218 -0
  6. package/dist/Job.js.map +1 -0
  7. package/dist/JobStore.d.ts +401 -0
  8. package/dist/JobStore.d.ts.map +1 -0
  9. package/dist/JobStore.js +89 -0
  10. package/dist/JobStore.js.map +1 -0
  11. package/dist/MemoryJobStore.d.ts +34 -0
  12. package/dist/MemoryJobStore.d.ts.map +1 -0
  13. package/dist/MemoryJobStore.js +381 -0
  14. package/dist/MemoryJobStore.js.map +1 -0
  15. package/dist/Worker.d.ts +127 -0
  16. package/dist/Worker.d.ts.map +1 -0
  17. package/dist/Worker.js +274 -0
  18. package/dist/Worker.js.map +1 -0
  19. package/dist/drizzle/DrizzleJobStore.d.ts +59 -0
  20. package/dist/drizzle/DrizzleJobStore.d.ts.map +1 -0
  21. package/dist/drizzle/DrizzleJobStore.js +426 -0
  22. package/dist/drizzle/DrizzleJobStore.js.map +1 -0
  23. package/dist/drizzle/index.d.ts +19 -0
  24. package/dist/drizzle/index.d.ts.map +1 -0
  25. package/dist/drizzle/index.js +19 -0
  26. package/dist/drizzle/index.js.map +1 -0
  27. package/dist/drizzle/schema.d.ts +464 -0
  28. package/dist/drizzle/schema.d.ts.map +1 -0
  29. package/dist/drizzle/schema.js +68 -0
  30. package/dist/drizzle/schema.js.map +1 -0
  31. package/dist/index.d.ts +30 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +30 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/testing/conformance.d.ts +27 -0
  36. package/dist/testing/conformance.d.ts.map +1 -0
  37. package/dist/testing/conformance.js +451 -0
  38. package/dist/testing/conformance.js.map +1 -0
  39. package/dist/testing/index.d.ts +8 -0
  40. package/dist/testing/index.d.ts.map +1 -0
  41. package/dist/testing/index.js +8 -0
  42. package/dist/testing/index.js.map +1 -0
  43. package/package.json +71 -0
  44. package/src/Job.ts +606 -0
  45. package/src/JobStore.ts +446 -0
  46. package/src/MemoryJobStore.ts +467 -0
  47. package/src/Worker.ts +514 -0
  48. package/src/drizzle/DrizzleJobStore.ts +599 -0
  49. package/src/drizzle/index.ts +20 -0
  50. package/src/drizzle/schema.ts +116 -0
  51. package/src/index.ts +33 -0
  52. package/src/testing/conformance.ts +654 -0
  53. 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))