effect-mq 0.3.2 → 0.4.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 (42) hide show
  1. package/README.md +102 -14
  2. package/dist/Job.d.ts +131 -9
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +81 -5
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +63 -2
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js.map +1 -1
  9. package/dist/MemoryJobStore.d.ts.map +1 -1
  10. package/dist/MemoryJobStore.js +159 -119
  11. package/dist/MemoryJobStore.js.map +1 -1
  12. package/dist/Worker.d.ts +18 -0
  13. package/dist/Worker.d.ts.map +1 -1
  14. package/dist/Worker.js +37 -9
  15. package/dist/Worker.js.map +1 -1
  16. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  17. package/dist/drizzle-postgres/DrizzleJobStore.js +239 -49
  18. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  19. package/dist/drizzle-postgres/schema.d.ts +2 -0
  20. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  21. package/dist/drizzle-postgres/schema.js +1 -0
  22. package/dist/drizzle-postgres/schema.js.map +1 -1
  23. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  24. package/dist/redis/RedisJobStore.js +173 -36
  25. package/dist/redis/RedisJobStore.js.map +1 -1
  26. package/dist/redis/scripts.d.ts +24 -1
  27. package/dist/redis/scripts.d.ts.map +1 -1
  28. package/dist/redis/scripts.js +126 -21
  29. package/dist/redis/scripts.js.map +1 -1
  30. package/dist/testing/conformance.d.ts.map +1 -1
  31. package/dist/testing/conformance.js +266 -0
  32. package/dist/testing/conformance.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/Job.ts +268 -13
  35. package/src/JobStore.ts +77 -2
  36. package/src/MemoryJobStore.ts +102 -53
  37. package/src/Worker.ts +61 -9
  38. package/src/drizzle-postgres/DrizzleJobStore.ts +273 -66
  39. package/src/drizzle-postgres/schema.ts +2 -0
  40. package/src/redis/RedisJobStore.ts +236 -47
  41. package/src/redis/scripts.ts +153 -21
  42. package/src/testing/conformance.ts +350 -0
