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
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import * as JobStore from "../JobStore.ts"
|
|
20
20
|
import type { PgClient } from "@effect/sql-pg"
|
|
21
|
-
import { asc, eq, sql } from "drizzle-orm"
|
|
21
|
+
import { asc, eq, getTableColumns, sql } from "drizzle-orm"
|
|
22
22
|
import * as PgDrizzle from "drizzle-orm/effect-postgres"
|
|
23
23
|
import { getTableConfig } from "drizzle-orm/pg-core"
|
|
24
24
|
import { Clock, type Context, Deferred, Duration, Effect, Layer, Option, type Scope, Stream } from "effect"
|
|
25
|
-
import type { MqJobAttemptsTable, MqJobsTable, MqQueueControlTable, MqSchedulesTable } from "./schema.ts"
|
|
25
|
+
import type { MqDedupeTable, MqJobAttemptsTable, MqJobsTable, MqQueueControlTable, MqSchedulesTable } from "./schema.ts"
|
|
26
26
|
|
|
27
27
|
const { JobId } = JobStore
|
|
28
28
|
|
|
@@ -38,13 +38,26 @@ export interface DrizzleJobStoreOptions<StoreId = JobStore.JobStore> {
|
|
|
38
38
|
readonly schedules: MqSchedulesTable
|
|
39
39
|
/** The queue pause/resume flags table (from `mqQueueControl`). */
|
|
40
40
|
readonly queues: MqQueueControlTable
|
|
41
|
+
/** The dedup-key registry table (from `mqDedupe`). */
|
|
42
|
+
readonly dedupe: MqDedupeTable
|
|
43
|
+
/**
|
|
44
|
+
* Values for columns added via `mqJobs({ extend })`, evaluated at enqueue
|
|
45
|
+
* (and on dedupe `replace`). Keys are the extended columns' TS names.
|
|
46
|
+
* Default: each extended column fills from `request.metadata[<TS name>]`,
|
|
47
|
+
* NULL when absent.
|
|
48
|
+
*/
|
|
49
|
+
readonly extraValues?:
|
|
50
|
+
| ((request: JobStore.EnqueueRequest) => Readonly<Record<string, ExtraColumnValue>>)
|
|
51
|
+
| undefined
|
|
41
52
|
/** Bind to a `JobStore.named(...)` key; default: the default `JobStore`. */
|
|
42
53
|
readonly store?: Context.Key<StoreId, JobStore.Service> | undefined
|
|
43
54
|
/**
|
|
44
55
|
* Store-level retention ceiling: terminal records older than this are
|
|
45
|
-
* removed by a periodic sweep
|
|
56
|
+
* removed by a periodic sweep — one duration for all terminal states or a
|
|
57
|
+
* per-state split (`{ completed: "1 day", failed: "30 days" }`). The sweep
|
|
58
|
+
* also honours stricter per-job `keep.age` rules.
|
|
46
59
|
*/
|
|
47
|
-
readonly historyTtl?:
|
|
60
|
+
readonly historyTtl?: JobStore.HistoryTtlInput | undefined
|
|
48
61
|
/** History sweep cadence (default 1 minute). */
|
|
49
62
|
readonly historySweepInterval?: Duration.Input | undefined
|
|
50
63
|
/**
|
|
@@ -61,6 +74,13 @@ export interface DrizzleJobStoreOptions<StoreId = JobStore.JobStore> {
|
|
|
61
74
|
|
|
62
75
|
type Db = PgDrizzle.EffectPgDatabase & { readonly $client: PgClient.PgClient }
|
|
63
76
|
|
|
77
|
+
/**
|
|
78
|
+
* A value the driver can bind directly into an extended column.
|
|
79
|
+
*
|
|
80
|
+
* @since 0.3.0
|
|
81
|
+
*/
|
|
82
|
+
export type ExtraColumnValue = string | number | boolean | Date | null
|
|
83
|
+
|
|
64
84
|
const storeError = (message: string) => (cause: unknown) =>
|
|
65
85
|
new JobStore.JobStoreError({ message, cause })
|
|
66
86
|
|
|
@@ -92,6 +112,7 @@ type JobRow = {
|
|
|
92
112
|
readonly keep: JobStore.KeepPolicy | null
|
|
93
113
|
readonly timeoutMs: number | string | null
|
|
94
114
|
readonly cancelRequested: boolean
|
|
115
|
+
readonly dedupeKey: string | null
|
|
95
116
|
readonly runAt: Date
|
|
96
117
|
readonly enqueuedAt: Date
|
|
97
118
|
readonly processedAt: Date | null
|
|
@@ -149,6 +170,7 @@ const toRecord = (row: JobRow): JobStore.JobRecord => ({
|
|
|
149
170
|
keep: row.keep ?? undefined,
|
|
150
171
|
timeoutMs: row.timeoutMs === null || row.timeoutMs === undefined ? undefined : Number(row.timeoutMs),
|
|
151
172
|
cancelRequested: row.cancelRequested,
|
|
173
|
+
dedupeKey: row.dedupeKey ?? undefined,
|
|
152
174
|
runAt: row.runAt.getTime(),
|
|
153
175
|
enqueuedAt: row.enqueuedAt.getTime(),
|
|
154
176
|
processedAt: row.processedAt?.getTime(),
|
|
@@ -173,16 +195,70 @@ export const make = (
|
|
|
173
195
|
const attempts = options.attempts
|
|
174
196
|
const schedules = options.schedules
|
|
175
197
|
const queues = options.queues
|
|
198
|
+
const dedupe = options.dedupe
|
|
176
199
|
const jobsName = getTableConfig(jobs).name
|
|
177
200
|
const attemptsName = getTableConfig(attempts).name
|
|
178
201
|
const wakeChannel = `effect_mq_wake_${jobsName}`
|
|
179
202
|
|
|
203
|
+
// Columns the user added via `mqJobs({ extend })`: everything beyond the
|
|
204
|
+
// factory's own set. They are written at enqueue (and on dedupe replace)
|
|
205
|
+
// from `extraValues` or the metadata entry with the same TS key.
|
|
206
|
+
const BASE_JOB_COLUMNS = new Set([
|
|
207
|
+
"id",
|
|
208
|
+
"name",
|
|
209
|
+
"queue",
|
|
210
|
+
"state",
|
|
211
|
+
"priority",
|
|
212
|
+
"seq",
|
|
213
|
+
"payload",
|
|
214
|
+
"metadata",
|
|
215
|
+
"attemptsMax",
|
|
216
|
+
"attemptsMade",
|
|
217
|
+
"stalledCount",
|
|
218
|
+
"backoff",
|
|
219
|
+
"keep",
|
|
220
|
+
"timeoutMs",
|
|
221
|
+
"cancelRequested",
|
|
222
|
+
"dedupeKey",
|
|
223
|
+
"runAt",
|
|
224
|
+
"enqueuedAt",
|
|
225
|
+
"processedAt",
|
|
226
|
+
"finishedAt",
|
|
227
|
+
"exit",
|
|
228
|
+
"failedReason",
|
|
229
|
+
"lockToken",
|
|
230
|
+
"lockExpiresAt"
|
|
231
|
+
])
|
|
232
|
+
const extendedColumns = Object.entries(getTableColumns(jobs))
|
|
233
|
+
.filter(([key]) => !BASE_JOB_COLUMNS.has(key))
|
|
234
|
+
.map(([key, column]) => ({ key, name: column.name }))
|
|
235
|
+
const extraColumnNames = extendedColumns.length === 0
|
|
236
|
+
? sql``
|
|
237
|
+
: sql.join(extendedColumns.map((column) => sql`, ${sql.identifier(column.name)}`))
|
|
238
|
+
const extraColumnValues = (request: JobStore.EnqueueRequest) => {
|
|
239
|
+
if (extendedColumns.length === 0) return sql``
|
|
240
|
+
const mapped = options.extraValues?.(request) ?? {}
|
|
241
|
+
return sql.join(extendedColumns.map((column) =>
|
|
242
|
+
sql`, ${Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null}`
|
|
243
|
+
))
|
|
244
|
+
}
|
|
245
|
+
const extraColumnAssignments = (request: JobStore.EnqueueRequest) => {
|
|
246
|
+
if (extendedColumns.length === 0) return sql``
|
|
247
|
+
const mapped = options.extraValues?.(request) ?? {}
|
|
248
|
+
return sql.join(extendedColumns.map((column) =>
|
|
249
|
+
sql`, ${sql.identifier(column.name)} = ${
|
|
250
|
+
Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null
|
|
251
|
+
}`
|
|
252
|
+
))
|
|
253
|
+
}
|
|
254
|
+
|
|
180
255
|
if (options.validate ?? true) {
|
|
181
256
|
yield* Effect.all([
|
|
182
257
|
db.select({ id: jobs.id }).from(jobs).limit(0),
|
|
183
258
|
db.select({ jobId: attempts.jobId }).from(attempts).limit(0),
|
|
184
259
|
db.select({ key: schedules.key }).from(schedules).limit(0),
|
|
185
|
-
db.select({ queue: queues.queue }).from(queues).limit(0)
|
|
260
|
+
db.select({ queue: queues.queue }).from(queues).limit(0),
|
|
261
|
+
db.select({ key: dedupe.key }).from(dedupe).limit(0)
|
|
186
262
|
]).pipe(
|
|
187
263
|
Effect.mapError(storeError(
|
|
188
264
|
`effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
|
|
@@ -191,21 +267,48 @@ export const make = (
|
|
|
191
267
|
)
|
|
192
268
|
}
|
|
193
269
|
|
|
194
|
-
// Wake plumbing: a
|
|
270
|
+
// Wake plumbing: a queue-filtered waiter registry (same protocol as the
|
|
195
271
|
// memory driver), fed by (a) local store operations and (b) cross-process
|
|
196
|
-
// LISTEN notifications.
|
|
272
|
+
// LISTEN notifications whose payload names the queue ("*" broadcasts).
|
|
273
|
+
// Filtering matters at scale: without it every enqueue wakes every idle
|
|
274
|
+
// taker of every queue on the store.
|
|
197
275
|
let wakeVersion = 0
|
|
198
|
-
let
|
|
199
|
-
const
|
|
276
|
+
let lastBroadcast = 0
|
|
277
|
+
const lastWake = new Map<JobStore.QueueName, number>()
|
|
278
|
+
interface Waiter {
|
|
279
|
+
readonly queues: ReadonlySet<JobStore.QueueName>
|
|
280
|
+
readonly deferred: Deferred.Deferred<void>
|
|
281
|
+
}
|
|
282
|
+
const waiters = new Set<Waiter>()
|
|
283
|
+
const lastWakeFor = (queue: JobStore.QueueName) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast)
|
|
284
|
+
const signalWake = (queue?: JobStore.QueueName) => {
|
|
200
285
|
wakeVersion += 1
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
286
|
+
if (queue === undefined) {
|
|
287
|
+
lastBroadcast = wakeVersion
|
|
288
|
+
} else {
|
|
289
|
+
lastWake.set(queue, wakeVersion)
|
|
290
|
+
}
|
|
291
|
+
// Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting
|
|
292
|
+
// fibers synchronously, and a woken taker that re-parks registers a
|
|
293
|
+
// NEW waiter — resolving inside the live Set iteration would visit it
|
|
294
|
+
// and livelock.
|
|
295
|
+
const toWake: Array<Waiter> = []
|
|
296
|
+
for (const waiter of waiters) {
|
|
297
|
+
if (queue === undefined || waiter.queues.has(queue)) {
|
|
298
|
+
waiters.delete(waiter)
|
|
299
|
+
toWake.push(waiter)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const waiter of toWake) {
|
|
303
|
+
Deferred.doneUnsafe(waiter.deferred, Effect.void)
|
|
304
|
+
}
|
|
204
305
|
}
|
|
205
306
|
// Resubscribe forever: if the LISTEN stream ends or fails, wake-ups
|
|
206
307
|
// degrade to the worker's pollInterval until the next attempt succeeds.
|
|
207
308
|
yield* client.listen(wakeChannel).pipe(
|
|
208
|
-
Stream.runForEach(() =>
|
|
309
|
+
Stream.runForEach((payload) =>
|
|
310
|
+
Effect.sync(() => signalWake(payload === "*" ? undefined : JobStore.QueueName(payload)))
|
|
311
|
+
),
|
|
209
312
|
Effect.catchCause((cause) =>
|
|
210
313
|
Effect.logWarning(
|
|
211
314
|
"effect-mq: LISTEN subscription failed; wake-ups degraded to polling until resubscribe",
|
|
@@ -217,15 +320,42 @@ export const make = (
|
|
|
217
320
|
Effect.forkScoped
|
|
218
321
|
)
|
|
219
322
|
if (options.historyTtl !== undefined) {
|
|
220
|
-
const
|
|
323
|
+
const ttlByState = JobStore.normalizeHistoryTtl(options.historyTtl)
|
|
221
324
|
const sweepMs = Duration.toMillis(options.historySweepInterval ?? "1 minute")
|
|
222
325
|
yield* Effect.gen(function*() {
|
|
223
326
|
yield* Effect.sleep(sweepMs)
|
|
224
327
|
const now = yield* nowDate
|
|
328
|
+
// Per-state ceilings, refined by stricter per-row keep ages — a quiet
|
|
329
|
+
// job name is pruned on the timer, not only when its group is acked.
|
|
330
|
+
for (const state of ["completed", "failed", "cancelled"] as const) {
|
|
331
|
+
const ttl = ttlByState[state]
|
|
332
|
+
yield* db.execute(sql`
|
|
333
|
+
DELETE FROM ${jobs}
|
|
334
|
+
WHERE ${jobs.state} = ${state} AND (
|
|
335
|
+
${ttl !== undefined ? sql`${jobs.finishedAt} <= ${new Date(now.getTime() - ttl)}` : sql`FALSE`}
|
|
336
|
+
OR (
|
|
337
|
+
COALESCE(
|
|
338
|
+
${jobs.keep}->${state}->>'ageMs',
|
|
339
|
+
CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
|
|
340
|
+
ELSE ${jobs.keep}->>'ageMs' END
|
|
341
|
+
) IS NOT NULL
|
|
342
|
+
AND ${jobs.finishedAt} <= ${now}::timestamptz
|
|
343
|
+
- make_interval(secs => (COALESCE(
|
|
344
|
+
${jobs.keep}->${state}->>'ageMs',
|
|
345
|
+
CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
|
|
346
|
+
ELSE ${jobs.keep}->>'ageMs' END
|
|
347
|
+
)::double precision) / 1000.0))
|
|
348
|
+
)
|
|
349
|
+
`)
|
|
350
|
+
}
|
|
351
|
+
// Dead dedup rows: expired windows, or pointers at vanished jobs.
|
|
225
352
|
yield* db.execute(sql`
|
|
226
|
-
DELETE FROM ${
|
|
227
|
-
WHERE ${
|
|
228
|
-
|
|
353
|
+
DELETE FROM ${dedupe}
|
|
354
|
+
WHERE (${dedupe.windowExpiresAt} IS NOT NULL AND ${dedupe.windowExpiresAt} <= ${now})
|
|
355
|
+
OR (${dedupe.windowExpiresAt} IS NULL AND NOT EXISTS (
|
|
356
|
+
SELECT 1 FROM ${jobs} WHERE ${jobs.id} = ${dedupe.jobId}
|
|
357
|
+
AND ${jobs.state} IN ('waiting', 'delayed', 'active')
|
|
358
|
+
))
|
|
229
359
|
`)
|
|
230
360
|
}).pipe(
|
|
231
361
|
Effect.catchCause((cause) => Effect.logWarning("effect-mq: history sweep failed", cause)),
|
|
@@ -235,12 +365,13 @@ export const make = (
|
|
|
235
365
|
}
|
|
236
366
|
|
|
237
367
|
// Local mutation wake-up: bump synchronously, then best-effort NOTIFY so
|
|
238
|
-
// workers in other processes wake promptly too.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
368
|
+
// workers in other processes wake promptly too. The payload names the
|
|
369
|
+
// queue (and must be non-empty: @effect/sql-pg drops falsy payloads).
|
|
370
|
+
const wakeUp = (queue?: JobStore.QueueName): Effect.Effect<void> =>
|
|
371
|
+
Effect.suspend(() => {
|
|
372
|
+
signalWake(queue)
|
|
373
|
+
return client.notify(wakeChannel, queue !== undefined && queue.length > 0 ? queue : "*").pipe(Effect.ignore)
|
|
374
|
+
})
|
|
244
375
|
|
|
245
376
|
const nowDate = Effect.map(Clock.currentTimeMillis, (ms) => new Date(ms))
|
|
246
377
|
|
|
@@ -263,14 +394,39 @@ export const make = (
|
|
|
263
394
|
`)
|
|
264
395
|
|
|
265
396
|
// Retention: drop terminal peers (same name + state) beyond count/age.
|
|
397
|
+
const keepPolicyFor = (
|
|
398
|
+
keep: JobStore.KeepPolicy | null | undefined,
|
|
399
|
+
state: string
|
|
400
|
+
): JobStore.KeepStatePolicy | undefined => {
|
|
401
|
+
if (keep === null || keep === undefined) return undefined
|
|
402
|
+
const policy = state === "completed"
|
|
403
|
+
? keep.completed
|
|
404
|
+
: state === "failed"
|
|
405
|
+
? keep.failed
|
|
406
|
+
: state === "cancelled"
|
|
407
|
+
? keep.cancelled
|
|
408
|
+
: undefined
|
|
409
|
+
if (policy !== undefined) return policy
|
|
410
|
+
// Rows persisted by 0.2.x carry the flat {count, ageMs} shape — honour
|
|
411
|
+
// it as an all-states policy so upgrades keep pruning.
|
|
412
|
+
if (
|
|
413
|
+
keep.completed === undefined && keep.failed === undefined && keep.cancelled === undefined &&
|
|
414
|
+
("count" in keep || "ageMs" in keep)
|
|
415
|
+
) {
|
|
416
|
+
// SAFETY: the flat legacy shape carries KeepStatePolicy fields.
|
|
417
|
+
return keep as JobStore.KeepStatePolicy
|
|
418
|
+
}
|
|
419
|
+
return undefined
|
|
420
|
+
}
|
|
421
|
+
|
|
266
422
|
const applyKeep = (
|
|
267
423
|
tx: Pick<Db, "execute">,
|
|
268
424
|
row: { name: string; state: string; keep: JobStore.KeepPolicy | null },
|
|
269
425
|
now: Date
|
|
270
426
|
) =>
|
|
271
427
|
Effect.gen(function*() {
|
|
272
|
-
const keep = row.keep
|
|
273
|
-
if (keep ===
|
|
428
|
+
const keep = keepPolicyFor(row.keep, row.state)
|
|
429
|
+
if (keep === undefined) return
|
|
274
430
|
if (keep.ageMs !== undefined) {
|
|
275
431
|
yield* tx.execute(sql`
|
|
276
432
|
DELETE FROM ${jobs}
|
|
@@ -307,53 +463,209 @@ export const make = (
|
|
|
307
463
|
)
|
|
308
464
|
)
|
|
309
465
|
|
|
310
|
-
|
|
311
|
-
|
|
466
|
+
// The shared INSERT: store-assigned ids come from the configured
|
|
467
|
+
// generator (or the seq sequence); loop on the (unlikely) collision with
|
|
468
|
+
// an existing id — ON CONFLICT DO NOTHING makes the retry safe. Returns
|
|
469
|
+
// the result or undefined when the caller-supplied id already exists.
|
|
470
|
+
const insertJob = (
|
|
471
|
+
exec: Pick<Db, "execute">,
|
|
472
|
+
request: JobStore.EnqueueRequest,
|
|
473
|
+
now: Date
|
|
474
|
+
) =>
|
|
475
|
+
Effect.gen(function*() {
|
|
476
|
+
const runAt = new Date(now.getTime() + Math.max(0, request.delayMs))
|
|
477
|
+
const state = request.delayMs > 0 ? "delayed" : "waiting"
|
|
478
|
+
const generate = options.idGenerator
|
|
479
|
+
for (let i = 0; i < 5; i++) {
|
|
480
|
+
const generated = request.id === undefined && generate !== undefined
|
|
481
|
+
? yield* Effect.suspend(() => {
|
|
482
|
+
const raw = generate(request)
|
|
483
|
+
return Effect.isEffect(raw) ? raw : Effect.succeed(raw)
|
|
484
|
+
})
|
|
485
|
+
: undefined
|
|
486
|
+
const idExpr = request.id !== undefined
|
|
487
|
+
? sql`${request.id}`
|
|
488
|
+
: generated !== undefined
|
|
489
|
+
? sql`${generated}`
|
|
490
|
+
: sql`'j-' || ${seqExpr}::text`
|
|
491
|
+
const rows = rowsOf(yield* exec.execute<{ id: string }>(sql`
|
|
492
|
+
INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
|
|
493
|
+
attempts_max, backoff, keep, timeout_ms, dedupe_key, run_at, enqueued_at${extraColumnNames})
|
|
494
|
+
VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
|
|
495
|
+
${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
|
|
496
|
+
${request.attemptsMax},
|
|
497
|
+
${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
|
|
498
|
+
${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
|
|
499
|
+
${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null}, ${runAt}, ${now}${extraColumnValues(request)})
|
|
500
|
+
ON CONFLICT (id) DO NOTHING
|
|
501
|
+
RETURNING ${jobs.id} AS id
|
|
502
|
+
`).pipe(Effect.mapError(storeError("enqueue failed"))))
|
|
503
|
+
const inserted = rows[0]
|
|
504
|
+
if (inserted !== undefined) {
|
|
505
|
+
return { id: JobId(inserted.id), duplicate: false }
|
|
506
|
+
}
|
|
507
|
+
if (request.id !== undefined) {
|
|
508
|
+
return { id: request.id, duplicate: true }
|
|
509
|
+
}
|
|
510
|
+
// generated id collided with an existing user id; try again
|
|
511
|
+
}
|
|
512
|
+
return yield* new JobStore.JobStoreError({
|
|
513
|
+
message: "enqueue failed: could not generate a unique job id"
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
// Enqueue with a dedup policy: one transaction locks the (name, key) row
|
|
518
|
+
// and applies the decision tree (replace-while-delayed, throttle window,
|
|
519
|
+
// pending dedup) before falling through to a fresh insert.
|
|
520
|
+
const enqueueDeduped = (request: JobStore.EnqueueRequest, policy: JobStore.DedupePolicy) =>
|
|
521
|
+
db.transaction((tx) =>
|
|
312
522
|
Effect.gen(function*() {
|
|
313
523
|
const now = yield* nowDate
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
? yield* Effect.suspend(() => {
|
|
323
|
-
const raw = generate(request)
|
|
324
|
-
return Effect.isEffect(raw) ? raw : Effect.succeed(raw)
|
|
325
|
-
})
|
|
326
|
-
: undefined
|
|
327
|
-
const idExpr = request.id !== undefined
|
|
328
|
-
? sql`${request.id}`
|
|
329
|
-
: generated !== undefined
|
|
330
|
-
? sql`${generated}`
|
|
331
|
-
: sql`'j-' || ${seqExpr}::text`
|
|
332
|
-
const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
|
|
333
|
-
INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
|
|
334
|
-
attempts_max, backoff, keep, timeout_ms, run_at, enqueued_at)
|
|
335
|
-
VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
|
|
336
|
-
${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
|
|
337
|
-
${request.attemptsMax},
|
|
338
|
-
${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
|
|
339
|
-
${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
|
|
340
|
-
${request.timeoutMs ?? null}, ${runAt}, ${now})
|
|
341
|
-
ON CONFLICT (id) DO NOTHING
|
|
342
|
-
RETURNING ${jobs.id} AS id
|
|
343
|
-
`).pipe(Effect.mapError(storeError("enqueue failed"))))
|
|
344
|
-
const inserted = rows[0]
|
|
345
|
-
if (inserted !== undefined) {
|
|
346
|
-
yield* wakeUp
|
|
347
|
-
return { id: JobId(inserted.id), duplicate: false }
|
|
524
|
+
// The explicit-id duplicate check precedes the dedup tree, matching
|
|
525
|
+
// the memory and redis drivers.
|
|
526
|
+
if (request.id !== undefined) {
|
|
527
|
+
const existing = rowsOf(yield* tx.execute<{ id: string }>(sql`
|
|
528
|
+
SELECT ${jobs.id} AS id FROM ${jobs} WHERE ${jobs.id} = ${request.id}
|
|
529
|
+
`))
|
|
530
|
+
if (existing.length > 0) {
|
|
531
|
+
return { id: request.id, duplicate: true, wake: false }
|
|
348
532
|
}
|
|
349
|
-
|
|
350
|
-
|
|
533
|
+
}
|
|
534
|
+
// A SELECT FOR UPDATE on a missing row locks nothing, so two
|
|
535
|
+
// concurrent first-enqueues would both insert. The no-op upsert
|
|
536
|
+
// always takes the row lock: a fresh placeholder (job_id = '')
|
|
537
|
+
// reads as "no entry" and falls through to the insert below.
|
|
538
|
+
const rows = rowsOf(yield* tx.execute<{ jobId: string; windowExpiresAt: Date | null }>(sql`
|
|
539
|
+
INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
|
|
540
|
+
VALUES (${request.name}, ${policy.key}, '', NULL)
|
|
541
|
+
ON CONFLICT (name, key) DO UPDATE SET name = EXCLUDED.name
|
|
542
|
+
RETURNING ${dedupe.jobId} AS "jobId", ${dedupe.windowExpiresAt} AS "windowExpiresAt"
|
|
543
|
+
`))
|
|
544
|
+
const entry = rows[0]
|
|
545
|
+
if (entry !== undefined && entry.jobId !== "") {
|
|
546
|
+
// Plain read (no FOR UPDATE): locking the job row here would
|
|
547
|
+
// invert the jobs-then-dedupe lock order every terminal
|
|
548
|
+
// transition uses and deadlock under load. The replace branch
|
|
549
|
+
// compensates with a state-conditional UPDATE.
|
|
550
|
+
const keyed = rowsOf(yield* tx.execute<{ state: JobStore.JobState }>(sql`
|
|
551
|
+
SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${entry.jobId}
|
|
552
|
+
`))
|
|
553
|
+
const keyedState = keyed[0]?.state
|
|
554
|
+
const windowLive = entry.windowExpiresAt !== null &&
|
|
555
|
+
entry.windowExpiresAt.getTime() > now.getTime()
|
|
556
|
+
const bumpWindow = policy.extend && policy.ttlMs !== undefined
|
|
557
|
+
? tx.execute(sql`
|
|
558
|
+
UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
|
|
559
|
+
WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
|
|
560
|
+
`).pipe(Effect.asVoid)
|
|
561
|
+
: Effect.void
|
|
562
|
+
// Latest-wins while the keyed job is still delayed. The UPDATE
|
|
563
|
+
// re-checks the state so a concurrent claim degrades this to a
|
|
564
|
+
// plain dedup instead of rewriting an active job.
|
|
565
|
+
if (policy.replace && keyedState === "delayed") {
|
|
566
|
+
const replaced = rowsOf(yield* tx.execute<{ id: string; queue: string }>(sql`
|
|
567
|
+
UPDATE ${jobs} SET
|
|
568
|
+
payload = ${JSON.stringify(request.payload ?? null)}::jsonb,
|
|
569
|
+
metadata = ${JSON.stringify(request.metadata)}::jsonb,
|
|
570
|
+
priority = ${request.priority},
|
|
571
|
+
attempts_max = ${request.attemptsMax},
|
|
572
|
+
backoff = ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
|
|
573
|
+
keep = ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
|
|
574
|
+
timeout_ms = ${request.timeoutMs ?? null},
|
|
575
|
+
run_at = ${new Date(now.getTime() + Math.max(0, request.delayMs))}${extraColumnAssignments(request)}
|
|
576
|
+
WHERE ${jobs.id} = ${entry.jobId} AND ${jobs.state} = 'delayed'
|
|
577
|
+
RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
|
|
578
|
+
`))
|
|
579
|
+
if (replaced.length > 0) {
|
|
580
|
+
// A landed replace re-arms the ttl window (the entry must
|
|
581
|
+
// outlive the chain it is deduplicating).
|
|
582
|
+
if (policy.ttlMs !== undefined) {
|
|
583
|
+
yield* tx.execute(sql`
|
|
584
|
+
UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
|
|
585
|
+
WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
|
|
586
|
+
`)
|
|
587
|
+
}
|
|
588
|
+
// The replace does not move the job between queues — wake
|
|
589
|
+
// the queue that actually holds the now-rescheduled job.
|
|
590
|
+
return {
|
|
591
|
+
id: JobId(entry.jobId),
|
|
592
|
+
duplicate: true,
|
|
593
|
+
wake: true,
|
|
594
|
+
wakeQueue: JobStore.QueueName(replaced[0]?.queue ?? request.queue)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return { id: JobId(entry.jobId), duplicate: true, wake: false }
|
|
598
|
+
}
|
|
599
|
+
if (windowLive) {
|
|
600
|
+
yield* bumpWindow
|
|
601
|
+
return { id: JobId(entry.jobId), duplicate: true, wake: false }
|
|
602
|
+
}
|
|
603
|
+
const pending = keyedState !== undefined && keyedState !== "completed" &&
|
|
604
|
+
keyedState !== "failed" && keyedState !== "cancelled"
|
|
605
|
+
if (entry.windowExpiresAt === null && pending) {
|
|
606
|
+
return { id: JobId(entry.jobId), duplicate: true, wake: false }
|
|
351
607
|
}
|
|
352
|
-
//
|
|
608
|
+
// Dead entry: the new job takes over the key below.
|
|
353
609
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
610
|
+
const result = yield* insertJob(tx, request, now)
|
|
611
|
+
if (!result.duplicate) {
|
|
612
|
+
yield* tx.execute(sql`
|
|
613
|
+
INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
|
|
614
|
+
VALUES (${request.name}, ${policy.key}, ${result.id},
|
|
615
|
+
${policy.ttlMs === undefined ? null : new Date(now.getTime() + policy.ttlMs)})
|
|
616
|
+
ON CONFLICT (name, key) DO UPDATE SET
|
|
617
|
+
job_id = EXCLUDED.job_id, window_expires_at = EXCLUDED.window_expires_at
|
|
618
|
+
`)
|
|
619
|
+
}
|
|
620
|
+
return { ...result, wake: !result.duplicate }
|
|
621
|
+
})
|
|
622
|
+
).pipe(
|
|
623
|
+
// Residual lock-order inversions (replace vs cancel of the same
|
|
624
|
+
// delayed job) surface as Postgres deadlocks (40P01); one side is
|
|
625
|
+
// killed and safe to retry.
|
|
626
|
+
Effect.retry({
|
|
627
|
+
times: 3,
|
|
628
|
+
while: (error) => String(error).includes("40P01") || String(error).includes("deadlock detected")
|
|
629
|
+
}),
|
|
630
|
+
Effect.mapError((error) =>
|
|
631
|
+
error instanceof JobStore.JobStoreError ? error : storeError("enqueue failed")(error)
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
// A job leaving the pending states frees its pending-mode dedup row; live
|
|
636
|
+
// throttle windows deliberately outlast the job.
|
|
637
|
+
const releaseDedupe = (
|
|
638
|
+
exec: Pick<Db, "execute">,
|
|
639
|
+
name: string,
|
|
640
|
+
dedupeKey: string | null,
|
|
641
|
+
jobId: string,
|
|
642
|
+
now: Date
|
|
643
|
+
) =>
|
|
644
|
+
dedupeKey === null
|
|
645
|
+
? Effect.void
|
|
646
|
+
: exec.execute(sql`
|
|
647
|
+
DELETE FROM ${dedupe}
|
|
648
|
+
WHERE ${dedupe.name} = ${name} AND ${dedupe.key} = ${dedupeKey}
|
|
649
|
+
AND ${dedupe.jobId} = ${jobId}
|
|
650
|
+
AND (${dedupe.windowExpiresAt} IS NULL OR ${dedupe.windowExpiresAt} <= ${now})
|
|
651
|
+
`).pipe(Effect.asVoid)
|
|
652
|
+
|
|
653
|
+
const store: JobStore.Service = {
|
|
654
|
+
enqueue: (request) =>
|
|
655
|
+
Effect.gen(function*() {
|
|
656
|
+
if (request.dedupe !== undefined) {
|
|
657
|
+
const result = yield* enqueueDeduped(request, request.dedupe)
|
|
658
|
+
if (result.wake) {
|
|
659
|
+
yield* wakeUp("wakeQueue" in result && result.wakeQueue !== undefined ? result.wakeQueue : request.queue)
|
|
660
|
+
}
|
|
661
|
+
return { id: result.id, duplicate: result.duplicate }
|
|
662
|
+
}
|
|
663
|
+
const now = yield* nowDate
|
|
664
|
+
const result = yield* insertJob(db, request, now)
|
|
665
|
+
if (!result.duplicate) {
|
|
666
|
+
yield* wakeUp(request.queue)
|
|
667
|
+
}
|
|
668
|
+
return result
|
|
357
669
|
}),
|
|
358
670
|
|
|
359
671
|
claim: (claimOptions) =>
|
|
@@ -396,7 +708,7 @@ export const make = (
|
|
|
396
708
|
${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
|
|
397
709
|
${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
|
|
398
710
|
${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
|
|
399
|
-
${jobs.cancelRequested} AS "cancelRequested",
|
|
711
|
+
${jobs.cancelRequested} AS "cancelRequested", ${jobs.dedupeKey} AS "dedupeKey",
|
|
400
712
|
${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
|
|
401
713
|
${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
|
|
402
714
|
${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
|
|
@@ -443,13 +755,21 @@ export const make = (
|
|
|
443
755
|
seq = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.seq} ELSE ${seqExpr} END,
|
|
444
756
|
cancel_requested = FALSE`
|
|
445
757
|
const rows = rowsOf(yield* tx.execute<
|
|
446
|
-
{
|
|
758
|
+
{
|
|
759
|
+
processedAt: Date | null
|
|
760
|
+
name: string
|
|
761
|
+
state: string
|
|
762
|
+
keep: JobStore.KeepPolicy | null
|
|
763
|
+
dedupeKey: string | null
|
|
764
|
+
queue: string
|
|
765
|
+
}
|
|
447
766
|
>(sql`
|
|
448
767
|
UPDATE ${jobs} SET ${update},
|
|
449
768
|
attempts_made = ${jobs.attemptsMade} + 1, lock_token = NULL, lock_expires_at = NULL
|
|
450
769
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
|
|
451
770
|
RETURNING ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name",
|
|
452
|
-
${jobs.state} AS "state", ${jobs.keep} AS "keep"
|
|
771
|
+
${jobs.state} AS "state", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey",
|
|
772
|
+
${jobs.queue} AS "queue"
|
|
453
773
|
`))
|
|
454
774
|
const row = rows[0]
|
|
455
775
|
if (row === undefined) {
|
|
@@ -472,8 +792,12 @@ export const make = (
|
|
|
472
792
|
outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit
|
|
473
793
|
)
|
|
474
794
|
if (outcome._tag !== "Retry" || cancelledRetry) {
|
|
795
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now)
|
|
475
796
|
yield* applyKeep(tx, row, now)
|
|
476
797
|
}
|
|
798
|
+
return outcome._tag === "Retry" && !cancelledRetry
|
|
799
|
+
? JobStore.QueueName(row.queue)
|
|
800
|
+
: undefined
|
|
477
801
|
})
|
|
478
802
|
).pipe(
|
|
479
803
|
Effect.mapError((error) =>
|
|
@@ -482,12 +806,13 @@ export const make = (
|
|
|
482
806
|
? error
|
|
483
807
|
: storeError("ack failed")(error)
|
|
484
808
|
),
|
|
485
|
-
Effect.tap(() =>
|
|
809
|
+
Effect.tap((queue) => queue !== undefined ? wakeUp(queue) : Effect.void),
|
|
810
|
+
Effect.asVoid
|
|
486
811
|
),
|
|
487
812
|
|
|
488
813
|
release: (id, token) =>
|
|
489
814
|
Effect.gen(function*() {
|
|
490
|
-
const
|
|
815
|
+
const released = yield* db.transaction((tx) =>
|
|
491
816
|
Effect.gen(function*() {
|
|
492
817
|
const now = yield* nowDate
|
|
493
818
|
// A cancel that arrived while the worker was shutting down is
|
|
@@ -499,6 +824,8 @@ export const make = (
|
|
|
499
824
|
processedAt: Date | null
|
|
500
825
|
name: string
|
|
501
826
|
keep: JobStore.KeepPolicy | null
|
|
827
|
+
dedupeKey: string | null
|
|
828
|
+
queue: string
|
|
502
829
|
}
|
|
503
830
|
>(sql`
|
|
504
831
|
UPDATE ${jobs} SET
|
|
@@ -508,24 +835,26 @@ export const make = (
|
|
|
508
835
|
lock_token = NULL, lock_expires_at = NULL
|
|
509
836
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
|
|
510
837
|
RETURNING ${jobs.id} AS id, (${jobs.state} = 'cancelled') AS cancelled,
|
|
511
|
-
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
|
|
838
|
+
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
|
|
839
|
+
${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue"
|
|
512
840
|
`))
|
|
513
841
|
const row = rows[0]
|
|
514
842
|
if (row === undefined) return undefined
|
|
515
843
|
if (row.cancelled) {
|
|
516
844
|
yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
|
|
845
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now)
|
|
517
846
|
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
|
|
518
847
|
}
|
|
519
|
-
return row.cancelled
|
|
848
|
+
return { cancelled: row.cancelled, queue: JobStore.QueueName(row.queue) }
|
|
520
849
|
})
|
|
521
850
|
).pipe(Effect.mapError((error) =>
|
|
522
851
|
error instanceof JobStore.JobStoreError ? error : storeError("release failed")(error)
|
|
523
852
|
))
|
|
524
|
-
if (
|
|
853
|
+
if (released === undefined) {
|
|
525
854
|
return yield* explainMiss(id)
|
|
526
855
|
}
|
|
527
|
-
if (!cancelled) {
|
|
528
|
-
yield* wakeUp
|
|
856
|
+
if (!released.cancelled) {
|
|
857
|
+
yield* wakeUp(released.queue)
|
|
529
858
|
}
|
|
530
859
|
}),
|
|
531
860
|
|
|
@@ -578,6 +907,7 @@ export const make = (
|
|
|
578
907
|
processedAt: Date | null
|
|
579
908
|
name: string
|
|
580
909
|
keep: JobStore.KeepPolicy | null
|
|
910
|
+
dedupeKey: string | null
|
|
581
911
|
}
|
|
582
912
|
>(sql`
|
|
583
913
|
UPDATE ${jobs} SET
|
|
@@ -598,7 +928,7 @@ export const make = (
|
|
|
598
928
|
cancel_requested = FALSE
|
|
599
929
|
WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
|
|
600
930
|
RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt",
|
|
601
|
-
${jobs.name} AS "name", ${jobs.keep} AS "keep"
|
|
931
|
+
${jobs.name} AS "name", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey"
|
|
602
932
|
`))
|
|
603
933
|
const recovered: Array<{ id: JobStore.JobId; failed: boolean }> = []
|
|
604
934
|
for (const row of rows) {
|
|
@@ -610,6 +940,9 @@ export const make = (
|
|
|
610
940
|
now,
|
|
611
941
|
undefined
|
|
612
942
|
)
|
|
943
|
+
if (row.state === "cancelled" || row.state === "failed") {
|
|
944
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, row.id, now)
|
|
945
|
+
}
|
|
613
946
|
if (row.state === "cancelled") {
|
|
614
947
|
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
|
|
615
948
|
} else {
|
|
@@ -623,14 +956,18 @@ export const make = (
|
|
|
623
956
|
error instanceof JobStore.JobStoreError ? error : storeError("recoverStalled failed")(error)
|
|
624
957
|
),
|
|
625
958
|
Effect.tap((recovered) =>
|
|
626
|
-
recovered.some((entry) => !entry.failed) ? wakeUp : Effect.void
|
|
959
|
+
recovered.some((entry) => !entry.failed) ? wakeUp() : Effect.void
|
|
627
960
|
)
|
|
628
961
|
),
|
|
629
962
|
|
|
630
|
-
awaitWake: (
|
|
963
|
+
awaitWake: (queues, wakeToken) =>
|
|
631
964
|
Effect.suspend(() => {
|
|
632
|
-
if (
|
|
633
|
-
|
|
965
|
+
if (queues.some((queue) => lastWakeFor(queue) > wakeToken)) return Effect.void
|
|
966
|
+
const waiter: Waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe<void>() }
|
|
967
|
+
waiters.add(waiter)
|
|
968
|
+
return Deferred.await(waiter.deferred).pipe(
|
|
969
|
+
Effect.ensuring(Effect.sync(() => waiters.delete(waiter)))
|
|
970
|
+
)
|
|
634
971
|
}),
|
|
635
972
|
|
|
636
973
|
getJob: (id) =>
|
|
@@ -707,13 +1044,13 @@ export const make = (
|
|
|
707
1044
|
retry: (id) =>
|
|
708
1045
|
Effect.gen(function*() {
|
|
709
1046
|
const now = yield* nowDate
|
|
710
|
-
const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
|
|
1047
|
+
const rows = rowsOf(yield* db.execute<{ id: string; queue: string }>(sql`
|
|
711
1048
|
UPDATE ${jobs} SET state = 'waiting', attempts_made = 0, stalled_count = 0,
|
|
712
1049
|
cancel_requested = FALSE,
|
|
713
1050
|
exit = NULL, failed_reason = NULL, finished_at = NULL, processed_at = NULL,
|
|
714
1051
|
run_at = ${now}, seq = ${seqExpr}
|
|
715
1052
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'failed'
|
|
716
|
-
RETURNING ${jobs.id} AS id
|
|
1053
|
+
RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
|
|
717
1054
|
`).pipe(Effect.mapError(storeError("retry failed"))))
|
|
718
1055
|
if (rows.length === 0) {
|
|
719
1056
|
const existing = yield* db.select({ state: jobs.state }).from(jobs)
|
|
@@ -724,7 +1061,7 @@ export const make = (
|
|
|
724
1061
|
}
|
|
725
1062
|
return yield* new JobStore.JobNotRetryableError({ jobId: id, state: found.state })
|
|
726
1063
|
}
|
|
727
|
-
yield* wakeUp
|
|
1064
|
+
yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""))
|
|
728
1065
|
}),
|
|
729
1066
|
|
|
730
1067
|
cancel: (id) =>
|
|
@@ -740,6 +1077,7 @@ export const make = (
|
|
|
740
1077
|
processedAt: Date | null
|
|
741
1078
|
name: string
|
|
742
1079
|
keep: JobStore.KeepPolicy | null
|
|
1080
|
+
dedupeKey: string | null
|
|
743
1081
|
}
|
|
744
1082
|
>(sql`
|
|
745
1083
|
UPDATE ${jobs} SET
|
|
@@ -748,7 +1086,8 @@ export const make = (
|
|
|
748
1086
|
cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
|
|
749
1087
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
|
|
750
1088
|
RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
|
|
751
|
-
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
|
|
1089
|
+
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
|
|
1090
|
+
${jobs.dedupeKey} AS "dedupeKey"
|
|
752
1091
|
`))
|
|
753
1092
|
const row = rows[0]
|
|
754
1093
|
if (row === undefined) {
|
|
@@ -763,6 +1102,7 @@ export const make = (
|
|
|
763
1102
|
}
|
|
764
1103
|
if (row.state === "cancelled") {
|
|
765
1104
|
yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
|
|
1105
|
+
yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now)
|
|
766
1106
|
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
|
|
767
1107
|
}
|
|
768
1108
|
})
|
|
@@ -780,10 +1120,10 @@ export const make = (
|
|
|
780
1120
|
promote: (id) =>
|
|
781
1121
|
Effect.gen(function*() {
|
|
782
1122
|
const now = yield* nowDate
|
|
783
|
-
const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
|
|
1123
|
+
const rows = rowsOf(yield* db.execute<{ id: string; queue: string }>(sql`
|
|
784
1124
|
UPDATE ${jobs} SET state = 'waiting', run_at = ${now}
|
|
785
1125
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'delayed'
|
|
786
|
-
RETURNING ${jobs.id} AS id
|
|
1126
|
+
RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
|
|
787
1127
|
`).pipe(Effect.mapError(storeError("promote failed"))))
|
|
788
1128
|
if (rows.length === 0) {
|
|
789
1129
|
const existing = rowsOf(yield* db.execute<{ state: JobStore.JobState }>(sql`
|
|
@@ -795,7 +1135,7 @@ export const make = (
|
|
|
795
1135
|
}
|
|
796
1136
|
return yield* new JobStore.JobNotPromotableError({ jobId: id, state: found.state })
|
|
797
1137
|
}
|
|
798
|
-
yield* wakeUp
|
|
1138
|
+
yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""))
|
|
799
1139
|
}),
|
|
800
1140
|
|
|
801
1141
|
pause: (queue) =>
|
|
@@ -812,7 +1152,7 @@ export const make = (
|
|
|
812
1152
|
UPDATE ${queues} SET paused = FALSE WHERE ${queues.queue} = ${queue}
|
|
813
1153
|
`).pipe(
|
|
814
1154
|
Effect.mapError(storeError("resume failed")),
|
|
815
|
-
Effect.andThen(wakeUp)
|
|
1155
|
+
Effect.andThen(wakeUp(queue))
|
|
816
1156
|
),
|
|
817
1157
|
|
|
818
1158
|
pausedQueues: () =>
|
|
@@ -848,7 +1188,7 @@ export const make = (
|
|
|
848
1188
|
ELSE EXCLUDED.next_run_at END
|
|
849
1189
|
`).pipe(
|
|
850
1190
|
Effect.mapError(storeError("upsertSchedule failed")),
|
|
851
|
-
Effect.
|
|
1191
|
+
Effect.andThen(wakeUp(schedule.queue))
|
|
852
1192
|
),
|
|
853
1193
|
|
|
854
1194
|
removeSchedule: (key) =>
|