effect-mq 0.2.0 → 0.3.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 (45) hide show
  1. package/README.md +153 -18
  2. package/dist/Job.d.ts +51 -1
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +44 -2
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +86 -6
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +27 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +6 -5
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +160 -32
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts.map +1 -1
  15. package/dist/Worker.js +1 -0
  16. package/dist/Worker.js.map +1 -1
  17. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +20 -3
  18. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  19. package/dist/drizzle-postgres/DrizzleJobStore.js +352 -84
  20. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  21. package/dist/drizzle-postgres/schema.d.ts +255 -351
  22. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  23. package/dist/drizzle-postgres/schema.js +75 -27
  24. package/dist/drizzle-postgres/schema.js.map +1 -1
  25. package/dist/redis/RedisJobStore.d.ts +4 -2
  26. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  27. package/dist/redis/RedisJobStore.js +67 -28
  28. package/dist/redis/RedisJobStore.js.map +1 -1
  29. package/dist/redis/scripts.d.ts +19 -6
  30. package/dist/redis/scripts.d.ts.map +1 -1
  31. package/dist/redis/scripts.js +208 -23
  32. package/dist/redis/scripts.js.map +1 -1
  33. package/dist/testing/conformance.d.ts.map +1 -1
  34. package/dist/testing/conformance.js +172 -7
  35. package/dist/testing/conformance.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/Job.ts +113 -6
  38. package/src/JobStore.ts +122 -6
  39. package/src/MemoryJobStore.ts +184 -44
  40. package/src/Worker.ts +1 -0
  41. package/src/drizzle-postgres/DrizzleJobStore.ts +431 -91
  42. package/src/drizzle-postgres/schema.ts +177 -63
  43. package/src/redis/RedisJobStore.ts +90 -35
  44. package/src/redis/scripts.ts +217 -24
  45. package/src/testing/conformance.ts +246 -7
@@ -7,13 +7,23 @@
7
7
  *
8
8
  * ```ts
9
9
  * // schema.ts
10
- * import { mqJobAttempts, mqJobs } from "effect-mq/drizzle"
10
+ * import { mqJobAttempts, mqJobs } from "effect-mq/drizzle-postgres"
11
11
  *
12
12
  * type DurableJobs = typeof GenerateInvoice._tag | typeof GenerateReport._tag
13
13
  * export const jobs = mqJobs<DurableJobs>()
14
14
  * export const jobAttempts = mqJobAttempts(jobs)
15
15
  * ```
16
16
  *
17
+ * Every factory accepts an `extraConfig` callback — the same shape as
18
+ * drizzle's own third `pgTable` argument — to add your own indexes (or
19
+ * checks/policies) on top of the built-in ones:
20
+ *
21
+ * ```ts
22
+ * export const jobs = mqJobs<DurableJobs>("effect_mq_jobs", {
23
+ * extraConfig: (t) => [index("jobs_name_recent_idx").on(t.name, t.enqueuedAt.desc())]
24
+ * })
25
+ * ```
26
+ *
17
27
  * Then `drizzle-kit generate` emits the CREATE TABLE migrations into your
18
28
  * pipeline like any other table. Reads through drizzle are encouraged;
19
29
  * writes must go through the `JobStore` (e.g. `store.retry`) so locking and
@@ -23,7 +33,20 @@
23
33
  */
24
34
  import type * as JobStore from "../JobStore.ts"
25
35
  import { sql } from "drizzle-orm"
