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
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* @since 0.1.0
|
|
18
18
|
*/
|
|
19
19
|
import * as JobStore from "../JobStore.js";
|
|
20
|
-
import { asc, eq, sql } from "drizzle-orm";
|
|
20
|
+
import { asc, eq, getTableColumns, sql } from "drizzle-orm";
|
|
21
21
|
import * as PgDrizzle from "drizzle-orm/effect-postgres";
|
|
22
22
|
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
23
23
|
import { Clock, Deferred, Duration, Effect, Layer, Option, Stream } from "effect";
|
|
@@ -55,6 +55,7 @@ const toRecord = (row) => ({
|
|
|
55
55
|
keep: row.keep ?? undefined,
|
|
56
56
|
timeoutMs: row.timeoutMs === null || row.timeoutMs === undefined ? undefined : Number(row.timeoutMs),
|
|
57
57
|
cancelRequested: row.cancelRequested,
|
|
58
|
+
dedupeKey: row.dedupeKey ?? undefined,
|
|
58
59
|
runAt: row.runAt.getTime(),
|
|
59
60
|
enqueuedAt: row.enqueuedAt.getTime(),
|
|
60
61
|
processedAt: row.processedAt?.getTime(),
|
|
@@ -75,51 +76,149 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
75
76
|
const attempts = options.attempts;
|
|
76
77
|
const schedules = options.schedules;
|
|
77
78
|
const queues = options.queues;
|
|
79
|
+
const dedupe = options.dedupe;
|
|
78
80
|
const jobsName = getTableConfig(jobs).name;
|
|
79
81
|
const attemptsName = getTableConfig(attempts).name;
|
|
80
82
|
const wakeChannel = `effect_mq_wake_${jobsName}`;
|
|
83
|
+
// Columns the user added via `mqJobs({ extend })`: everything beyond the
|
|
84
|
+
// factory's own set. They are written at enqueue (and on dedupe replace)
|
|
85
|
+
// from `extraValues` or the metadata entry with the same TS key.
|
|
86
|
+
const BASE_JOB_COLUMNS = new Set([
|
|
87
|
+
"id",
|
|
88
|
+
"name",
|
|
89
|
+
"queue",
|
|
90
|
+
"state",
|
|
91
|
+
"priority",
|
|
92
|
+
"seq",
|
|
93
|
+
"payload",
|
|
94
|
+
"metadata",
|
|
95
|
+
"attemptsMax",
|
|
96
|
+
"attemptsMade",
|
|
97
|
+
"stalledCount",
|
|
98
|
+
"backoff",
|
|
99
|
+
"keep",
|
|
100
|
+
"timeoutMs",
|
|
101
|
+
"cancelRequested",
|
|
102
|
+
"dedupeKey",
|
|
103
|
+
"runAt",
|
|
104
|
+
"enqueuedAt",
|
|
105
|
+
"processedAt",
|
|
106
|
+
"finishedAt",
|
|
107
|
+
"exit",
|
|
108
|
+
"failedReason",
|
|
109
|
+
"lockToken",
|
|
110
|
+
"lockExpiresAt"
|
|
111
|
+
]);
|
|
112
|
+
const extendedColumns = Object.entries(getTableColumns(jobs))
|
|
113
|
+
.filter(([key]) => !BASE_JOB_COLUMNS.has(key))
|
|
114
|
+
.map(([key, column]) => ({ key, name: column.name }));
|
|
115
|
+
const extraColumnNames = extendedColumns.length === 0
|
|
116
|
+
? sql ``
|
|
117
|
+
: sql.join(extendedColumns.map((column) => sql `, ${sql.identifier(column.name)}`));
|
|
118
|
+
const extraColumnValues = (request) => {
|
|
119
|
+
if (extendedColumns.length === 0)
|
|
120
|
+
return sql ``;
|
|
121
|
+
const mapped = options.extraValues?.(request) ?? {};
|
|
122
|
+
return sql.join(extendedColumns.map((column) => sql `, ${Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null}`));
|
|
123
|
+
};
|
|
124
|
+
const extraColumnAssignments = (request) => {
|
|
125
|
+
if (extendedColumns.length === 0)
|
|
126
|
+
return sql ``;
|
|
127
|
+
const mapped = options.extraValues?.(request) ?? {};
|
|
128
|
+
return sql.join(extendedColumns.map((column) => sql `, ${sql.identifier(column.name)} = ${Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null}`));
|
|
129
|
+
};
|
|
81
130
|
if (options.validate ?? true) {
|
|
82
131
|
yield* Effect.all([
|
|
83
132
|
db.select({ id: jobs.id }).from(jobs).limit(0),
|
|
84
133
|
db.select({ jobId: attempts.jobId }).from(attempts).limit(0),
|
|
85
134
|
db.select({ key: schedules.key }).from(schedules).limit(0),
|
|
86
|
-
db.select({ queue: queues.queue }).from(queues).limit(0)
|
|
135
|
+
db.select({ queue: queues.queue }).from(queues).limit(0),
|
|
136
|
+
db.select({ key: dedupe.key }).from(dedupe).limit(0)
|
|
87
137
|
]).pipe(Effect.mapError(storeError(`effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
|
|
88
138
|
`re-export the effect-mq/drizzle schema factories from your drizzle schema and run your migrations (drizzle-kit generate)`)));
|
|
89
139
|
}
|
|
90
|
-
// Wake plumbing: a
|
|
140
|
+
// Wake plumbing: a queue-filtered waiter registry (same protocol as the
|
|
91
141
|
// memory driver), fed by (a) local store operations and (b) cross-process
|
|
92
|
-
// LISTEN notifications.
|
|
142
|
+
// LISTEN notifications whose payload names the queue ("*" broadcasts).
|
|
143
|
+
// Filtering matters at scale: without it every enqueue wakes every idle
|
|
144
|
+
// taker of every queue on the store.
|
|
93
145
|
let wakeVersion = 0;
|
|
94
|
-
let
|
|
95
|
-
const
|
|
146
|
+
let lastBroadcast = 0;
|
|
147
|
+
const lastWake = new Map();
|
|
148
|
+
const waiters = new Set();
|
|
149
|
+
const lastWakeFor = (queue) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast);
|
|
150
|
+
const signalWake = (queue) => {
|
|
96
151
|
wakeVersion += 1;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
152
|
+
if (queue === undefined) {
|
|
153
|
+
lastBroadcast = wakeVersion;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
lastWake.set(queue, wakeVersion);
|
|
157
|
+
}
|
|
158
|
+
// Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting
|
|
159
|
+
// fibers synchronously, and a woken taker that re-parks registers a
|
|
160
|
+
// NEW waiter — resolving inside the live Set iteration would visit it
|
|
161
|
+
// and livelock.
|
|
162
|
+
const toWake = [];
|
|
163
|
+
for (const waiter of waiters) {
|
|
164
|
+
if (queue === undefined || waiter.queues.has(queue)) {
|
|
165
|
+
waiters.delete(waiter);
|
|
166
|
+
toWake.push(waiter);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const waiter of toWake) {
|
|
170
|
+
Deferred.doneUnsafe(waiter.deferred, Effect.void);
|
|
171
|
+
}
|
|
100
172
|
};
|
|
101
173
|
// Resubscribe forever: if the LISTEN stream ends or fails, wake-ups
|
|
102
174
|
// degrade to the worker's pollInterval until the next attempt succeeds.
|
|
103
|
-
yield* client.listen(wakeChannel).pipe(Stream.runForEach(() => Effect.sync(signalWake)), Effect.catchCause((cause) => Effect.logWarning("effect-mq: LISTEN subscription failed; wake-ups degraded to polling until resubscribe", cause)), Effect.andThen(Effect.sleep("1 second")), Effect.forever, Effect.forkScoped);
|
|
175
|
+
yield* client.listen(wakeChannel).pipe(Stream.runForEach((payload) => Effect.sync(() => signalWake(payload === "*" ? undefined : JobStore.QueueName(payload)))), Effect.catchCause((cause) => Effect.logWarning("effect-mq: LISTEN subscription failed; wake-ups degraded to polling until resubscribe", cause)), Effect.andThen(Effect.sleep("1 second")), Effect.forever, Effect.forkScoped);
|
|
104
176
|
if (options.historyTtl !== undefined) {
|
|
105
|
-
const
|
|
177
|
+
const ttlByState = JobStore.normalizeHistoryTtl(options.historyTtl);
|
|
106
178
|
const sweepMs = Duration.toMillis(options.historySweepInterval ?? "1 minute");
|
|
107
179
|
yield* Effect.gen(function* () {
|
|
108
180
|
yield* Effect.sleep(sweepMs);
|
|
109
181
|
const now = yield* nowDate;
|
|
182
|
+
// Per-state ceilings, refined by stricter per-row keep ages — a quiet
|
|
183
|
+
// job name is pruned on the timer, not only when its group is acked.
|
|
184
|
+
for (const state of ["completed", "failed", "cancelled"]) {
|
|
185
|
+
const ttl = ttlByState[state];
|
|
186
|
+
yield* db.execute(sql `
|
|
187
|
+
DELETE FROM ${jobs}
|
|
188
|
+
WHERE ${jobs.state} = ${state} AND (
|
|
189
|
+
${ttl !== undefined ? sql `${jobs.finishedAt} <= ${new Date(now.getTime() - ttl)}` : sql `FALSE`}
|
|
190
|
+
OR (
|
|
191
|
+
COALESCE(
|
|
192
|
+
${jobs.keep}->${state}->>'ageMs',
|
|
193
|
+
CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
|
|
194
|
+
ELSE ${jobs.keep}->>'ageMs' END
|
|
195
|
+
) IS NOT NULL
|
|
196
|
+
AND ${jobs.finishedAt} <= ${now}::timestamptz
|
|
197
|
+
- make_interval(secs => (COALESCE(
|
|
198
|
+
${jobs.keep}->${state}->>'ageMs',
|
|
199
|
+
CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
|
|
200
|
+
ELSE ${jobs.keep}->>'ageMs' END
|
|
201
|
+
)::double precision) / 1000.0))
|
|
202
|
+
)
|
|
203
|
+
`);
|
|
204
|
+
}
|
|
205
|
+
// Dead dedup rows: expired windows, or pointers at vanished jobs.
|
|
110
206
|
yield* db.execute(sql `
|
|
111
|
-
DELETE FROM ${
|
|
112
|
-
WHERE ${
|
|
113
|
-
|
|
207
|
+
DELETE FROM ${dedupe}
|
|
208
|
+
WHERE (${dedupe.windowExpiresAt} IS NOT NULL AND ${dedupe.windowExpiresAt} <= ${now})
|
|
209
|
+
OR (${dedupe.windowExpiresAt} IS NULL AND NOT EXISTS (
|
|
210
|
+
SELECT 1 FROM ${jobs} WHERE ${jobs.id} = ${dedupe.jobId}
|
|
211
|
+
AND ${jobs.state} IN ('waiting', 'delayed', 'active')
|
|
212
|
+
))
|
|
114
213
|
`);
|
|
115
214
|
}).pipe(Effect.catchCause((cause) => Effect.logWarning("effect-mq: history sweep failed", cause)), Effect.forever, Effect.forkScoped);
|
|
116
215
|
}
|
|
117
216
|
// Local mutation wake-up: bump synchronously, then best-effort NOTIFY so
|
|
118
|
-
// workers in other processes wake promptly too.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return client.notify(wakeChannel, "
|
|
217
|
+
// workers in other processes wake promptly too. The payload names the
|
|
218
|
+
// queue (and must be non-empty: @effect/sql-pg drops falsy payloads).
|
|
219
|
+
const wakeUp = (queue) => Effect.suspend(() => {
|
|
220
|
+
signalWake(queue);
|
|
221
|
+
return client.notify(wakeChannel, queue !== undefined && queue.length > 0 ? queue : "*").pipe(Effect.ignore);
|
|
123
222
|
});
|
|
124
223
|
const nowDate = Effect.map(Clock.currentTimeMillis, (ms) => new Date(ms));
|
|
125
224
|
// quote_ident handles quoted/mixed-case table names safely.
|
|
@@ -131,9 +230,30 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
131
230
|
FROM ${attempts} WHERE ${attempts.jobId} = ${jobId}
|
|
132
231
|
`);
|
|
133
232
|
// Retention: drop terminal peers (same name + state) beyond count/age.
|
|
134
|
-
const
|
|
135
|
-
const keep = row.keep;
|
|
233
|
+
const keepPolicyFor = (keep, state) => {
|
|
136
234
|
if (keep === null || keep === undefined)
|
|
235
|
+
return undefined;
|
|
236
|
+
const policy = state === "completed"
|
|
237
|
+
? keep.completed
|
|
238
|
+
: state === "failed"
|
|
239
|
+
? keep.failed
|
|
240
|
+
: state === "cancelled"
|
|
241
|
+
? keep.cancelled
|
|
242
|
+
: undefined;
|
|
243
|
+
if (policy !== undefined)
|
|
244
|
+
return policy;
|
|
245
|
+
// Rows persisted by 0.2.x carry the flat {count, ageMs} shape — honour
|
|
246
|
+
// it as an all-states policy so upgrades keep pruning.
|
|
247
|
+
if (keep.completed === undefined && keep.failed === undefined && keep.cancelled === undefined &&
|
|
248
|
+
("count" in keep || "ageMs" in keep)) {
|
|
249
|
+
// SAFETY: the flat legacy shape carries KeepStatePolicy fields.
|
|
250
|
+
return keep;
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
};
|
|
254
|
+
const applyKeep = (tx, row, now) => Effect.gen(function* () {
|
|
255
|
+
const keep = keepPolicyFor(row.keep, row.state);
|
|
256
|
+
if (keep === undefined)
|
|
137
257
|
return;
|
|
138
258
|
if (keep.ageMs !== undefined) {
|
|
139
259
|
yield* tx.execute(sql `
|
|
@@ -159,52 +279,186 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
159
279
|
const explainMiss = (id) => db.select({ id: jobs.id }).from(jobs).where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("failed to inspect job")), Effect.flatMap((rows) => Effect.fail(rows.length === 0
|
|
160
280
|
? new JobStore.JobNotFoundError({ jobId: id })
|
|
161
281
|
: new JobStore.LockLostError({ jobId: id }))));
|
|
282
|
+
// The shared INSERT: store-assigned ids come from the configured
|
|
283
|
+
// generator (or the seq sequence); loop on the (unlikely) collision with
|
|
284
|
+
// an existing id — ON CONFLICT DO NOTHING makes the retry safe. Returns
|
|
285
|
+
// the result or undefined when the caller-supplied id already exists.
|
|
286
|
+
const insertJob = (exec, request, now) => Effect.gen(function* () {
|
|
287
|
+
const runAt = new Date(now.getTime() + Math.max(0, request.delayMs));
|
|
288
|
+
const state = request.delayMs > 0 ? "delayed" : "waiting";
|
|
289
|
+
const generate = options.idGenerator;
|
|
290
|
+
for (let i = 0; i < 5; i++) {
|
|
291
|
+
const generated = request.id === undefined && generate !== undefined
|
|
292
|
+
? yield* Effect.suspend(() => {
|
|
293
|
+
const raw = generate(request);
|
|
294
|
+
return Effect.isEffect(raw) ? raw : Effect.succeed(raw);
|
|
295
|
+
})
|
|
296
|
+
: undefined;
|
|
297
|
+
const idExpr = request.id !== undefined
|
|
298
|
+
? sql `${request.id}`
|
|
299
|
+
: generated !== undefined
|
|
300
|
+
? sql `${generated}`
|
|
301
|
+
: sql `'j-' || ${seqExpr}::text`;
|
|
302
|
+
const rows = rowsOf(yield* exec.execute(sql `
|
|
303
|
+
INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
|
|
304
|
+
attempts_max, backoff, keep, timeout_ms, dedupe_key, run_at, enqueued_at${extraColumnNames})
|
|
305
|
+
VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
|
|
306
|
+
${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
|
|
307
|
+
${request.attemptsMax},
|
|
308
|
+
${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
|
|
309
|
+
${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
|
|
310
|
+
${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null}, ${runAt}, ${now}${extraColumnValues(request)})
|
|
311
|
+
ON CONFLICT (id) DO NOTHING
|
|
312
|
+
RETURNING ${jobs.id} AS id
|
|
313
|
+
`).pipe(Effect.mapError(storeError("enqueue failed"))));
|
|
314
|
+
const inserted = rows[0];
|
|
315
|
+
if (inserted !== undefined) {
|
|
316
|
+
return { id: JobId(inserted.id), duplicate: false };
|
|
317
|
+
}
|
|
318
|
+
if (request.id !== undefined) {
|
|
319
|
+
return { id: request.id, duplicate: true };
|
|
320
|
+
}
|
|
321
|
+
// generated id collided with an existing user id; try again
|
|
322
|
+
}
|
|
323
|
+
return yield* new JobStore.JobStoreError({
|
|
324
|
+
message: "enqueue failed: could not generate a unique job id"
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
// Enqueue with a dedup policy: one transaction locks the (name, key) row
|
|
328
|
+
// and applies the decision tree (replace-while-delayed, throttle window,
|
|
329
|
+
// pending dedup) before falling through to a fresh insert.
|
|
330
|
+
const enqueueDeduped = (request, policy) => db.transaction((tx) => Effect.gen(function* () {
|
|
331
|
+
const now = yield* nowDate;
|
|
332
|
+
// The explicit-id duplicate check precedes the dedup tree, matching
|
|
333
|
+
// the memory and redis drivers.
|
|
334
|
+
if (request.id !== undefined) {
|
|
335
|
+
const existing = rowsOf(yield* tx.execute(sql `
|
|
336
|
+
SELECT ${jobs.id} AS id FROM ${jobs} WHERE ${jobs.id} = ${request.id}
|
|
337
|
+
`));
|
|
338
|
+
if (existing.length > 0) {
|
|
339
|
+
return { id: request.id, duplicate: true, wake: false };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// A SELECT FOR UPDATE on a missing row locks nothing, so two
|
|
343
|
+
// concurrent first-enqueues would both insert. The no-op upsert
|
|
344
|
+
// always takes the row lock: a fresh placeholder (job_id = '')
|
|
345
|
+
// reads as "no entry" and falls through to the insert below.
|
|
346
|
+
const rows = rowsOf(yield* tx.execute(sql `
|
|
347
|
+
INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
|
|
348
|
+
VALUES (${request.name}, ${policy.key}, '', NULL)
|
|
349
|
+
ON CONFLICT (name, key) DO UPDATE SET name = EXCLUDED.name
|
|
350
|
+
RETURNING ${dedupe.jobId} AS "jobId", ${dedupe.windowExpiresAt} AS "windowExpiresAt"
|
|
351
|
+
`));
|
|
352
|
+
const entry = rows[0];
|
|
353
|
+
if (entry !== undefined && entry.jobId !== "") {
|
|
354
|
+
// Plain read (no FOR UPDATE): locking the job row here would
|
|
355
|
+
// invert the jobs-then-dedupe lock order every terminal
|
|
356
|
+
// transition uses and deadlock under load. The replace branch
|
|
357
|
+
// compensates with a state-conditional UPDATE.
|
|
358
|
+
const keyed = rowsOf(yield* tx.execute(sql `
|
|
359
|
+
SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${entry.jobId}
|
|
360
|
+
`));
|
|
361
|
+
const keyedState = keyed[0]?.state;
|
|
362
|
+
const windowLive = entry.windowExpiresAt !== null &&
|
|
363
|
+
entry.windowExpiresAt.getTime() > now.getTime();
|
|
364
|
+
const bumpWindow = policy.extend && policy.ttlMs !== undefined
|
|
365
|
+
? tx.execute(sql `
|
|
366
|
+
UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
|
|
367
|
+
WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
|
|
368
|
+
`).pipe(Effect.asVoid)
|
|
369
|
+
: Effect.void;
|
|
370
|
+
// Latest-wins while the keyed job is still delayed. The UPDATE
|
|
371
|
+
// re-checks the state so a concurrent claim degrades this to a
|
|
372
|
+
// plain dedup instead of rewriting an active job.
|
|
373
|
+
if (policy.replace && keyedState === "delayed") {
|
|
374
|
+
const replaced = rowsOf(yield* tx.execute(sql `
|
|
375
|
+
UPDATE ${jobs} SET
|
|
376
|
+
payload = ${JSON.stringify(request.payload ?? null)}::jsonb,
|
|
377
|
+
metadata = ${JSON.stringify(request.metadata)}::jsonb,
|
|
378
|
+
priority = ${request.priority},
|
|
379
|
+
attempts_max = ${request.attemptsMax},
|
|
380
|
+
backoff = ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
|
|
381
|
+
keep = ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
|
|
382
|
+
timeout_ms = ${request.timeoutMs ?? null},
|
|
383
|
+
run_at = ${new Date(now.getTime() + Math.max(0, request.delayMs))}${extraColumnAssignments(request)}
|
|
384
|
+
WHERE ${jobs.id} = ${entry.jobId} AND ${jobs.state} = 'delayed'
|
|
385
|
+
RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
|
|
386
|
+
`));
|
|
387
|
+
if (replaced.length > 0) {
|
|
388
|
+
// A landed replace re-arms the ttl window (the entry must
|
|
389
|
+
// outlive the chain it is deduplicating).
|
|
390
|
+
if (policy.ttlMs !== undefined) {
|
|
391
|
+
yield* tx.execute(sql `
|
|
392
|
+
UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
|
|
393
|
+
WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
|
|
394
|
+
`);
|
|
395
|
+
}
|
|
396
|
+
// The replace does not move the job between queues — wake
|
|
397
|
+
// the queue that actually holds the now-rescheduled job.
|
|
398
|
+
return {
|
|
399
|
+
id: JobId(entry.jobId),
|
|
400
|
+
duplicate: true,
|
|
401
|
+
wake: true,
|
|
402
|
+
wakeQueue: JobStore.QueueName(replaced[0]?.queue ?? request.queue)
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return { id: JobId(entry.jobId), duplicate: true, wake: false };
|
|
406
|
+
}
|
|
407
|
+
if (windowLive) {
|
|
408
|
+
yield* bumpWindow;
|
|
409
|
+
return { id: JobId(entry.jobId), duplicate: true, wake: false };
|
|
410
|
+
}
|
|
411
|
+
const pending = keyedState !== undefined && keyedState !== "completed" &&
|
|
412
|
+
keyedState !== "failed" && keyedState !== "cancelled";
|
|
413
|
+
if (entry.windowExpiresAt === null && pending) {
|
|
414
|
+
return { id: JobId(entry.jobId), duplicate: true, wake: false };
|
|
415
|
+
}
|
|
416
|
+
// Dead entry: the new job takes over the key below.
|
|
417
|
+
}
|
|
418
|
+
const result = yield* insertJob(tx, request, now);
|
|
419
|
+
if (!result.duplicate) {
|
|
420
|
+
yield* tx.execute(sql `
|
|
421
|
+
INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
|
|
422
|
+
VALUES (${request.name}, ${policy.key}, ${result.id},
|
|
423
|
+
${policy.ttlMs === undefined ? null : new Date(now.getTime() + policy.ttlMs)})
|
|
424
|
+
ON CONFLICT (name, key) DO UPDATE SET
|
|
425
|
+
job_id = EXCLUDED.job_id, window_expires_at = EXCLUDED.window_expires_at
|
|
426
|
+
`);
|
|
427
|
+
}
|
|
428
|
+
return { ...result, wake: !result.duplicate };
|
|
429
|
+
})).pipe(
|
|
430
|
+
// Residual lock-order inversions (replace vs cancel of the same
|
|
431
|
+
// delayed job) surface as Postgres deadlocks (40P01); one side is
|
|
432
|
+
// killed and safe to retry.
|
|
433
|
+
Effect.retry({
|
|
434
|
+
times: 3,
|
|
435
|
+
while: (error) => String(error).includes("40P01") || String(error).includes("deadlock detected")
|
|
436
|
+
}), Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("enqueue failed")(error)));
|
|
437
|
+
// A job leaving the pending states frees its pending-mode dedup row; live
|
|
438
|
+
// throttle windows deliberately outlast the job.
|
|
439
|
+
const releaseDedupe = (exec, name, dedupeKey, jobId, now) => dedupeKey === null
|
|
440
|
+
? Effect.void
|
|
441
|
+
: exec.execute(sql `
|
|
442
|
+
DELETE FROM ${dedupe}
|
|
443
|
+
WHERE ${dedupe.name} = ${name} AND ${dedupe.key} = ${dedupeKey}
|
|
444
|
+
AND ${dedupe.jobId} = ${jobId}
|
|
445
|
+
AND (${dedupe.windowExpiresAt} IS NULL OR ${dedupe.windowExpiresAt} <= ${now})
|
|
446
|
+
`).pipe(Effect.asVoid);
|
|
162
447
|
const store = {
|
|
163
448
|
enqueue: (request) => Effect.gen(function* () {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// seq sequence); loop on the (unlikely) collision with an existing
|
|
169
|
-
// id — ON CONFLICT DO NOTHING makes the retry safe.
|
|
170
|
-
const generate = options.idGenerator;
|
|
171
|
-
for (let i = 0; i < 5; i++) {
|
|
172
|
-
const generated = request.id === undefined && generate !== undefined
|
|
173
|
-
? yield* Effect.suspend(() => {
|
|
174
|
-
const raw = generate(request);
|
|
175
|
-
return Effect.isEffect(raw) ? raw : Effect.succeed(raw);
|
|
176
|
-
})
|
|
177
|
-
: undefined;
|
|
178
|
-
const idExpr = request.id !== undefined
|
|
179
|
-
? sql `${request.id}`
|
|
180
|
-
: generated !== undefined
|
|
181
|
-
? sql `${generated}`
|
|
182
|
-
: sql `'j-' || ${seqExpr}::text`;
|
|
183
|
-
const rows = rowsOf(yield* db.execute(sql `
|
|
184
|
-
INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
|
|
185
|
-
attempts_max, backoff, keep, timeout_ms, run_at, enqueued_at)
|
|
186
|
-
VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
|
|
187
|
-
${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
|
|
188
|
-
${request.attemptsMax},
|
|
189
|
-
${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
|
|
190
|
-
${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
|
|
191
|
-
${request.timeoutMs ?? null}, ${runAt}, ${now})
|
|
192
|
-
ON CONFLICT (id) DO NOTHING
|
|
193
|
-
RETURNING ${jobs.id} AS id
|
|
194
|
-
`).pipe(Effect.mapError(storeError("enqueue failed"))));
|
|
195
|
-
const inserted = rows[0];
|
|
196
|
-
if (inserted !== undefined) {
|
|
197
|
-
yield* wakeUp;
|
|
198
|
-
return { id: JobId(inserted.id), duplicate: false };
|
|
199
|
-
}
|
|
200
|
-
if (request.id !== undefined) {
|
|
201
|
-
return { id: request.id, duplicate: true };
|
|
449
|
+
if (request.dedupe !== undefined) {
|
|
450
|
+
const result = yield* enqueueDeduped(request, request.dedupe);
|
|
451
|
+
if (result.wake) {
|
|
452
|
+
yield* wakeUp("wakeQueue" in result && result.wakeQueue !== undefined ? result.wakeQueue : request.queue);
|
|
202
453
|
}
|
|
203
|
-
|
|
454
|
+
return { id: result.id, duplicate: result.duplicate };
|
|
204
455
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
456
|
+
const now = yield* nowDate;
|
|
457
|
+
const result = yield* insertJob(db, request, now);
|
|
458
|
+
if (!result.duplicate) {
|
|
459
|
+
yield* wakeUp(request.queue);
|
|
460
|
+
}
|
|
461
|
+
return result;
|
|
208
462
|
}),
|
|
209
463
|
claim: (claimOptions) => Effect.suspend(() => {
|
|
210
464
|
// Snapshot BEFORE the transaction: any wake that fires while the
|
|
@@ -244,7 +498,7 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
244
498
|
${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
|
|
245
499
|
${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
|
|
246
500
|
${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
|
|
247
|
-
${jobs.cancelRequested} AS "cancelRequested",
|
|
501
|
+
${jobs.cancelRequested} AS "cancelRequested", ${jobs.dedupeKey} AS "dedupeKey",
|
|
248
502
|
${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
|
|
249
503
|
${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
|
|
250
504
|
${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
|
|
@@ -289,7 +543,8 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
289
543
|
attempts_made = ${jobs.attemptsMade} + 1, lock_token = NULL, lock_expires_at = NULL
|
|
290
544
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
|
|
291
545
|
RETURNING ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name",
|
|
292
|
-
${jobs.state} AS "state", ${jobs.keep} AS "keep"
|
|
546
|
+
${jobs.state} AS "state", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey",
|
|
547
|
+
${jobs.queue} AS "queue"
|
|
293
548
|
`));
|
|
294
549
|
const row = rows[0];
|
|
295
550
|
if (row === undefined) {
|
|
@@ -305,14 +560,18 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
305
560
|
: "retried";
|
|
306
561
|
yield* insertAttempt(tx, id, ledgerOutcome, row.processedAt, now, outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit);
|
|
307
562
|
if (outcome._tag !== "Retry" || cancelledRetry) {
|
|
563
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
|
|
308
564
|
yield* applyKeep(tx, row, now);
|
|
309
565
|
}
|
|
566
|
+
return outcome._tag === "Retry" && !cancelledRetry
|
|
567
|
+
? JobStore.QueueName(row.queue)
|
|
568
|
+
: undefined;
|
|
310
569
|
})).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError || error instanceof JobStore.LockLostError ||
|
|
311
570
|
error instanceof JobStore.JobStoreError
|
|
312
571
|
? error
|
|
313
|
-
: storeError("ack failed")(error)), Effect.tap(() =>
|
|
572
|
+
: storeError("ack failed")(error)), Effect.tap((queue) => queue !== undefined ? wakeUp(queue) : Effect.void), Effect.asVoid),
|
|
314
573
|
release: (id, token) => Effect.gen(function* () {
|
|
315
|
-
const
|
|
574
|
+
const released = yield* db.transaction((tx) => Effect.gen(function* () {
|
|
316
575
|
const now = yield* nowDate;
|
|
317
576
|
// A cancel that arrived while the worker was shutting down is
|
|
318
577
|
// honoured instead of reviving the job.
|
|
@@ -324,22 +583,24 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
324
583
|
lock_token = NULL, lock_expires_at = NULL
|
|
325
584
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
|
|
326
585
|
RETURNING ${jobs.id} AS id, (${jobs.state} = 'cancelled') AS cancelled,
|
|
327
|
-
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
|
|
586
|
+
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
|
|
587
|
+
${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue"
|
|
328
588
|
`));
|
|
329
589
|
const row = rows[0];
|
|
330
590
|
if (row === undefined)
|
|
331
591
|
return undefined;
|
|
332
592
|
if (row.cancelled) {
|
|
333
593
|
yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
|
|
594
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
|
|
334
595
|
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
|
|
335
596
|
}
|
|
336
|
-
return row.cancelled;
|
|
597
|
+
return { cancelled: row.cancelled, queue: JobStore.QueueName(row.queue) };
|
|
337
598
|
})).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("release failed")(error)));
|
|
338
|
-
if (
|
|
599
|
+
if (released === undefined) {
|
|
339
600
|
return yield* explainMiss(id);
|
|
340
601
|
}
|
|
341
|
-
if (!cancelled) {
|
|
342
|
-
yield* wakeUp;
|
|
602
|
+
if (!released.cancelled) {
|
|
603
|
+
yield* wakeUp(released.queue);
|
|
343
604
|
}
|
|
344
605
|
}),
|
|
345
606
|
extendLocks: (locks, durationMs) => Effect.gen(function* () {
|
|
@@ -399,11 +660,14 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
399
660
|
cancel_requested = FALSE
|
|
400
661
|
WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
|
|
401
662
|
RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt",
|
|
402
|
-
${jobs.name} AS "name", ${jobs.keep} AS "keep"
|
|
663
|
+
${jobs.name} AS "name", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey"
|
|
403
664
|
`));
|
|
404
665
|
const recovered = [];
|
|
405
666
|
for (const row of rows) {
|
|
406
667
|
yield* insertAttempt(tx, row.id, row.state === "cancelled" ? "cancelled" : "stalled", row.processedAt, now, undefined);
|
|
668
|
+
if (row.state === "cancelled" || row.state === "failed") {
|
|
669
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, row.id, now);
|
|
670
|
+
}
|
|
407
671
|
if (row.state === "cancelled") {
|
|
408
672
|
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
|
|
409
673
|
}
|
|
@@ -412,11 +676,13 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
412
676
|
}
|
|
413
677
|
}
|
|
414
678
|
return recovered;
|
|
415
|
-
})).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("recoverStalled failed")(error)), Effect.tap((recovered) => recovered.some((entry) => !entry.failed) ? wakeUp : Effect.void)),
|
|
416
|
-
awaitWake: (
|
|
417
|
-
if (
|
|
679
|
+
})).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("recoverStalled failed")(error)), Effect.tap((recovered) => recovered.some((entry) => !entry.failed) ? wakeUp() : Effect.void)),
|
|
680
|
+
awaitWake: (queues, wakeToken) => Effect.suspend(() => {
|
|
681
|
+
if (queues.some((queue) => lastWakeFor(queue) > wakeToken))
|
|
418
682
|
return Effect.void;
|
|
419
|
-
|
|
683
|
+
const waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe() };
|
|
684
|
+
waiters.add(waiter);
|
|
685
|
+
return Deferred.await(waiter.deferred).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(waiter))));
|
|
420
686
|
}),
|
|
421
687
|
getJob: (id) => db.select().from(jobs).where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("getJob failed")), Effect.map((rows) => {
|
|
422
688
|
const row = rows[0];
|
|
@@ -483,7 +749,7 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
483
749
|
exit = NULL, failed_reason = NULL, finished_at = NULL, processed_at = NULL,
|
|
484
750
|
run_at = ${now}, seq = ${seqExpr}
|
|
485
751
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'failed'
|
|
486
|
-
RETURNING ${jobs.id} AS id
|
|
752
|
+
RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
|
|
487
753
|
`).pipe(Effect.mapError(storeError("retry failed"))));
|
|
488
754
|
if (rows.length === 0) {
|
|
489
755
|
const existing = yield* db.select({ state: jobs.state }).from(jobs)
|
|
@@ -494,7 +760,7 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
494
760
|
}
|
|
495
761
|
return yield* new JobStore.JobNotRetryableError({ jobId: id, state: found.state });
|
|
496
762
|
}
|
|
497
|
-
yield* wakeUp;
|
|
763
|
+
yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""));
|
|
498
764
|
}),
|
|
499
765
|
cancel: (id) => db.transaction((tx) => Effect.gen(function* () {
|
|
500
766
|
const now = yield* nowDate;
|
|
@@ -507,7 +773,8 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
507
773
|
cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
|
|
508
774
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
|
|
509
775
|
RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
|
|
510
|
-
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
|
|
776
|
+
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
|
|
777
|
+
${jobs.dedupeKey} AS "dedupeKey"
|
|
511
778
|
`));
|
|
512
779
|
const row = rows[0];
|
|
513
780
|
if (row === undefined) {
|
|
@@ -522,6 +789,7 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
522
789
|
}
|
|
523
790
|
if (row.state === "cancelled") {
|
|
524
791
|
yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
|
|
792
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
|
|
525
793
|
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
|
|
526
794
|
}
|
|
527
795
|
})).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError ||
|
|
@@ -534,7 +802,7 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
534
802
|
const rows = rowsOf(yield* db.execute(sql `
|
|
535
803
|
UPDATE ${jobs} SET state = 'waiting', run_at = ${now}
|
|
536
804
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'delayed'
|
|
537
|
-
RETURNING ${jobs.id} AS id
|
|
805
|
+
RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
|
|
538
806
|
`).pipe(Effect.mapError(storeError("promote failed"))));
|
|
539
807
|
if (rows.length === 0) {
|
|
540
808
|
const existing = rowsOf(yield* db.execute(sql `
|
|
@@ -546,7 +814,7 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
546
814
|
}
|
|
547
815
|
return yield* new JobStore.JobNotPromotableError({ jobId: id, state: found.state });
|
|
548
816
|
}
|
|
549
|
-
yield* wakeUp;
|
|
817
|
+
yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""));
|
|
550
818
|
}),
|
|
551
819
|
pause: (queue) => db.execute(sql `
|
|
552
820
|
INSERT INTO ${queues} (queue, paused) VALUES (${queue}, TRUE)
|
|
@@ -554,7 +822,7 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
554
822
|
`).pipe(Effect.mapError(storeError("pause failed")), Effect.asVoid),
|
|
555
823
|
resume: (queue) => db.execute(sql `
|
|
556
824
|
UPDATE ${queues} SET paused = FALSE WHERE ${queues.queue} = ${queue}
|
|
557
|
-
`).pipe(Effect.mapError(storeError("resume failed")), Effect.andThen(wakeUp)),
|
|
825
|
+
`).pipe(Effect.mapError(storeError("resume failed")), Effect.andThen(wakeUp(queue))),
|
|
558
826
|
pausedQueues: () => db.execute(sql `
|
|
559
827
|
SELECT ${queues.queue} AS queue FROM ${queues} WHERE ${queues.paused} = TRUE
|
|
560
828
|
`).pipe(Effect.mapError(storeError("pausedQueues failed")), Effect.map((result) => rowsOf(result).map((row) => JobStore.QueueName(row.queue)))),
|
|
@@ -580,7 +848,7 @@ export const make = (options) => Effect.gen(function* () {
|
|
|
580
848
|
AND ${schedules.everyMs} IS NOT DISTINCT FROM EXCLUDED.every_ms
|
|
581
849
|
THEN ${schedules.nextRunAt}
|
|
582
850
|
ELSE EXCLUDED.next_run_at END
|
|
583
|
-
`).pipe(Effect.mapError(storeError("upsertSchedule failed")), Effect.
|
|
851
|
+
`).pipe(Effect.mapError(storeError("upsertSchedule failed")), Effect.andThen(wakeUp(schedule.queue))),
|
|
584
852
|
removeSchedule: (key) => db.execute(sql `
|
|
585
853
|
DELETE FROM ${schedules} WHERE ${schedules.key} = ${key}
|
|
586
854
|
RETURNING ${schedules.key} AS key
|