effect-mq 0.3.2 → 0.4.1
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 +102 -14
- package/dist/Job.d.ts +131 -9
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +81 -5
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +63 -2
- 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 +159 -119
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Worker.d.ts +18 -0
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +37 -9
- package/dist/Worker.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +239 -49
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +35 -27
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +8 -2
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +173 -36
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +24 -1
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +126 -21
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +266 -0
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Job.ts +268 -13
- package/src/JobStore.ts +77 -2
- package/src/MemoryJobStore.ts +102 -53
- package/src/Worker.ts +61 -9
- package/src/drizzle-postgres/DrizzleJobStore.ts +273 -66
- package/src/drizzle-postgres/schema.ts +33 -25
- package/src/redis/RedisJobStore.ts +236 -47
- package/src/redis/scripts.ts +153 -21
- package/src/testing/conformance.ts +350 -0
package/src/MemoryJobStore.ts
CHANGED
|
@@ -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
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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:
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
?
|
|
340
|
-
:
|
|
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.
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
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
|
|
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 (
|
|
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,
|