26
- import { bigint, boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core"
36
+ import {
37
+ type AnyPgColumnBuilder,
38
+ bigint,
39
+ boolean,
40
+ index,
41
+ integer,
42
+ jsonb,
43
+ type PgBuildExtraConfigColumns,
44
+ pgTable,
45
+ type PgTableExtraConfigValue,
46
+ primaryKey,
47
+ text,
48
+ timestamp
49
+ } from "drizzle-orm/pg-core"
27
50
 
28
51
  type JobId = JobStore.JobId
29
52
  type QueueName = JobStore.QueueName
@@ -32,40 +55,86 @@ type JobState = JobStore.JobState
32
55
  type BackoffPolicy = JobStore.BackoffPolicy
33
56
  type KeepPolicy = JobStore.KeepPolicy
34
57
  type AttemptOutcome = JobStore.AttemptRecord["outcome"]
58
+
59
+ /**
60
+ * Table-factory options: `extraConfig` receives the table's columns (exactly
61
+ * like drizzle's third `pgTable` argument) and returns additional indexes,
62
+ * checks, or policies, appended after the built-in ones.
63
+ *
64
+ * @since 0.2.1
65
+ */
66
+ export interface MqTableOptions<Columns extends Record<string, AnyPgColumnBuilder>> {
67
+ readonly extraConfig?:
68
+ | ((table: PgBuildExtraConfigColumns<Columns>) => Array<PgTableExtraConfigValue>)
69
+ | undefined
70
+ }
71
+
72
+ const jobsColumns = <JobName extends string>() => ({
73
+ id: text("id").primaryKey().$type<JobId>(),
74
+ name: text("name").notNull().$type<JobName>(),
75
+ queue: text("queue").notNull().$type<QueueName>(),
76
+ state: text("state").notNull().$type<JobState>(),
77
+ priority: integer("priority").notNull().default(0),
78
+ /** FIFO order within a priority; bumped on retry so retries go to the tail. */
79
+ seq: bigint("seq", { mode: "number" }).notNull().generatedByDefaultAsIdentity(),
80
+ payload: jsonb("payload"),
81
+ metadata: jsonb("metadata").notNull().default({}).$type<Record<string, string>>(),
82
+ attemptsMax: integer("attempts_max").notNull(),
83
+ attemptsMade: integer("attempts_made").notNull().default(0),
84
+ stalledCount: integer("stalled_count").notNull().default(0),
85
+ backoff: jsonb("backoff").$type<BackoffPolicy>(),
86
+ keep: jsonb("keep").$type<KeepPolicy>(),
87
+ timeoutMs: bigint("timeout_ms", { mode: "number" }),
88
+ cancelRequested: boolean("cancel_requested").notNull().default(false),
89
+ dedupeKey: text("dedupe_key"),
90
+ runAt: timestamp("run_at", { withTimezone: true, mode: "date" }).notNull(),
91
+ enqueuedAt: timestamp("enqueued_at", { withTimezone: true, mode: "date" }).notNull(),
92
+ processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),
93
+ finishedAt: timestamp("finished_at", { withTimezone: true, mode: "date" }),
94
+ exit: jsonb("exit"),
95
+ failedReason: text("failed_reason"),
96
+ lockToken: text("lock_token"),
97
+ lockExpiresAt: timestamp("lock_expires_at", { withTimezone: true, mode: "date" })
98
+ })
99
+
35
100
  /**
36
101
  * The jobs table factory. `JobName` types the `name` column — derive it from
37
102
  * your job definitions: `mqJobs<typeof GenerateInvoice._tag | typeof Report._tag>()`.
38
103
  *
104
+ * `extend` adds your own columns (tenant ids, object ids, ...) to the table.
105
+ * At enqueue the Postgres store fills each extended column from the job's
106
+ * `metadata` entry with the same TS key (NULL when absent); override the
107
+ * mapping with the store's `extraValues` option. Extended columns are yours
108
+ * to read with plain drizzle queries — FKs, RLS policies, and `extraConfig`
109
+ * indexes all work:
110
+ *
111
+ * ```ts
112
+ * export const jobs = mqJobs<JobNames>("effect_mq_jobs", {
113
+ * extend: {
114
+ * companyId: text("company_id").notNull(),
115
+ * objectId: text("object_id")
116
+ * },
117
+ * extraConfig: (t) => [index("jobs_company_idx").on(t.companyId, t.state)]
118
+ * })
119
+ * ```
120
+ *
39
121
  * @since 0.1.0
40
122
  */
