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.
- package/README.md +153 -18
- package/dist/Job.d.ts +51 -1
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +44 -2
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +86 -6
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js +27 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts +6 -5
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +160 -32
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +1 -0
- package/dist/Worker.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts +20 -3
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +352 -84
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +255 -351
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +75 -27
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts +4 -2
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +67 -28
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +19 -6
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +208 -23
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +172 -7
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Job.ts +113 -6
- package/src/JobStore.ts +122 -6
- package/src/MemoryJobStore.ts +184 -44
- package/src/Worker.ts +1 -0
- package/src/drizzle-postgres/DrizzleJobStore.ts +431 -91
- package/src/drizzle-postgres/schema.ts +177 -63
- package/src/redis/RedisJobStore.ts +90 -35
- package/src/redis/scripts.ts +217 -24
- 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 {
|
|
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 = <
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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 = (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 = (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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?:
|
|
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
|
|
157
|
+
const evalSweepState = redis.eval(scripts.sweepState)
|
|
158
|
+
const evalSweepDedupes = redis.eval(scripts.sweepDedupes)
|
|
155
159
|
|
|
156
|
-
// Wake protocol: a
|
|
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
|
|
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
|
|
161
|
-
const
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 =
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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: (
|
|
421
|
+
awaitWake: (queues, wakeToken) =>
|
|
371
422
|
Effect.suspend(() => {
|
|
372
|
-
if (
|
|
373
|
-
|
|
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) =>
|