effect-mq 0.1.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 +305 -25
- package/dist/Job.d.ts +118 -5
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +119 -4
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +260 -9
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js +115 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts +38 -6
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +351 -47
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Worker.d.ts +5 -1
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +117 -10
- package/dist/Worker.js.map +1 -1
- package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +35 -2
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
- package/dist/drizzle-postgres/DrizzleJobStore.js +941 -0
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
- package/dist/drizzle-postgres/index.d.ts.map +1 -0
- package/dist/drizzle-postgres/index.js.map +1 -0
- package/dist/drizzle-postgres/schema.d.ts +670 -0
- package/dist/drizzle-postgres/schema.d.ts.map +1 -0
- package/dist/drizzle-postgres/schema.js +150 -0
- package/dist/drizzle-postgres/schema.js.map +1 -0
- package/dist/redis/RedisJobStore.d.ts +58 -0
- package/dist/redis/RedisJobStore.d.ts.map +1 -0
- package/dist/redis/RedisJobStore.js +424 -0
- package/dist/redis/RedisJobStore.js.map +1 -0
- package/dist/redis/index.d.ts +9 -0
- package/dist/redis/index.d.ts.map +1 -0
- package/dist/redis/index.js +9 -0
- package/dist/redis/index.js.map +1 -0
- package/dist/redis/scripts.d.ts +181 -0
- package/dist/redis/scripts.d.ts.map +1 -0
- package/dist/redis/scripts.js +940 -0
- package/dist/redis/scripts.js.map +1 -0
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +502 -8
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +8 -4
- package/src/Job.ts +301 -10
- package/src/JobStore.ts +373 -9
- package/src/MemoryJobStore.ts +440 -53
- package/src/Worker.ts +153 -10
- package/src/drizzle-postgres/DrizzleJobStore.ts +1311 -0
- package/src/drizzle-postgres/schema.ts +279 -0
- package/src/redis/RedisJobStore.ts +652 -0
- package/src/redis/index.ts +8 -0
- package/src/redis/scripts.ts +1055 -0
- package/src/testing/conformance.ts +665 -8
- package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
- package/dist/drizzle/DrizzleJobStore.js +0 -426
- package/dist/drizzle/DrizzleJobStore.js.map +0 -1
- package/dist/drizzle/index.d.ts.map +0 -1
- package/dist/drizzle/index.js.map +0 -1
- package/dist/drizzle/schema.d.ts +0 -464
- package/dist/drizzle/schema.d.ts.map +0 -1
- package/dist/drizzle/schema.js +0 -68
- package/dist/drizzle/schema.js.map +0 -1
- package/src/drizzle/DrizzleJobStore.ts +0 -599
- package/src/drizzle/schema.ts +0 -116
- /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
- /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
- /package/src/{drizzle → drizzle-postgres}/index.ts +0 -0
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Postgres `JobStore` running through drizzle's Effect driver
|
|
3
|
+
* (`drizzle-orm/effect-postgres`, which is built on `@effect/sql-pg` —
|
|
4
|
+
* Node and Bun compatible).
|
|
5
|
+
*
|
|
6
|
+
* - Claims use `FOR UPDATE SKIP LOCKED`; acks are lock-token guarded.
|
|
7
|
+
* - ALL time comes from the Effect `Clock` as bind parameters (never SQL
|
|
8
|
+
* `now()`), so the conformance suite runs against real Postgres under
|
|
9
|
+
* `TestClock`.
|
|
10
|
+
* - Wake-ups use LISTEN/NOTIFY through the shared `PgClient` (with the
|
|
11
|
+
* worker's `pollInterval` as the fallback), so cross-process workers wake
|
|
12
|
+
* promptly.
|
|
13
|
+
*
|
|
14
|
+
* TODO: a standalone non-drizzle Postgres driver on plain `@effect/sql-pg`
|
|
15
|
+
* (same table layout), and an adapter for promise-based drizzle databases.
|
|
16
|
+
*
|
|
17
|
+
* @since 0.1.0
|
|
18
|
+
*/
|
|
19
|
+
import * as JobStore from "../JobStore.js";
|
|
20
|
+
import { asc, eq, getTableColumns, sql } from "drizzle-orm";
|
|
21
|
+
import * as PgDrizzle from "drizzle-orm/effect-postgres";
|
|
22
|
+
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
23
|
+
import { Clock, Deferred, Duration, Effect, Layer, Option, Stream } from "effect";
|
|
24
|
+
const { JobId } = JobStore;
|
|
25
|
+
const storeError = (message) => (cause) => new JobStore.JobStoreError({ message, cause });
|
|
26
|
+
const rowsOf = (result) => "rows" in result ? result.rows : result;
|
|
27
|
+
const toSchedule = (row) => ({
|
|
28
|
+
key: JobStore.ScheduleKey(row.key),
|
|
29
|
+
jobName: row.jobName,
|
|
30
|
+
queue: JobStore.QueueName(row.queue),
|
|
31
|
+
cron: row.cron ?? undefined,
|
|
32
|
+
tz: row.tz ?? undefined,
|
|
33
|
+
everyMs: row.everyMs === null ? undefined : Number(row.everyMs),
|
|
34
|
+
payload: row.payload,
|
|
35
|
+
metadata: row.metadata ?? {},
|
|
36
|
+
priority: row.priority,
|
|
37
|
+
attemptsMax: row.attemptsMax,
|
|
38
|
+
backoff: row.backoff ?? undefined,
|
|
39
|
+
keep: row.keep ?? undefined,
|
|
40
|
+
timeoutMs: row.timeoutMs === null ? undefined : Number(row.timeoutMs),
|
|
41
|
+
nextRunAt: row.nextRunAt.getTime()
|
|
42
|
+
});
|
|
43
|
+
const toRecord = (row) => ({
|
|
44
|
+
id: JobId(row.id),
|
|
45
|
+
name: row.name,
|
|
46
|
+
queue: JobStore.QueueName(row.queue),
|
|
47
|
+
payload: row.payload,
|
|
48
|
+
metadata: row.metadata ?? {},
|
|
49
|
+
state: row.state,
|
|
50
|
+
priority: row.priority,
|
|
51
|
+
attemptsMax: row.attemptsMax,
|
|
52
|
+
attemptsMade: row.attemptsMade,
|
|
53
|
+
stalledCount: row.stalledCount,
|
|
54
|
+
backoff: row.backoff ?? undefined,
|
|
55
|
+
keep: row.keep ?? undefined,
|
|
56
|
+
timeoutMs: row.timeoutMs === null || row.timeoutMs === undefined ? undefined : Number(row.timeoutMs),
|
|
57
|
+
cancelRequested: row.cancelRequested,
|
|
58
|
+
dedupeKey: row.dedupeKey ?? undefined,
|
|
59
|
+
runAt: row.runAt.getTime(),
|
|
60
|
+
enqueuedAt: row.enqueuedAt.getTime(),
|
|
61
|
+
processedAt: row.processedAt?.getTime(),
|
|
62
|
+
finishedAt: row.finishedAt?.getTime(),
|
|
63
|
+
exit: row.exit ?? undefined,
|
|
64
|
+
failedReason: row.failedReason ?? undefined
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* Build the store implementation. Requires `PgClient` and a `Scope` (for the
|
|
68
|
+
* LISTEN subscription).
|
|
69
|
+
*
|
|
70
|
+
* @since 0.1.0
|
|
71
|
+
*/
|
|
72
|
+
export const make = (options) => Effect.gen(function* () {
|
|
73
|
+
const db = yield* PgDrizzle.makeWithDefaults();
|
|
74
|
+
const client = db.$client;
|
|
75
|
+
const jobs = options.jobs;
|
|
76
|
+
const attempts = options.attempts;
|
|
77
|
+
const schedules = options.schedules;
|
|
78
|
+
const queues = options.queues;
|
|
79
|
+
const dedupe = options.dedupe;
|
|
80
|
+
const jobsName = getTableConfig(jobs).name;
|
|
81
|
+
const attemptsName = getTableConfig(attempts).name;
|
|
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
|
+
};
|
|
130
|
+
if (options.validate ?? true) {
|
|
131
|
+
yield* Effect.all([
|
|
132
|
+
db.select({ id: jobs.id }).from(jobs).limit(0),
|
|
133
|
+
db.select({ jobId: attempts.jobId }).from(attempts).limit(0),
|
|
134
|
+
db.select({ key: schedules.key }).from(schedules).limit(0),
|
|
135
|
+
db.select({ queue: queues.queue }).from(queues).limit(0),
|
|
136
|
+
db.select({ key: dedupe.key }).from(dedupe).limit(0)
|
|
137
|
+
]).pipe(Effect.mapError(storeError(`effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
|
|
138
|
+
`re-export the effect-mq/drizzle schema factories from your drizzle schema and run your migrations (drizzle-kit generate)`)));
|
|
139
|
+
}
|
|
140
|
+
// Wake plumbing: a queue-filtered waiter registry (same protocol as the
|
|
141
|
+
// memory driver), fed by (a) local store operations and (b) cross-process
|
|
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.
|
|
145
|
+
let wakeVersion = 0;
|
|
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) => {
|
|
151
|
+
wakeVersion += 1;
|
|
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
|
+
}
|
|
172
|
+
};
|
|
173
|
+
// Resubscribe forever: if the LISTEN stream ends or fails, wake-ups
|
|
174
|
+
// degrade to the worker's pollInterval until the next attempt succeeds.
|
|
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);
|
|
176
|
+
if (options.historyTtl !== undefined) {
|
|
177
|
+
const ttlByState = JobStore.normalizeHistoryTtl(options.historyTtl);
|
|
178
|
+
const sweepMs = Duration.toMillis(options.historySweepInterval ?? "1 minute");
|
|
179
|
+
yield* Effect.gen(function* () {
|
|
180
|
+
yield* Effect.sleep(sweepMs);
|
|
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.
|
|
206
|
+
yield* db.execute(sql `
|
|
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
|
+
))
|
|
213
|
+
`);
|
|
214
|
+
}).pipe(Effect.catchCause((cause) => Effect.logWarning("effect-mq: history sweep failed", cause)), Effect.forever, Effect.forkScoped);
|
|
215
|
+
}
|
|
216
|
+
// Local mutation wake-up: bump synchronously, then best-effort NOTIFY so
|
|
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);
|
|
222
|
+
});
|
|
223
|
+
const nowDate = Effect.map(Clock.currentTimeMillis, (ms) => new Date(ms));
|
|
224
|
+
// quote_ident handles quoted/mixed-case table names safely.
|
|
225
|
+
const seqExpr = sql `nextval(pg_get_serial_sequence(quote_ident(${jobsName}), 'seq'))`;
|
|
226
|
+
const insertAttempt = (tx, jobId, outcome, startedAt, finishedAt, exit) => tx.execute(sql `
|
|
227
|
+
INSERT INTO ${attempts} (job_id, attempt, outcome, started_at, finished_at, exit)
|
|
228
|
+
SELECT ${jobId}, COALESCE(MAX(${attempts.attempt}), 0) + 1, ${outcome}, ${startedAt}, ${finishedAt},
|
|
229
|
+
${exit === undefined ? null : JSON.stringify(exit)}::jsonb
|
|
230
|
+
FROM ${attempts} WHERE ${attempts.jobId} = ${jobId}
|
|
231
|
+
`);
|
|
232
|
+
// Retention: drop terminal peers (same name + state) beyond count/age.
|
|
233
|
+
const keepPolicyFor = (keep, state) => {
|
|
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)
|
|
257
|
+
return;
|
|
258
|
+
if (keep.ageMs !== undefined) {
|
|
259
|
+
yield* tx.execute(sql `
|
|
260
|
+
DELETE FROM ${jobs}
|
|
261
|
+
WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
|
|
262
|
+
AND ${jobs.finishedAt} <= ${new Date(now.getTime() - keep.ageMs)}
|
|
263
|
+
`);
|
|
264
|
+
}
|
|
265
|
+
if (keep.count !== undefined) {
|
|
266
|
+
yield* tx.execute(sql `
|
|
267
|
+
DELETE FROM ${jobs}
|
|
268
|
+
WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
|
|
269
|
+
AND ${jobs.id} NOT IN (
|
|
270
|
+
SELECT ${jobs.id} FROM ${jobs}
|
|
271
|
+
WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
|
|
272
|
+
ORDER BY ${jobs.finishedAt} DESC, ${jobs.seq} DESC
|
|
273
|
+
LIMIT ${keep.count}
|
|
274
|
+
)
|
|
275
|
+
`);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
// Distinguish JobNotFound vs LockLost after a guarded UPDATE hit 0 rows.
|
|
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
|
|
280
|
+
? new JobStore.JobNotFoundError({ jobId: id })
|
|
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);
|
|
447
|
+
const store = {
|
|
448
|
+
enqueue: (request) => Effect.gen(function* () {
|
|
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);
|
|
453
|
+
}
|
|
454
|
+
return { id: result.id, duplicate: result.duplicate };
|
|
455
|
+
}
|
|
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;
|
|
462
|
+
}),
|
|
463
|
+
claim: (claimOptions) => Effect.suspend(() => {
|
|
464
|
+
// Snapshot BEFORE the transaction: any wake that fires while the
|
|
465
|
+
// claim's statements run must make awaitWake(token) return
|
|
466
|
+
// immediately (spurious wake-ups are allowed; lost ones are not).
|
|
467
|
+
const observedWake = wakeVersion;
|
|
468
|
+
return db.transaction((tx) => Effect.gen(function* () {
|
|
469
|
+
const now = yield* nowDate;
|
|
470
|
+
// Promote due delayed jobs first (separate statement: CTEs share
|
|
471
|
+
// a snapshot, so an UPDATE CTE would be invisible to the claim).
|
|
472
|
+
yield* tx.execute(sql `
|
|
473
|
+
UPDATE ${jobs} SET state = 'waiting'
|
|
474
|
+
WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'delayed'
|
|
475
|
+
AND ${jobs.runAt} <= ${now}
|
|
476
|
+
`);
|
|
477
|
+
const pausedRows = rowsOf(yield* tx.execute(sql `
|
|
478
|
+
SELECT ${queues.paused} AS "paused" FROM ${queues}
|
|
479
|
+
WHERE ${queues.queue} = ${claimOptions.queue}
|
|
480
|
+
`));
|
|
481
|
+
const isPaused = pausedRows[0]?.paused === true;
|
|
482
|
+
const claimed = isPaused ? [] : rowsOf(yield* tx.execute(sql `
|
|
483
|
+
WITH candidate AS (
|
|
484
|
+
SELECT ${jobs.id} AS id FROM ${jobs}
|
|
485
|
+
WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'waiting'
|
|
486
|
+
AND ${jobs.name} = ANY(${sql.param([...claimOptions.names])})
|
|
487
|
+
ORDER BY ${jobs.priority} DESC, ${jobs.seq} ASC
|
|
488
|
+
FOR UPDATE SKIP LOCKED
|
|
489
|
+
LIMIT 1
|
|
490
|
+
)
|
|
491
|
+
UPDATE ${jobs} SET state = 'active', lock_token = ${claimOptions.token},
|
|
492
|
+
lock_expires_at = ${new Date(now.getTime() + claimOptions.lockDurationMs)},
|
|
493
|
+
processed_at = ${now}
|
|
494
|
+
FROM candidate WHERE ${jobs.id} = candidate.id
|
|
495
|
+
RETURNING ${jobs.id} AS "id", ${jobs.name} AS "name", ${jobs.queue} AS "queue",
|
|
496
|
+
${jobs.state} AS "state", ${jobs.priority} AS "priority", ${jobs.seq} AS "seq",
|
|
497
|
+
${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
|
|
498
|
+
${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
|
|
499
|
+
${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
|
|
500
|
+
${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
|
|
501
|
+
${jobs.cancelRequested} AS "cancelRequested", ${jobs.dedupeKey} AS "dedupeKey",
|
|
502
|
+
${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
|
|
503
|
+
${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
|
|
504
|
+
${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
|
|
505
|
+
`));
|
|
506
|
+
const row = claimed[0];
|
|
507
|
+
if (row !== undefined) {
|
|
508
|
+
const result = { _tag: "Claimed", job: toRecord(row) };
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
const next = rowsOf(yield* tx.execute(sql `
|
|
512
|
+
SELECT MIN(${jobs.runAt}) AS next FROM ${jobs}
|
|
513
|
+
WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'delayed'
|
|
514
|
+
AND ${jobs.name} = ANY(${sql.param([...claimOptions.names])})
|
|
515
|
+
`));
|
|
516
|
+
const empty = {
|
|
517
|
+
_tag: "Empty",
|
|
518
|
+
nextRunAt: next[0]?.next?.getTime(),
|
|
519
|
+
wakeToken: observedWake
|
|
520
|
+
};
|
|
521
|
+
return empty;
|
|
522
|
+
}));
|
|
523
|
+
}).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("claim failed")(error))),
|
|
524
|
+
ack: (id, token, outcome) => db.transaction((tx) => Effect.gen(function* () {
|
|
525
|
+
const now = yield* nowDate;
|
|
526
|
+
const update = outcome._tag === "Complete"
|
|
527
|
+
? sql `state = 'completed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
|
|
528
|
+
: outcome._tag === "Fail"
|
|
529
|
+
? sql `state = 'failed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
|
|
530
|
+
: outcome._tag === "Cancelled"
|
|
531
|
+
? sql `state = 'cancelled', cancel_requested = FALSE, finished_at = ${now}`
|
|
532
|
+
// A cancel that raced this natural failure wins over revival
|
|
533
|
+
// (mirrors release/recoverStalled).
|
|
534
|
+
: sql `state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled'
|
|
535
|
+
ELSE ${outcome.delayMs > 0 ? "delayed" : "waiting"} END,
|
|
536
|
+
finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
|
|
537
|
+
run_at = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.runAt}
|
|
538
|
+
ELSE ${new Date(now.getTime() + Math.max(0, outcome.delayMs))}::timestamptz END,
|
|
539
|
+
seq = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.seq} ELSE ${seqExpr} END,
|
|
540
|
+
cancel_requested = FALSE`;
|
|
541
|
+
const rows = rowsOf(yield* tx.execute(sql `
|
|
542
|
+
UPDATE ${jobs} SET ${update},
|
|
543
|
+
attempts_made = ${jobs.attemptsMade} + 1, lock_token = NULL, lock_expires_at = NULL
|
|
544
|
+
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
|
|
545
|
+
RETURNING ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name",
|
|
546
|
+
${jobs.state} AS "state", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey",
|
|
547
|
+
${jobs.queue} AS "queue"
|
|
548
|
+
`));
|
|
549
|
+
const row = rows[0];
|
|
550
|
+
if (row === undefined) {
|
|
551
|
+
return yield* explainMiss(id);
|
|
552
|
+
}
|
|
553
|
+
const cancelledRetry = outcome._tag === "Retry" && row.state === "cancelled";
|
|
554
|
+
const ledgerOutcome = outcome._tag === "Complete"
|
|
555
|
+
? "completed"
|
|
556
|
+
: outcome._tag === "Fail"
|
|
557
|
+
? "failed"
|
|
558
|
+
: outcome._tag === "Cancelled" || cancelledRetry
|
|
559
|
+
? "cancelled"
|
|
560
|
+
: "retried";
|
|
561
|
+
yield* insertAttempt(tx, id, ledgerOutcome, row.processedAt, now, outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit);
|
|
562
|
+
if (outcome._tag !== "Retry" || cancelledRetry) {
|
|
563
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
|
|
564
|
+
yield* applyKeep(tx, row, now);
|
|
565
|
+
}
|
|
566
|
+
return outcome._tag === "Retry" && !cancelledRetry
|
|
567
|
+
? JobStore.QueueName(row.queue)
|
|
568
|
+
: undefined;
|
|
569
|
+
})).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError || error instanceof JobStore.LockLostError ||
|
|
570
|
+
error instanceof JobStore.JobStoreError
|
|
571
|
+
? error
|
|
572
|
+
: storeError("ack failed")(error)), Effect.tap((queue) => queue !== undefined ? wakeUp(queue) : Effect.void), Effect.asVoid),
|
|
573
|
+
release: (id, token) => Effect.gen(function* () {
|
|
574
|
+
const released = yield* db.transaction((tx) => Effect.gen(function* () {
|
|
575
|
+
const now = yield* nowDate;
|
|
576
|
+
// A cancel that arrived while the worker was shutting down is
|
|
577
|
+
// honoured instead of reviving the job.
|
|
578
|
+
const rows = rowsOf(yield* tx.execute(sql `
|
|
579
|
+
UPDATE ${jobs} SET
|
|
580
|
+
state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled' ELSE 'waiting' END,
|
|
581
|
+
finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
|
|
582
|
+
cancel_requested = FALSE,
|
|
583
|
+
lock_token = NULL, lock_expires_at = NULL
|
|
584
|
+
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
|
|
585
|
+
RETURNING ${jobs.id} AS id, (${jobs.state} = 'cancelled') AS cancelled,
|
|
586
|
+
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
|
|
587
|
+
${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue"
|
|
588
|
+
`));
|
|
589
|
+
const row = rows[0];
|
|
590
|
+
if (row === undefined)
|
|
591
|
+
return undefined;
|
|
592
|
+
if (row.cancelled) {
|
|
593
|
+
yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
|
|
594
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
|
|
595
|
+
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
|
|
596
|
+
}
|
|
597
|
+
return { cancelled: row.cancelled, queue: JobStore.QueueName(row.queue) };
|
|
598
|
+
})).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("release failed")(error)));
|
|
599
|
+
if (released === undefined) {
|
|
600
|
+
return yield* explainMiss(id);
|
|
601
|
+
}
|
|
602
|
+
if (!released.cancelled) {
|
|
603
|
+
yield* wakeUp(released.queue);
|
|
604
|
+
}
|
|
605
|
+
}),
|
|
606
|
+
extendLocks: (locks, durationMs) => Effect.gen(function* () {
|
|
607
|
+
if (locks.length === 0) {
|
|
608
|
+
const empty = { lost: [], cancelRequested: [] };
|
|
609
|
+
return empty;
|
|
610
|
+
}
|
|
611
|
+
const now = yield* nowDate;
|
|
612
|
+
const rows = rowsOf(yield* db.execute(sql `
|
|
613
|
+
WITH input AS (
|
|
614
|
+
SELECT ids.job_id, toks.token
|
|
615
|
+
FROM unnest(${sql.param(locks.map((lock) => lock.id))}::text[]) WITH ORDINALITY AS ids(job_id, ord)
|
|
616
|
+
JOIN unnest(${sql.param(locks.map((lock) => lock.token))}::text[]) WITH ORDINALITY AS toks(token, ord) USING (ord)
|
|
617
|
+
),
|
|
618
|
+
updated AS (
|
|
619
|
+
UPDATE ${jobs} SET lock_expires_at = ${new Date(now.getTime() + durationMs)}
|
|
620
|
+
FROM input
|
|
621
|
+
WHERE ${jobs.id} = input.job_id AND ${jobs.state} = 'active'
|
|
622
|
+
AND ${jobs.lockToken} = input.token AND ${jobs.cancelRequested} = FALSE
|
|
623
|
+
RETURNING ${jobs.id} AS id
|
|
624
|
+
)
|
|
625
|
+
SELECT input.job_id AS id,
|
|
626
|
+
CASE WHEN ${jobs.id} IS NOT NULL AND ${jobs.state} = 'active'
|
|
627
|
+
AND ${jobs.lockToken} = input.token AND ${jobs.cancelRequested} THEN 'cancel'
|
|
628
|
+
ELSE 'lost' END AS status
|
|
629
|
+
FROM input
|
|
630
|
+
LEFT JOIN updated ON updated.id = input.job_id
|
|
631
|
+
LEFT JOIN ${jobs} ON ${jobs.id} = input.job_id
|
|
632
|
+
WHERE updated.id IS NULL
|
|
633
|
+
`).pipe(Effect.mapError(storeError("extendLocks failed"))));
|
|
634
|
+
const result = {
|
|
635
|
+
lost: rows.filter((row) => row.status === "lost").map((row) => JobId(row.id)),
|
|
636
|
+
cancelRequested: rows.filter((row) => row.status === "cancel").map((row) => JobId(row.id))
|
|
637
|
+
};
|
|
638
|
+
return result;
|
|
639
|
+
}),
|
|
640
|
+
recoverStalled: (recoverOptions) => db.transaction((tx) => Effect.gen(function* () {
|
|
641
|
+
const now = yield* nowDate;
|
|
642
|
+
// A stalled job whose worker died before honouring a cancel
|
|
643
|
+
// request is finished as cancelled rather than revived.
|
|
644
|
+
const rows = rowsOf(yield* tx.execute(sql `
|
|
645
|
+
UPDATE ${jobs} SET
|
|
646
|
+
stalled_count = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.stalledCount}
|
|
647
|
+
ELSE ${jobs.stalledCount} + 1 END,
|
|
648
|
+
lock_token = NULL, lock_expires_at = NULL,
|
|
649
|
+
state = CASE
|
|
650
|
+
WHEN ${jobs.cancelRequested} THEN 'cancelled'
|
|
651
|
+
WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int THEN 'failed'
|
|
652
|
+
ELSE 'waiting' END,
|
|
653
|
+
finished_at = CASE
|
|
654
|
+
WHEN ${jobs.cancelRequested} OR ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
|
|
655
|
+
THEN ${now}::timestamptz ELSE NULL END,
|
|
656
|
+
failed_reason = CASE
|
|
657
|
+
WHEN ${jobs.cancelRequested} THEN NULL
|
|
658
|
+
WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
|
|
659
|
+
THEN 'job stalled more than allowable limit' ELSE NULL END,
|
|
660
|
+
cancel_requested = FALSE
|
|
661
|
+
WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
|
|
662
|
+
RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt",
|
|
663
|
+
${jobs.name} AS "name", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey"
|
|
664
|
+
`));
|
|
665
|
+
const recovered = [];
|
|
666
|
+
for (const row of rows) {
|
|
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
|
+
}
|
|
671
|
+
if (row.state === "cancelled") {
|
|
672
|
+
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
recovered.push({ id: JobId(row.id), failed: row.state === "failed" });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return recovered;
|
|
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))
|
|
682
|
+
return Effect.void;
|
|
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))));
|
|
686
|
+
}),
|
|
687
|
+
getJob: (id) => db.select().from(jobs).where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("getJob failed")), Effect.map((rows) => {
|
|
688
|
+
const row = rows[0];
|
|
689
|
+
return row === undefined ? Option.none() : Option.some(toRecord(row));
|
|
690
|
+
})),
|
|
691
|
+
getAttempts: (id) => db.select().from(attempts).where(eq(attempts.jobId, id)).orderBy(asc(attempts.attempt)).pipe(Effect.mapError(storeError("getAttempts failed")), Effect.map((rows) => rows.map((row) => ({
|
|
692
|
+
attempt: row.attempt,
|
|
693
|
+
startedAt: row.startedAt?.getTime(),
|
|
694
|
+
finishedAt: row.finishedAt.getTime(),
|
|
695
|
+
outcome: row.outcome,
|
|
696
|
+
exit: row.exit ?? undefined
|
|
697
|
+
})))),
|
|
698
|
+
list: (listOptions) => Effect.gen(function* () {
|
|
699
|
+
const limit = Math.max(1, listOptions.limit ?? 50);
|
|
700
|
+
const conditions = [sql `TRUE`];
|
|
701
|
+
if (listOptions.queue !== undefined)
|
|
702
|
+
conditions.push(sql `${jobs.queue} = ${listOptions.queue}`);
|
|
703
|
+
if (listOptions.name !== undefined)
|
|
704
|
+
conditions.push(sql `${jobs.name} = ${listOptions.name}`);
|
|
705
|
+
if (listOptions.states !== undefined) {
|
|
706
|
+
// NB: an empty array matches nothing (ANY('{}') is false), same
|
|
707
|
+
// as the memory driver.
|
|
708
|
+
conditions.push(sql `${jobs.state} = ANY(${sql.param([...listOptions.states])})`);
|
|
709
|
+
}
|
|
710
|
+
if (listOptions.metadata !== undefined && Object.keys(listOptions.metadata).length > 0) {
|
|
711
|
+
conditions.push(sql `${jobs.metadata} @> ${JSON.stringify(listOptions.metadata)}::jsonb`);
|
|
712
|
+
}
|
|
713
|
+
if (listOptions.cursor !== undefined) {
|
|
714
|
+
const split = listOptions.cursor.indexOf(":");
|
|
715
|
+
const cursorAt = new Date(Number(listOptions.cursor.slice(0, split)));
|
|
716
|
+
const cursorId = listOptions.cursor.slice(split + 1);
|
|
717
|
+
conditions.push(sql `(${jobs.enqueuedAt}, ${jobs.id}) < (${cursorAt}, ${cursorId})`);
|
|
718
|
+
}
|
|
719
|
+
const rows = rowsOf(yield* db.execute(sql `
|
|
720
|
+
SELECT ${jobs.id} AS "id", ${jobs.name} AS "name", ${jobs.queue} AS "queue",
|
|
721
|
+
${jobs.state} AS "state", ${jobs.priority} AS "priority", ${jobs.seq} AS "seq",
|
|
722
|
+
${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
|
|
723
|
+
${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
|
|
724
|
+
${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
|
|
725
|
+
${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
|
|
726
|
+
${jobs.cancelRequested} AS "cancelRequested",
|
|
727
|
+
${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
|
|
728
|
+
${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
|
|
729
|
+
${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
|
|
730
|
+
FROM ${jobs}
|
|
731
|
+
WHERE ${sql.join(conditions, sql ` AND `)}
|
|
732
|
+
ORDER BY ${jobs.enqueuedAt} DESC, ${jobs.id} DESC
|
|
733
|
+
LIMIT ${limit + 1}
|
|
734
|
+
`).pipe(Effect.mapError(storeError("list failed"))));
|
|
735
|
+
const items = rows.slice(0, limit).map(toRecord);
|
|
736
|
+
const last = items[items.length - 1];
|
|
737
|
+
return {
|
|
738
|
+
items,
|
|
739
|
+
cursor: rows.length > limit && last !== undefined
|
|
740
|
+
? `${last.enqueuedAt}:${last.id}`
|
|
741
|
+
: undefined
|
|
742
|
+
};
|
|
743
|
+
}),
|
|
744
|
+
retry: (id) => Effect.gen(function* () {
|
|
745
|
+
const now = yield* nowDate;
|
|
746
|
+
const rows = rowsOf(yield* db.execute(sql `
|
|
747
|
+
UPDATE ${jobs} SET state = 'waiting', attempts_made = 0, stalled_count = 0,
|
|
748
|
+
cancel_requested = FALSE,
|
|
749
|
+
exit = NULL, failed_reason = NULL, finished_at = NULL, processed_at = NULL,
|
|
750
|
+
run_at = ${now}, seq = ${seqExpr}
|
|
751
|
+
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'failed'
|
|
752
|
+
RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
|
|
753
|
+
`).pipe(Effect.mapError(storeError("retry failed"))));
|
|
754
|
+
if (rows.length === 0) {
|
|
755
|
+
const existing = yield* db.select({ state: jobs.state }).from(jobs)
|
|
756
|
+
.where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("retry failed")));
|
|
757
|
+
const found = existing[0];
|
|
758
|
+
if (found === undefined) {
|
|
759
|
+
return yield* new JobStore.JobNotFoundError({ jobId: id });
|
|
760
|
+
}
|
|
761
|
+
return yield* new JobStore.JobNotRetryableError({ jobId: id, state: found.state });
|
|
762
|
+
}
|
|
763
|
+
yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""));
|
|
764
|
+
}),
|
|
765
|
+
cancel: (id) => db.transaction((tx) => Effect.gen(function* () {
|
|
766
|
+
const now = yield* nowDate;
|
|
767
|
+
// One guarded statement: waiting/delayed become terminal, active
|
|
768
|
+
// gets the cancel-request flag; anything else is reported by state.
|
|
769
|
+
const rows = rowsOf(yield* tx.execute(sql `
|
|
770
|
+
UPDATE ${jobs} SET
|
|
771
|
+
state = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN 'cancelled' ELSE ${jobs.state} END,
|
|
772
|
+
finished_at = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN ${now}::timestamptz ELSE ${jobs.finishedAt} END,
|
|
773
|
+
cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
|
|
774
|
+
WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
|
|
775
|
+
RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
|
|
776
|
+
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
|
|
777
|
+
${jobs.dedupeKey} AS "dedupeKey"
|
|
778
|
+
`));
|
|
779
|
+
const row = rows[0];
|
|
780
|
+
if (row === undefined) {
|
|
781
|
+
const existing = rowsOf(yield* tx.execute(sql `
|
|
782
|
+
SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
|
|
783
|
+
`));
|
|
784
|
+
const found = existing[0];
|
|
785
|
+
if (found === undefined) {
|
|
786
|
+
return yield* new JobStore.JobNotFoundError({ jobId: id });
|
|
787
|
+
}
|
|
788
|
+
return yield* new JobStore.JobNotCancellableError({ jobId: id, state: found.state });
|
|
789
|
+
}
|
|
790
|
+
if (row.state === "cancelled") {
|
|
791
|
+
yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
|
|
792
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
|
|
793
|
+
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
|
|
794
|
+
}
|
|
795
|
+
})).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError ||
|
|
796
|
+
error instanceof JobStore.JobNotCancellableError ||
|
|
797
|
+
error instanceof JobStore.JobStoreError
|
|
798
|
+
? error
|
|
799
|
+
: storeError("cancel failed")(error)), Effect.asVoid),
|
|
800
|
+
promote: (id) => Effect.gen(function* () {
|
|
801
|
+
const now = yield* nowDate;
|
|
802
|
+
const rows = rowsOf(yield* db.execute(sql `
|
|
803
|
+
UPDATE ${jobs} SET state = 'waiting', run_at = ${now}
|
|
804
|
+
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'delayed'
|
|
805
|
+
RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
|
|
806
|
+
`).pipe(Effect.mapError(storeError("promote failed"))));
|
|
807
|
+
if (rows.length === 0) {
|
|
808
|
+
const existing = rowsOf(yield* db.execute(sql `
|
|
809
|
+
SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
|
|
810
|
+
`).pipe(Effect.mapError(storeError("promote failed"))));
|
|
811
|
+
const found = existing[0];
|
|
812
|
+
if (found === undefined) {
|
|
813
|
+
return yield* new JobStore.JobNotFoundError({ jobId: id });
|
|
814
|
+
}
|
|
815
|
+
return yield* new JobStore.JobNotPromotableError({ jobId: id, state: found.state });
|
|
816
|
+
}
|
|
817
|
+
yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""));
|
|
818
|
+
}),
|
|
819
|
+
pause: (queue) => db.execute(sql `
|
|
820
|
+
INSERT INTO ${queues} (queue, paused) VALUES (${queue}, TRUE)
|
|
821
|
+
ON CONFLICT (queue) DO UPDATE SET paused = TRUE
|
|
822
|
+
`).pipe(Effect.mapError(storeError("pause failed")), Effect.asVoid),
|
|
823
|
+
resume: (queue) => db.execute(sql `
|
|
824
|
+
UPDATE ${queues} SET paused = FALSE WHERE ${queues.queue} = ${queue}
|
|
825
|
+
`).pipe(Effect.mapError(storeError("resume failed")), Effect.andThen(wakeUp(queue))),
|
|
826
|
+
pausedQueues: () => db.execute(sql `
|
|
827
|
+
SELECT ${queues.queue} AS queue FROM ${queues} WHERE ${queues.paused} = TRUE
|
|
828
|
+
`).pipe(Effect.mapError(storeError("pausedQueues failed")), Effect.map((result) => rowsOf(result).map((row) => JobStore.QueueName(row.queue)))),
|
|
829
|
+
upsertSchedule: (schedule) => db.execute(sql `
|
|
830
|
+
INSERT INTO ${schedules} (key, job_name, queue, cron, tz, every_ms, payload, metadata,
|
|
831
|
+
priority, attempts_max, backoff, keep, timeout_ms, next_run_at)
|
|
832
|
+
VALUES (${schedule.key}, ${schedule.jobName}, ${schedule.queue},
|
|
833
|
+
${schedule.cron ?? null}, ${schedule.tz ?? null}, ${schedule.everyMs ?? null},
|
|
834
|
+
${JSON.stringify(schedule.payload ?? null)}::jsonb, ${JSON.stringify(schedule.metadata)}::jsonb,
|
|
835
|
+
${schedule.priority}, ${schedule.attemptsMax},
|
|
836
|
+
${schedule.backoff === undefined ? null : JSON.stringify(schedule.backoff)}::jsonb,
|
|
837
|
+
${schedule.keep === undefined ? null : JSON.stringify(schedule.keep)}::jsonb,
|
|
838
|
+
${schedule.timeoutMs ?? null}, ${new Date(schedule.nextRunAt)})
|
|
839
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
840
|
+
job_name = EXCLUDED.job_name, queue = EXCLUDED.queue, cron = EXCLUDED.cron,
|
|
841
|
+
tz = EXCLUDED.tz, every_ms = EXCLUDED.every_ms, payload = EXCLUDED.payload,
|
|
842
|
+
metadata = EXCLUDED.metadata, priority = EXCLUDED.priority,
|
|
843
|
+
attempts_max = EXCLUDED.attempts_max, backoff = EXCLUDED.backoff,
|
|
844
|
+
keep = EXCLUDED.keep, timeout_ms = EXCLUDED.timeout_ms,
|
|
845
|
+
next_run_at = CASE
|
|
846
|
+
WHEN ${schedules.cron} IS NOT DISTINCT FROM EXCLUDED.cron
|
|
847
|
+
AND ${schedules.tz} IS NOT DISTINCT FROM EXCLUDED.tz
|
|
848
|
+
AND ${schedules.everyMs} IS NOT DISTINCT FROM EXCLUDED.every_ms
|
|
849
|
+
THEN ${schedules.nextRunAt}
|
|
850
|
+
ELSE EXCLUDED.next_run_at END
|
|
851
|
+
`).pipe(Effect.mapError(storeError("upsertSchedule failed")), Effect.andThen(wakeUp(schedule.queue))),
|
|
852
|
+
removeSchedule: (key) => db.execute(sql `
|
|
853
|
+
DELETE FROM ${schedules} WHERE ${schedules.key} = ${key}
|
|
854
|
+
RETURNING ${schedules.key} AS key
|
|
855
|
+
`).pipe(Effect.mapError(storeError("removeSchedule failed")), Effect.map((result) => rowsOf(result).length > 0)),
|
|
856
|
+
listSchedules: (listOptions) => Effect.gen(function* () {
|
|
857
|
+
const conditions = [sql `TRUE`];
|
|
858
|
+
if (listOptions?.jobName !== undefined) {
|
|
859
|
+
conditions.push(sql `${schedules.jobName} = ${listOptions.jobName}`);
|
|
860
|
+
}
|
|
861
|
+
if (listOptions?.queue !== undefined) {
|
|
862
|
+
conditions.push(sql `${schedules.queue} = ${listOptions.queue}`);
|
|
863
|
+
}
|
|
864
|
+
const rows = rowsOf(yield* db.execute(sql `
|
|
865
|
+
SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
|
|
866
|
+
${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
|
|
867
|
+
${schedules.everyMs} AS "everyMs", ${schedules.payload} AS "payload",
|
|
868
|
+
${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
|
|
869
|
+
${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
|
|
870
|
+
${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
|
|
871
|
+
${schedules.nextRunAt} AS "nextRunAt"
|
|
872
|
+
FROM ${schedules}
|
|
873
|
+
WHERE ${sql.join(conditions, sql ` AND `)}
|
|
874
|
+
ORDER BY ${schedules.key}
|
|
875
|
+
`).pipe(Effect.mapError(storeError("listSchedules failed"))));
|
|
876
|
+
return rows.map(toSchedule);
|
|
877
|
+
}),
|
|
878
|
+
dueSchedules: () => Effect.gen(function* () {
|
|
879
|
+
const now = yield* nowDate;
|
|
880
|
+
const rows = rowsOf(yield* db.execute(sql `
|
|
881
|
+
SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
|
|
882
|
+
${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
|
|
883
|
+
${schedules.everyMs} AS "everyMs", ${schedules.payload} AS "payload",
|
|
884
|
+
${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
|
|
885
|
+
${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
|
|
886
|
+
${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
|
|
887
|
+
${schedules.nextRunAt} AS "nextRunAt"
|
|
888
|
+
FROM ${schedules}
|
|
889
|
+
WHERE ${schedules.nextRunAt} <= ${now}
|
|
890
|
+
ORDER BY ${schedules.nextRunAt} ASC
|
|
891
|
+
`).pipe(Effect.mapError(storeError("dueSchedules failed"))));
|
|
892
|
+
return rows.map(toSchedule);
|
|
893
|
+
}),
|
|
894
|
+
advanceSchedule: (key, expectedRunAt, nextRunAt) => db.execute(sql `
|
|
895
|
+
UPDATE ${schedules} SET next_run_at = ${new Date(nextRunAt)}
|
|
896
|
+
WHERE ${schedules.key} = ${key} AND ${schedules.nextRunAt} = ${new Date(expectedRunAt)}
|
|
897
|
+
`).pipe(Effect.mapError(storeError("advanceSchedule failed")), Effect.asVoid),
|
|
898
|
+
counts: (queue) => db.execute(sql `
|
|
899
|
+
SELECT ${jobs.state} AS "state", count(*)::int AS "count" FROM ${jobs}
|
|
900
|
+
${queue === undefined ? sql `` : sql `WHERE ${jobs.queue} = ${queue}`}
|
|
901
|
+
GROUP BY ${jobs.state}
|
|
902
|
+
`).pipe(Effect.mapError(storeError("counts failed")), Effect.map((result) => {
|
|
903
|
+
const rows = rowsOf(result);
|
|
904
|
+
const counts = {
|
|
905
|
+
waiting: 0,
|
|
906
|
+
delayed: 0,
|
|
907
|
+
active: 0,
|
|
908
|
+
completed: 0,
|
|
909
|
+
failed: 0,
|
|
910
|
+
cancelled: 0
|
|
911
|
+
};
|
|
912
|
+
for (const row of rows)
|
|
913
|
+
counts[row.state] = row.count;
|
|
914
|
+
return counts;
|
|
915
|
+
})),
|
|
916
|
+
remove: (id) => db.execute(sql `
|
|
917
|
+
DELETE FROM ${jobs}
|
|
918
|
+
WHERE ${jobs.id} = ${id} AND ${jobs.state} <> 'active'
|
|
919
|
+
RETURNING ${jobs.id} AS id
|
|
920
|
+
`).pipe(Effect.mapError(storeError("remove failed")), Effect.map((result) => rowsOf(result).length > 0))
|
|
921
|
+
};
|
|
922
|
+
return store;
|
|
923
|
+
});
|
|
924
|
+
/**
|
|
925
|
+
* A Postgres-backed `JobStore` layer over your drizzle tables. Requires
|
|
926
|
+
* `PgClient` (from `@effect/sql-pg`).
|
|
927
|
+
*
|
|
928
|
+
* ```ts
|
|
929
|
+
* const StoreLive = DrizzleJobStore.layer({ jobs, attempts, store: Durable }).pipe(
|
|
930
|
+
* Layer.provide(PgClient.layer({ url: Redacted.make(DATABASE_URL) }))
|
|
931
|
+
* )
|
|
932
|
+
* ```
|
|
933
|
+
*
|
|
934
|
+
* @since 0.1.0
|
|
935
|
+
*/
|
|
936
|
+
export const layer = (options) => Layer.effect(
|
|
937
|
+
// SAFETY: when `options.store` is omitted the public signature fixes
|
|
938
|
+
// `StoreId` to its default `JobStore.JobStore`, so the default key is the
|
|
939
|
+
// right `Context.Key<StoreId>`; when it is present the cast is an identity.
|
|
940
|
+
(options.store ?? JobStore.JobStore), make(options));
|
|
941
|
+
//# sourceMappingURL=DrizzleJobStore.js.map
|