41
- export const mqJobs = <JobName extends string = string>(
42
- tableName = "effect_mq_jobs"
123
+ export const mqJobs = <
124
+ JobName extends string = string,
125
+ Extend extends Record<string, AnyPgColumnBuilder> = Record<never, never>
126
+ >(
127
+ tableName = "effect_mq_jobs",
128
+ options?: MqTableOptions<ReturnType<typeof jobsColumns<JobName>> & Extend> & {
129
+ /** Extra columns appended to the factory's own (see the JSDoc example). */
130
+ readonly extend?: Extend | undefined
131
+ }
43
132
  ) =>
44
133
  pgTable(tableName, {
45
- id: text("id").primaryKey().$type<JobId>(),
46
- name: text("name").notNull().$type<JobName>(),
47
- queue: text("queue").notNull().$type<QueueName>(),
48
- state: text("state").notNull().$type<JobState>(),
49
- priority: integer("priority").notNull().default(0),
50
- /** FIFO order within a priority; bumped on retry so retries go to the tail. */
51
- seq: bigint("seq", { mode: "number" }).notNull().generatedByDefaultAsIdentity(),
52
- payload: jsonb("payload"),
53
- metadata: jsonb("metadata").notNull().default({}).$type<Record<string, string>>(),
54
- attemptsMax: integer("attempts_max").notNull(),
55
- attemptsMade: integer("attempts_made").notNull().default(0),
56
- stalledCount: integer("stalled_count").notNull().default(0),
57
- backoff: jsonb("backoff").$type<BackoffPolicy>(),
58
- keep: jsonb("keep").$type<KeepPolicy>(),
59
- timeoutMs: bigint("timeout_ms", { mode: "number" }),
60
- cancelRequested: boolean("cancel_requested").notNull().default(false),
61
- runAt: timestamp("run_at", { withTimezone: true, mode: "date" }).notNull(),
62
- enqueuedAt: timestamp("enqueued_at", { withTimezone: true, mode: "date" }).notNull(),
63
- processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),
64
- finishedAt: timestamp("finished_at", { withTimezone: true, mode: "date" }),
65
- exit: jsonb("exit"),
66
- failedReason: text("failed_reason"),
67
- lockToken: text("lock_token"),
68
- lockExpiresAt: timestamp("lock_expires_at", { withTimezone: true, mode: "date" })
134
+ ...jobsColumns<JobName>(),
135
+ // SAFETY: when `extend` is absent, `Extend` was never inferred from a
136
+ // value and stays at its empty-record default, which {} satisfies.
137
+ ...options?.extend ?? ({} as Extend)
69
138
  }, (table) => [
70
139
  // Claim path: pop highest priority, FIFO within it.
71
140
  index(`${tableName}_ready_idx`)
@@ -79,14 +148,24 @@ export const mqJobs = <JobName extends string = string>(
79
148
  index(`${tableName}_active_idx`)
80
149
  .on(table.lockExpiresAt)
81
150
  .where(sql`${table.state} = 'active'`),
82
- // History/retention queries.
151
+ // History/retention queries (leading `name` also serves name-only filters).
83
152
  index(`${tableName}_history_idx`).on(table.name, table.state, table.finishedAt),
84
153
  // Listing (newest first, keyset pagination).
85
154
  index(`${tableName}_listing_idx`).on(table.enqueuedAt.desc(), table.id.desc()),
86
155
  // Metadata containment queries.
87
- index(`${tableName}_metadata_idx`).using("gin", table.metadata.op("jsonb_path_ops"))
156
+ index(`${tableName}_metadata_idx`).using("gin", table.metadata.op("jsonb_path_ops")),
157
+ ...options?.extraConfig?.(table) ?? []
88
158
  ])
89
159
 
160
+ const attemptsColumns = (jobs: MqJobsTable) => ({
161
+ jobId: text("job_id").notNull().references(() => jobs.id, { onDelete: "cascade" }).$type<JobId>(),
162
+ attempt: integer("attempt").notNull(),
163
+ outcome: text("outcome").notNull().$type<AttemptOutcome>(),
164
+ startedAt: timestamp("started_at", { withTimezone: true, mode: "date" }),
165
+ finishedAt: timestamp("finished_at", { withTimezone: true, mode: "date" }).notNull(),
166
+ exit: jsonb("exit")
167
+ })
168
+
90
169
  /**
91
170
  * The job run-ledger table factory (one row per attempt, including
92
171
  * successes and stall recoveries).
@@ -95,54 +174,84 @@ export const mqJobs = <JobName extends string = string>(
95
174
  */
96
175
  export const mqJobAttempts = (
97
176
  jobs: ReturnType<typeof mqJobs<any>>,
98
- tableName = "effect_mq_job_attempts"
177
+ tableName = "effect_mq_job_attempts",
178
+ options?: MqTableOptions<ReturnType<typeof attemptsColumns>>
99
179
  ) =>
100
- pgTable(tableName, {
101
- jobId: text("job_id").notNull().references(() => jobs.id, { onDelete: "cascade" }).$type<JobId>(),
102
- attempt: integer("attempt").notNull(),
103
- outcome: text("outcome").notNull().$type<AttemptOutcome>(),
104
- startedAt: timestamp("started_at", { withTimezone: true, mode: "date" }),
105
- finishedAt: timestamp("finished_at", { withTimezone: true, mode: "date" }).notNull(),
106
- exit: jsonb("exit")
107
- }, (table) => [
108
- primaryKey({ columns: [table.jobId, table.attempt] })
180
+ pgTable(tableName, attemptsColumns(jobs), (table) => [
181
+ primaryKey({ columns: [table.jobId, table.attempt] }),
182
+ ...options?.extraConfig?.(table) ?? []
109
183
  ])
110
184
 
185
+ const schedulesColumns = () => ({
186
+ key: text("key").primaryKey().$type<ScheduleKey>(),
187
+ jobName: text("job_name").notNull(),
188
+ queue: text("queue").notNull().$type<QueueName>(),
189
+ cron: text("cron"),
190
+ tz: text("tz"),
191
+ everyMs: bigint("every_ms", { mode: "number" }),
192
+ payload: jsonb("payload"),
193
+ metadata: jsonb("metadata").notNull().default({}).$type<Record<string, string>>(),
194
+ priority: integer("priority").notNull().default(0),
195
+ attemptsMax: integer("attempts_max").notNull(),
196
+ backoff: jsonb("backoff").$type<BackoffPolicy>(),
197
+ keep: jsonb("keep").$type<KeepPolicy>(),
198
+ timeoutMs: bigint("timeout_ms", { mode: "number" }),
199
+ nextRunAt: timestamp("next_run_at", { withTimezone: true, mode: "date" }).notNull()
200
+ })
201
+
111
202
  /**
112
203
  * Repeatable-job schedules (one row per `Job.schedule` key).
113
204
  *
114
205
  * @since 0.2.0
115
206
  */
116
- export const mqSchedules = (tableName = "effect_mq_schedules") =>
117
- pgTable(tableName, {
118
- key: text("key").primaryKey().$type<ScheduleKey>(),
119
- jobName: text("job_name").notNull(),
120
- queue: text("queue").notNull().$type<QueueName>(),
121
- cron: text("cron"),
122
- tz: text("tz"),
123
- everyMs: bigint("every_ms", { mode: "number" }),
124
- payload: jsonb("payload"),
125
- metadata: jsonb("metadata").notNull().default({}).$type<Record<string, string>>(),
126
- priority: integer("priority").notNull().default(0),
127
- attemptsMax: integer("attempts_max").notNull(),
128
- backoff: jsonb("backoff").$type<BackoffPolicy>(),
129
- keep: jsonb("keep").$type<KeepPolicy>(),
130
- timeoutMs: bigint("timeout_ms", { mode: "number" }),
131
- nextRunAt: timestamp("next_run_at", { withTimezone: true, mode: "date" }).notNull()
132
- }, (table) => [
133
- index(`${tableName}_due_idx`).on(table.nextRunAt)
207
+ export const mqSchedules = (
208
+ tableName = "effect_mq_schedules",
209
+ options?: MqTableOptions<ReturnType<typeof schedulesColumns>>
210
+ ) =>
211
+ pgTable(tableName, schedulesColumns(), (table) => [
212
+ index(`${tableName}_due_idx`).on(table.nextRunAt),
213
+ ...options?.extraConfig?.(table) ?? []
134
214
  ])
135
215
 
216
+ const dedupeColumns = () => ({
217
+ name: text("name").notNull(),
218
+ key: text("key").notNull(),
219
+ jobId: text("job_id").notNull().$type<JobId>(),
220
+ /** Set for ttl/throttle windows; NULL rows live as long as their job is pending. */
221
+ windowExpiresAt: timestamp("window_expires_at", { withTimezone: true, mode: "date" })
222
+ })
223
+
224
+ /**
225
+ * Dedup-key registry (one row per job name + dedup key; see `DedupePolicy`).
226
+ *
227
+ * @since 0.3.0
228
+ */
229
+ export const mqDedupe = (
230
+ tableName = "effect_mq_dedupe",
231
+ options?: MqTableOptions<ReturnType<typeof dedupeColumns>>
232
+ ) =>
233
+ pgTable(tableName, dedupeColumns(), (table) => [
234
+ primaryKey({ columns: [table.name, table.key] }),
235
+ ...options?.extraConfig?.(table) ?? []
236
+ ])
237
+
238
+ const queueControlColumns = () => ({
239
+ queue: text("queue").primaryKey().$type<QueueName>(),
240
+ paused: boolean("paused").notNull().default(false)
241
+ })
242
+
136
243
  /**
137
244
  * Durable queue control flags (pause/resume).
138
245
  *
139
246
  * @since 0.2.0
140
247
  */
141
- export const mqQueueControl = (tableName = "effect_mq_queue_control") =>
142
- pgTable(tableName, {
143
- queue: text("queue").primaryKey().$type<QueueName>(),
144
- paused: boolean("paused").notNull().default(false)
145
- })
248
+ export const mqQueueControl = (
249
+ tableName = "effect_mq_queue_control",
250
+ options?: MqTableOptions<ReturnType<typeof queueControlColumns>>
251
+ ) =>
252
+ pgTable(tableName, queueControlColumns(), (table) => [
253
+ ...options?.extraConfig?.(table) ?? []
254
+ ])
146
255
 
147
256
  /**
148
257
  * @since 0.1.0
@@ -163,3 +272,8 @@ export type MqSchedulesTable = ReturnType<typeof mqSchedules>
163
272
  * @since 0.2.0
164
273
  */
165
274
  export type MqQueueControlTable = ReturnType<typeof mqQueueControl>
275
+
276
+ /**
277
+ * @since 0.3.0
278
+ */
279
+ export type MqDedupeTable = ReturnType<typeof mqDedupe>
@@ -25,9 +25,11 @@ export interface RedisJobStoreOptions {
25
25
  readonly prefix?: string | undefined
26
26
  /**
27
27
  * Store-level retention ceiling: terminal records older than this are
28
- * removed by a periodic sweep. Per-job `keep` may only be stricter.
28
+ * removed by a periodic sweep one duration for all terminal states or a
29
+ * per-state split (`{ completed: "1 day", failed: "30 days" }`). The sweep
30
+ * also honours stricter per-job `keep.age` rules.
29
31
  */
30
- readonly historyTtl?: Duration.Input | undefined
32
+ readonly historyTtl?: JobStore.HistoryTtlInput | undefined
31
33
  /** History sweep cadence (default 1 minute). */
32
34
  readonly historySweepInterval?: Duration.Input | undefined
33
35
  /**
@@ -81,6 +83,7 @@ const toRecord = (hash: ReadonlyMap<string, string>): JobStore.JobRecord => ({
81
83
  keep: optionalJson<JobStore.KeepPolicy>(hash.get("keep")),
82
84
  timeoutMs: optionalNumber(hash.get("timeoutMs")),
83
85
  cancelRequested: hash.get("cancelRequested") === "1",
86
+ dedupeKey: optionalString(hash.get("dedupeKey")),
84
87
  runAt: Number(hash.get("runAt") ?? 0),
85
88
  enqueuedAt: Number(hash.get("enqueuedAt") ?? 0),
86
89
  processedAt: optionalNumber(hash.get("processedAt")),
@@ -151,23 +154,52 @@ export const make = (
151
154
  const evalListSchedules = redis.eval(scripts.listSchedules)
152
155
  const evalDueSchedules = redis.eval(scripts.dueSchedules)
153
156
  const evalAdvanceSchedule = redis.eval(scripts.advanceSchedule)
154
- const evalSweepHistory = redis.eval(scripts.sweepHistory)
157
+ const evalSweepState = redis.eval(scripts.sweepState)
158
+ const evalSweepDedupes = redis.eval(scripts.sweepDedupes)
155
159
 
156
- // Wake protocol: a local version + Deferred chain (same-process wake-ups
160
+ // Wake protocol: a queue-filtered waiter registry (same-process wake-ups
157
161
  // never depend on the pub/sub round trip), with the channel carrying
158
- // cross-process wake-ups. Same shape as the Postgres driver's NOTIFY.
162
+ // cross-process wake-ups the message names the queue ("*" broadcasts).
163
+ // Filtering matters at scale: without it every enqueue wakes every idle
164
+ // taker of every queue on the store.
159
165
  let wakeVersion = 0
160
- let wake = Deferred.makeUnsafe<void>()
161
- const signalWakeLocal = () => {
166
+ let lastBroadcast = 0
167
+ const lastWake = new Map<JobStore.QueueName, number>()
168
+ interface Waiter {
169
+ readonly queues: ReadonlySet<JobStore.QueueName>
170
+ readonly deferred: Deferred.Deferred<void>
171
+ }
172
+ const waiters = new Set<Waiter>()
173
+ const lastWakeFor = (queue: JobStore.QueueName) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast)
174
+ const signalWakeLocal = (queue?: JobStore.QueueName) => {
162
175
  wakeVersion += 1
163
- const current = wake
164
- wake = Deferred.makeUnsafe<void>()
165
- Deferred.doneUnsafe(current, Exit.succeed<void>(void 0))
176
+ if (queue === undefined) {
177
+ lastBroadcast = wakeVersion
178
+ } else {
179
+ lastWake.set(queue, wakeVersion)
180
+ }
181
+ // Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting
182
+ // fibers synchronously, and a woken taker that re-parks registers a
183
+ // NEW waiter — resolving inside the live Set iteration would visit it
184
+ // and livelock.
185
+ const toWake: Array<Waiter> = []
186
+ for (const waiter of waiters) {
187
+ if (queue === undefined || waiter.queues.has(queue)) {
188
+ waiters.delete(waiter)
189
+ toWake.push(waiter)
190
+ }
191
+ }
192
+ for (const waiter of toWake) {
193
+ Deferred.doneUnsafe(waiter.deferred, Exit.succeed<void>(void 0))
194
+ }
166
195
  }
167
- const wakeUp = Effect.suspend(() => {
168
- signalWakeLocal()
169
- return redis.send("PUBLISH", wakeChannel, "1").pipe(Effect.ignore)
170
- })
196
+ const wakeUp = (queue?: JobStore.QueueName): Effect.Effect<void> =>
197
+ Effect.suspend(() => {
198
+ signalWakeLocal(queue)
199
+ return redis.send("PUBLISH", wakeChannel, queue !== undefined && queue.length > 0 ? queue : "*").pipe(
200
+ Effect.ignore
201
+ )
202
+ })
171
203
 
172
204
  // Cross-process wake-ups. The pump resubscribes on connection loss (Bun
173
205
  // subscribers do not auto-reconnect); the retry delay only ever runs
@@ -176,8 +208,8 @@ export const make = (
176
208
  Effect.gen(function*() {
177
209
  const messages = yield* redis.subscribe(wakeChannel)
178
210
  while (true) {
179
- yield* Queue.take(messages)
180
- signalWakeLocal()
211
+ const message = yield* Queue.take(messages)
212
+ signalWakeLocal(message.message === "*" ? undefined : JobStore.QueueName(message.message))
181
213
  }
182
214
  })
183
215
  ).pipe(
@@ -187,13 +219,26 @@ export const make = (
187
219
  )
188
220
 
189
221
  if (options?.historyTtl !== undefined) {
190
- const ttlMs = Duration.toMillis(options.historyTtl)
222
+ const ttlByState = JobStore.normalizeHistoryTtl(options.historyTtl)
191
223
  const intervalMs = Duration.toMillis(options.historySweepInterval ?? "1 minute")
192
224
  yield* Effect.gen(function*() {
193
225
  yield* Effect.sleep(intervalMs)
194
226
  const now = yield* Clock.currentTimeMillis
195
- while ((yield* evalSweepHistory(prefix, now - ttlMs, 500)) !== "0") {
196
- // bounded batches until the window is clean
227
+ // Per-state ceilings refined by stricter per-row keep ages, paged by
228
+ // an offset cursor so young rows are visited once per sweep.
229
+ for (const state of ["completed", "failed", "cancelled"] as const) {
230
+ const ttl = ttlByState[state]
231
+ let offset = 0
232
+ while (true) {
233
+ const page: { scanned: number; deleted: number } = JSON.parse(
234
+ yield* evalSweepState(prefix, state, ttl === undefined ? "" : String(ttl), 200, offset, now)
235
+ )
236
+ if (page.scanned < 200) break
237
+ offset += page.scanned - page.deleted
238
+ }
239
+ }
240
+ while ((yield* evalSweepDedupes(prefix, 200, now)) !== "0") {
241
+ // bounded batches until the dedup backlog is clean
197
242
  }
198
243
  }).pipe(
199
244
  Effect.catchCause((cause) => Effect.logError("effect-mq: redis history sweep failed", cause)),
@@ -228,7 +273,11 @@ export const make = (
228
273
  request.keep === undefined ? "" : JSON.stringify(request.keep),
229
274
  request.timeoutMs === undefined ? "" : String(request.timeoutMs),
230
275
  Math.max(0, request.delayMs),
231
- now
276
+ now,
277
+ request.dedupe?.key ?? "",
278
+ request.dedupe?.ttlMs === undefined ? "" : String(request.dedupe.ttlMs),
279
+ request.dedupe?.extend === true ? "1" : "0",
280
+ request.dedupe?.replace === true ? "1" : "0"
232
281
  )
233
282
 
234
283
  const store: JobStore.Service = {
@@ -253,6 +302,7 @@ export const make = (
253
302
  wake?: boolean
254
303
  collision?: boolean
255
304
  error?: string
305
+ queue?: string
256
306
  } = JSON.parse(yield* enqueueOnce(request, mode, candidate, now))
257
307
  if (reply.collision === true) continue
258
308
  if (reply.error !== undefined || reply.id === undefined) {
@@ -261,7 +311,8 @@ export const make = (
261
311
  })
262
312
  }
263
313
  if (reply.wake === true) {
264
- yield* wakeUp
314
+ // A replace-while-delayed reply names the keyed job's queue.
315
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : request.queue)
265
316
  }
266
317
  return { id: JobStore.JobId(reply.id), duplicate: reply.duplicate === true }
267
318
  }
@@ -311,7 +362,7 @@ export const make = (
311
362
  ? ""
312
363
  : JSON.stringify(outcome.exit)
313
364
  const delayMs = outcome._tag === "Retry" ? Math.max(0, outcome.delayMs) : 0
314
- const reply: { error?: string; wake?: boolean } = JSON.parse(
365
+ const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
315
366
  yield* evalAck(prefix, id, token, outcome._tag, exitJson, delayMs, now).pipe(
316
367
  Effect.mapError(storeError("ack failed"))
317
368
  )
@@ -319,20 +370,20 @@ export const make = (
319
370
  if (reply.error === "notfound") return yield* new JobStore.JobNotFoundError({ jobId: id })
320
371
  if (reply.error === "locklost") return yield* new JobStore.LockLostError({ jobId: id })
321
372
  if (reply.wake === true) {
322
- yield* wakeUp
373
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
323
374
  }
324
375
  }),
325
376
 
326
377
  release: (id, token) =>
327
378
  Effect.gen(function*() {
328
379
  const now = yield* Clock.currentTimeMillis
329
- const reply: { error?: string; wake?: boolean } = JSON.parse(
380
+ const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
330
381
  yield* evalRelease(prefix, id, token, now).pipe(Effect.mapError(storeError("release failed")))
331
382
  )
332
383
  if (reply.error === "notfound") return yield* new JobStore.JobNotFoundError({ jobId: id })
333
384
  if (reply.error === "locklost") return yield* new JobStore.LockLostError({ jobId: id })
334
385
  if (reply.wake === true) {
335
- yield* wakeUp
386
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
336
387
  }
337
388
  }),
338
389
 
@@ -362,15 +413,19 @@ export const make = (
362
413
  )
363
414
  const result = recovered.map((entry) => ({ id: JobStore.JobId(entry.id), failed: entry.failed }))
364
415
  if (result.some((entry) => !entry.failed)) {
365
- yield* wakeUp
416
+ yield* wakeUp()
366
417
  }
367
418
  return result
368
419
  }).pipe(Effect.mapError(storeError("recoverStalled failed"))),
369
420
 
370
- awaitWake: (_queues, wakeToken) =>
421
+ awaitWake: (queues, wakeToken) =>
371
422
  Effect.suspend(() => {
372
- if (wakeVersion > wakeToken) return Effect.void
373
- return Deferred.await(wake)
423
+ if (queues.some((queue) => lastWakeFor(queue) > wakeToken)) return Effect.void
424
+ const waiter: Waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe<void>() }
425
+ waiters.add(waiter)
426
+ return Deferred.await(waiter.deferred).pipe(
427
+ Effect.ensuring(Effect.sync(() => waiters.delete(waiter)))
428
+ )
374
429
  }),
375
430
 
376
431
  getJob: (id) =>
@@ -435,14 +490,14 @@ export const make = (
435
490
  retry: (id) =>
436
491
  Effect.gen(function*() {
437
492
  const now = yield* Clock.currentTimeMillis
438
- const reply: { error?: string; state?: JobStore.JobState } = JSON.parse(
493
+ const reply: { error?: string; state?: JobStore.JobState; queue?: string } = JSON.parse(
439
494
  yield* evalRetry(prefix, id, now).pipe(Effect.mapError(storeError("retry failed")))
440
495
  )
441
496
  if (reply.error === "notfound") return yield* new JobStore.JobNotFoundError({ jobId: id })
442
497
  if (reply.error === "state") {
443
498
  return yield* new JobStore.JobNotRetryableError({ jobId: id, state: reply.state ?? "failed" })
444
499
  }
445
- yield* wakeUp
500
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
446
501
  }),
447
502
 
448
503
  counts: (queue) =>
@@ -491,14 +546,14 @@ export const make = (
491
546
  promote: (id) =>
492
547
  Effect.gen(function*() {
493
548
  const now = yield* Clock.currentTimeMillis
494
- const reply: { error?: string; state?: JobStore.JobState } = JSON.parse(
549
+ const reply: { error?: string; state?: JobStore.JobState; queue?: string } = JSON.parse(
495
550
  yield* evalPromote(prefix, id, now).pipe(Effect.mapError(storeError("promote failed")))
496
551
  )
497
552
  if (reply.error === "notfound") return yield* new JobStore.JobNotFoundError({ jobId: id })
498
553
  if (reply.error === "state") {
499
554
  return yield* new JobStore.JobNotPromotableError({ jobId: id, state: reply.state ?? "completed" })
500
555
  }
501
- yield* wakeUp
556
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
502
557
  }),
503
558
 
504
559
  pause: (queue) =>
@@ -510,7 +565,7 @@ export const make = (
510
565
  resume: (queue) =>
511
566
  redis.send("SREM", `${prefix}:paused`, queue).pipe(
512
567
  Effect.mapError(storeError("resume failed")),
513
- Effect.flatMap((removed) => Number(removed) > 0 ? wakeUp : Effect.void)
568
+ Effect.flatMap((removed) => Number(removed) > 0 ? wakeUp(queue) : Effect.void)
514
569
  ),
515
570
 
516
571
  pausedQueues: () =>
@@ -542,7 +597,7 @@ export const make = (
542
597
  schedule.nextRunAt
543
598
  ).pipe(
544
599
  Effect.mapError(storeError("upsertSchedule failed")),
545
- Effect.andThen(wakeUp)
600
+ Effect.andThen(wakeUp(schedule.queue))
546
601
  ),
547
602
 
548
603
  removeSchedule: (key) =>