@@ -55,6 +55,7 @@ interface MemJob {
55
55
  timeoutMs: number | undefined
56
56
  cancelRequested: boolean
57
57
  readonly dedupeKey: string | undefined
58
+ trace: JobRecord["trace"]
58
59
  runAt: number
59
60
  readonly enqueuedAt: number
60
61
  processedAt: number | undefined
@@ -85,6 +86,7 @@ const snapshot = (job: MemJob): JobRecord => ({
85
86
  timeoutMs: job.timeoutMs,
86
87
  cancelRequested: job.cancelRequested,
87
88
  dedupeKey: job.dedupeKey,
89
+ trace: job.trace,
88
90
  runAt: job.runAt,
89
91
  enqueuedAt: job.enqueuedAt,
90
92
  processedAt: job.processedAt,
@@ -278,9 +280,63 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
278
280
  }
279
281
  }
280
282
 
281
- const service: Service = JobStore.of({
282
- enqueue: (request: EnqueueRequest) =>
283
- Effect.gen(function*() {
283
+ const cancelJob = (id: JobId) =>
284
+ Effect.gen(function*() {
285
+ const now = yield* Clock.currentTimeMillis
286
+ const job = jobs.get(id)
287
+ if (job === undefined) {
288
+ return yield* new JobNotFoundError({ jobId: id })
289
+ }
290
+ switch (job.state) {
291
+ case "waiting":
292
+ case "delayed": {
293
+ markCancelled(job, now)
294
+ return
295
+ }
296
+ case "active": {
297
+ job.cancelRequested = true
298
+ return
299
+ }
300
+ default: {
301
+ return yield* new JobNotCancellableError({ jobId: id, state: job.state })
302
+ }
303
+ }
304
+ })
305
+
306
+ const insertJobRecord = (id: JobId, request: EnqueueRequest, now: number) => {
307
+ jobs.set(id, {
308
+ id,
309
+ name: request.name,
310
+ queue: request.queue,
311
+ payload: request.payload,
312
+ metadata: request.metadata,
313
+ state: request.delayMs > 0 ? "delayed" : "waiting",
314
+ priority: request.priority,
315
+ attemptsMax: request.attemptsMax,
316
+ attemptsMade: 0,
317
+ stalledCount: 0,
318
+ backoff: request.backoff,
319
+ keep: request.keep,
320
+ timeoutMs: request.timeoutMs,
321
+ cancelRequested: false,
322
+ dedupeKey: request.dedupe?.key,
323
+ trace: request.trace,
324
+ runAt: now + Math.max(0, request.delayMs),
325
+ enqueuedAt: now,
326
+ processedAt: undefined,
327
+ finishedAt: undefined,
328
+ exit: undefined,
329
+ failedReason: undefined,
330
+ attempts: [],
331
+ seq: ++seq,
332
+ lockToken: undefined,
333
+ lockExpiresAt: undefined
334
+ })
335
+ signalWake(request.queue)
336
+ }
337
+
338
+ const enqueueOne = (request: EnqueueRequest) =>
339
+ Effect.gen(function*() {
284
340
  const now = yield* Clock.currentTimeMillis
285
341
  if (request.id !== undefined && jobs.has(request.id)) {
286
342
  return { id: request.id, duplicate: true }
@@ -302,6 +358,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
302
358
  keyed.backoff = request.backoff
303
359
  keyed.keep = request.keep
304
360
  keyed.timeoutMs = request.timeoutMs
361
+ keyed.trace = request.trace
305
362
  keyed.runAt = now + Math.max(0, request.delayMs)
306
363
  // A landed replace re-arms the ttl window.
307
364
  if (request.dedupe.ttlMs !== undefined) {
@@ -349,42 +406,20 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
349
406
  }
350
407
  }
351
408
  }
352
- jobs.set(id, {
353
- id,
354
- name: request.name,
355
- queue: request.queue,
356
- payload: request.payload,
357
- metadata: request.metadata,
358
- state: request.delayMs > 0 ? "delayed" : "waiting",
359
- priority: request.priority,
360
- attemptsMax: request.attemptsMax,
361
- attemptsMade: 0,
362
- stalledCount: 0,
363
- backoff: request.backoff,
364
- keep: request.keep,
365
- timeoutMs: request.timeoutMs,
366
- cancelRequested: false,
367
- dedupeKey: request.dedupe?.key,
368
- runAt: now + Math.max(0, request.delayMs),
369
- enqueuedAt: now,
370
- processedAt: undefined,
371
- finishedAt: undefined,
372
- exit: undefined,
373
- failedReason: undefined,
374
- attempts: [],
375
- seq: ++seq,
376
- lockToken: undefined,
377
- lockExpiresAt: undefined
378
- })
409
+ insertJobRecord(id, request, now)
379
410
  if (request.dedupe !== undefined) {
380
411
  dedupes.set(dedupeMapKey(request.name, request.dedupe.key), {
381
412
  jobId: id,
382
413
  expiresAt: request.dedupe.ttlMs !== undefined ? now + request.dedupe.ttlMs : undefined
383
414
  })
384
415
  }
385
- signalWake(request.queue)
386
416
  return { id, duplicate: false }
387
- }),
417
+ })
418
+
419
+ const service: Service = JobStore.of({
420
+ enqueue: enqueueOne,
421
+
422
+ enqueueMany: (requests) => Effect.forEach(requests, enqueueOne),
388
423
 
389
424
  claim: (options) =>
390
425
  Effect.gen(function*() {
@@ -652,27 +687,18 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
652
687
  signalWake(job.queue)
653
688
  }),
654
689
 
655
- cancel: (id) =>
656
- Effect.gen(function*() {
657
- const now = yield* Clock.currentTimeMillis
658
- const job = jobs.get(id)
659
- if (job === undefined) {
660
- return yield* new JobNotFoundError({ jobId: id })
661
- }
662
- switch (job.state) {
663
- case "waiting":
664
- case "delayed": {
665
- markCancelled(job, now)
666
- return
667
- }
668
- case "active": {
669
- job.cancelRequested = true
670
- return
671
- }
672
- default: {
673
- return yield* new JobNotCancellableError({ jobId: id, state: job.state })
674
- }
675
- }
690
+ cancel: cancelJob,
691
+
692
+ cancelByDedupe: (name, key) =>
693
+ Effect.suspend(() => {
694
+ const entry = dedupes.get(dedupeMapKey(name, key))
695
+ if (entry === undefined) return Effect.succeed(false)
696
+ return cancelJob(entry.jobId).pipe(
697
+ Effect.as(true),
698
+ // Idempotent: a vanished or already-terminal keyed job is "nothing
699
+ // pending", not an error.
700
+ Effect.catchTag(["JobNotFoundError", "JobNotCancellableError"], () => Effect.succeed(false))
701
+ )
676
702
  }),
677
703
 
678
704
  promote: (id) =>
@@ -747,6 +773,29 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
747
773
  }
748
774
  }),
749
775
 
776
+ tickSchedule: (key, expectedRunAt, nextRunAt, request) =>
777
+ Effect.gen(function*() {
778
+ const id = request.id
779
+ if (id === undefined) {
780
+ return yield* new JobStoreError({
781
+ message: "tickSchedule requires an explicit request.id"
782
+ })
783
+ }
784
+ const now = yield* Clock.currentTimeMillis
785
+ // One synchronous block: CAS, insert, and advance commit together —
786
+ // no yield point can interleave another sweeper between them.
787
+ const schedule = schedules.get(key)
788
+ if (schedule === undefined || schedule.nextRunAt !== expectedRunAt) {
789
+ return false
790
+ }
791
+ schedules.set(key, { ...schedule, nextRunAt })
792
+ // Slot already materialized (pre-0.4 crash between enqueue and
793
+ // advance): the schedule still advances, but nothing new fired.
794
+ if (jobs.has(id)) return false
795
+ insertJobRecord(id, request, now)
796
+ return true
797
+ }),
798
+
750
799
  counts: (queue) =>
751
800
  Effect.sync(() => {
752
801
  const counts = {
package/src/Worker.ts CHANGED
@@ -124,6 +124,24 @@ export interface WorkerOptions<StoreId = JobStore> {
124
124
  * sampling costs one store query per queue per tick).
125
125
  */
126
126
  readonly queueMetricsInterval?: Duration.Input | undefined
127
+ /**
128
+ * Name for the span wrapping each handler run (default
129
+ * `` `${context.name}.run` ``). The span carries `effectMqJobId`,
130
+ * `effectMqQueue`, and `effectMqAttempt` attributes and — when the
131
+ * producer enqueued inside a span — joins the producing trace as its
132
+ * child.
133
+ */
134
+ readonly handlerSpanName?: ((context: JobContext) => string) | undefined
135
+ /**
136
+ * How the handler span attaches to the producer's persisted trace:
137
+ * - `"auto"` (default): immediate enqueues CONTINUE the producer trace
138
+ * (parent-child); explicitly delayed/`at`-scheduled ones start their own
139
+ * trace with a causal LINK back (long-delayed parent-child traces render
140
+ * badly and defeat tail sampling).
141
+ * - `"parent"` / `"link"`: force one mode for every job.
142
+ * - `"none"`: spans and attributes only, no cross-trace edge.
143
+ */
144
+ readonly traceLinking?: "auto" | "parent" | "link" | "none" | undefined
127
145
  /** Identifier used in lock tokens (default: random). */
128
146
  readonly id?: string | undefined
129
147
  }
@@ -335,9 +353,43 @@ export const make = <StoreId = JobStore>(
335
353
  // it as a defect so it flows through normal retry accounting.
336
354
  // timeoutOrElse (not timeout + a catch on TimeoutError) so a
337
355
  // handler's OWN typed TimeoutError failure stays a typed failure.
356
+ // Each run gets a span (configurable name) tagged with the job
357
+ // id, parented on the PRODUCER's persisted span context when
358
+ // present — so producer -> handler traces connect across
359
+ // processes.
360
+ const linking = options?.traceLinking ?? "auto"
361
+ const attach = record.trace === undefined
362
+ ? "none"
363
+ : linking === "auto"
364
+ ? (record.trace.delayed ? "link" : "parent")
365
+ : linking
366
+ const producerSpan = record.trace === undefined ? undefined : Tracer.externalSpan({
367
+ traceId: record.trace.traceId,
368
+ spanId: record.trace.spanId,
369
+ sampled: record.trace.sampled
370
+ })
371
+ const withRunSpan = entry.run(record.payload, context).pipe(
372
+ Effect.withSpan(
373
+ options?.handlerSpanName?.(context) ?? `${record.name}.run`,
374
+ {
375
+ attributes: {
376
+ effectMqJobId: record.id,
377
+ effectMqQueue: record.queue,
378
+ effectMqAttempt: context.attempt
379
+ },
380
+ links: attach === "link" && producerSpan !== undefined
381
+ ? [{ span: producerSpan, attributes: {} }]
382
+ : undefined
383
+ },
384
+ { captureStackTrace: false }
385
+ )
386
+ )
387
+ const spanned = attach === "parent" && producerSpan !== undefined
388
+ ? withRunSpan.pipe(Effect.withParentSpan(producerSpan))
389
+ : withRunSpan
338
390
  const handlerEffect = record.timeoutMs === undefined
339
- ? entry.run(record.payload, context)
340
- : entry.run(record.payload, context).pipe(
391
+ ? spanned
392
+ : spanned.pipe(
341
393
  Effect.timeoutOrElse({
342
394
  duration: record.timeoutMs,
343
395
  orElse: () =>
@@ -581,10 +633,10 @@ export const make = <StoreId = JobStore>(
581
633
  Effect.forever
582
634
  )
583
635
 
584
- // Tick one due repeatable-job schedule. The enqueue id is deterministic
585
- // per (schedule, slot), so concurrent sweepers across workers dedup
586
- // naturally; advanceSchedule is conditional, so double-advances are
587
- // no-ops.
636
+ // Tick one due repeatable-job schedule. tickSchedule is a single atomic
637
+ // op (CAS on nextRunAt + insert + advance), so concurrent sweepers fire
638
+ // each slot exactly once even when the previous slot's job row has
639
+ // already been pruned by retention.
588
640
  const sweepSchedule = (schedule: ScheduleRecord) =>
589
641
  Effect.gen(function*() {
590
642
  const now = yield* Clock.currentTimeMillis
@@ -595,7 +647,7 @@ export const make = <StoreId = JobStore>(
595
647
  `effect-mq: schedule "${schedule.key}" has an invalid cron/every configuration; skipping`
596
648
  )
597
649
  }
598
- const tick = yield* retryStore(store.enqueue({
650
+ const fired = yield* retryStore(store.tickSchedule(schedule.key, slot, next, {
599
651
  id: JobId(`sched/${schedule.key}/${slot}`),
600
652
  name: schedule.jobName,
601
653
  queue: schedule.queue,
@@ -607,15 +659,15 @@ export const make = <StoreId = JobStore>(
607
659
  keep: schedule.keep,
608
660
  timeoutMs: schedule.timeoutMs,
609
661
  dedupe: undefined,
662
+ trace: undefined,
610
663
  delayMs: 0
611
664
  }))
612
- if (!tick.duplicate) {
665
+ if (fired) {
613
666
  yield* Metric.update(
614
667
  Metrics.scheduleTicks.pipe(Metric.withAttributes({ name: schedule.jobName })),
615
668
  1
616
669
  )
617
670
  }
618
- yield* retryStore(store.advanceSchedule(schedule.key, slot, next))
619
671
  })
620
672
 
621
673
  // Each due schedule is swept in isolation so one poison row (bad cron,