effect-mq 0.1.0 → 0.2.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 +167 -22
- package/dist/Job.d.ts +67 -4
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +76 -3
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +174 -3
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js +89 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts +37 -6
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +201 -25
- 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 +116 -10
- package/dist/Worker.js.map +1 -1
- package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +18 -2
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
- package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.js +287 -40
- 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 → drizzle-postgres}/schema.d.ts +306 -4
- package/dist/drizzle-postgres/schema.d.ts.map +1 -0
- package/dist/{drizzle → drizzle-postgres}/schema.js +36 -2
- package/dist/drizzle-postgres/schema.js.map +1 -0
- package/dist/redis/RedisJobStore.d.ts +56 -0
- package/dist/redis/RedisJobStore.d.ts.map +1 -0
- package/dist/redis/RedisJobStore.js +385 -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 +168 -0
- package/dist/redis/scripts.d.ts.map +1 -0
- package/dist/redis/scripts.js +755 -0
- package/dist/redis/scripts.js.map +1 -0
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +332 -3
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +8 -4
- package/src/Job.ts +189 -5
- package/src/JobStore.ts +252 -4
- package/src/MemoryJobStore.ts +273 -26
- package/src/Worker.ts +152 -10
- package/src/{drizzle → drizzle-postgres}/DrizzleJobStore.ts +412 -40
- package/src/{drizzle → drizzle-postgres}/schema.ts +52 -3
- package/src/redis/RedisJobStore.ts +597 -0
- package/src/redis/index.ts +8 -0
- package/src/redis/scripts.ts +862 -0
- package/src/testing/conformance.ts +421 -3
- package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
- 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.map +0 -1
- package/dist/drizzle/schema.js.map +0 -1
- /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
|
@@ -21,8 +21,8 @@ import type { PgClient } from "@effect/sql-pg"
|
|
|
21
21
|
import { asc, eq, sql } from "drizzle-orm"
|
|
22
22
|
import * as PgDrizzle from "drizzle-orm/effect-postgres"
|
|
23
23
|
import { getTableConfig } from "drizzle-orm/pg-core"
|
|
24
|
-
import { Clock, type Context, Deferred, Effect, Layer, Option, type Scope, Stream } from "effect"
|
|
25
|
-
import type { MqJobAttemptsTable, MqJobsTable } from "./schema.ts"
|
|
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"
|
|
26
26
|
|
|
27
27
|
const { JobId } = JobStore
|
|
28
28
|
|
|
@@ -34,8 +34,24 @@ export interface DrizzleJobStoreOptions<StoreId = JobStore.JobStore> {
|
|
|
34
34
|
readonly jobs: MqJobsTable
|
|
35
35
|
/** The run-ledger table instance (from `mqJobAttempts`). */
|
|
36
36
|
readonly attempts: MqJobAttemptsTable
|
|
37
|
+
/** The schedules table instance (from `mqSchedules`). */
|
|
38
|
+
readonly schedules: MqSchedulesTable
|
|
39
|
+
/** The queue pause/resume flags table (from `mqQueueControl`). */
|
|
40
|
+
readonly queues: MqQueueControlTable
|
|
37
41
|
/** Bind to a `JobStore.named(...)` key; default: the default `JobStore`. */
|
|
38
42
|
readonly store?: Context.Key<StoreId, JobStore.Service> | undefined
|
|
43
|
+
/**
|
|
44
|
+
* Store-level retention ceiling: terminal records older than this are
|
|
45
|
+
* removed by a periodic sweep. Per-job `keep` may only be stricter.
|
|
46
|
+
*/
|
|
47
|
+
readonly historyTtl?: Duration.Input | undefined
|
|
48
|
+
/** History sweep cadence (default 1 minute). */
|
|
49
|
+
readonly historySweepInterval?: Duration.Input | undefined
|
|
50
|
+
/**
|
|
51
|
+
* Generator for store-assigned job ids (e.g. `() => \`job_${ulid()}\``).
|
|
52
|
+
* Default: `j-<seq>` from the jobs sequence. See `JobStore.IdGenerator`.
|
|
53
|
+
*/
|
|
54
|
+
readonly idGenerator?: JobStore.IdGenerator | undefined
|
|
39
55
|
/**
|
|
40
56
|
* Probe the tables at startup and fail fast when the schema is missing
|
|
41
57
|
* (default true). Migrations are owned by your drizzle-kit pipeline.
|
|
@@ -74,6 +90,8 @@ type JobRow = {
|
|
|
74
90
|
readonly stalledCount: number
|
|
75
91
|
readonly backoff: JobStore.BackoffPolicy | null
|
|
76
92
|
readonly keep: JobStore.KeepPolicy | null
|
|
93
|
+
readonly timeoutMs: number | string | null
|
|
94
|
+
readonly cancelRequested: boolean
|
|
77
95
|
readonly runAt: Date
|
|
78
96
|
readonly enqueuedAt: Date
|
|
79
97
|
readonly processedAt: Date | null
|
|
@@ -82,6 +100,40 @@ type JobRow = {
|
|
|
82
100
|
readonly failedReason: string | null
|
|
83
101
|
}
|
|
84
102
|
|
|
103
|
+
type ScheduleRow = {
|
|
104
|
+
readonly key: string
|
|
105
|
+
readonly jobName: string
|
|
106
|
+
readonly queue: string
|
|
107
|
+
readonly cron: string | null
|
|
108
|
+
readonly tz: string | null
|
|
109
|
+
readonly everyMs: number | string | null
|
|
110
|
+
readonly payload: unknown
|
|
111
|
+
readonly metadata: Record<string, string>
|
|
112
|
+
readonly priority: number
|
|
113
|
+
readonly attemptsMax: number
|
|
114
|
+
readonly backoff: JobStore.BackoffPolicy | null
|
|
115
|
+
readonly keep: JobStore.KeepPolicy | null
|
|
116
|
+
readonly timeoutMs: number | string | null
|
|
117
|
+
readonly nextRunAt: Date
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const toSchedule = (row: ScheduleRow): JobStore.ScheduleRecord => ({
|
|
121
|
+
key: JobStore.ScheduleKey(row.key),
|
|
122
|
+
jobName: row.jobName,
|
|
123
|
+
queue: JobStore.QueueName(row.queue),
|
|
124
|
+
cron: row.cron ?? undefined,
|
|
125
|
+
tz: row.tz ?? undefined,
|
|
126
|
+
everyMs: row.everyMs === null ? undefined : Number(row.everyMs),
|
|
127
|
+
payload: row.payload,
|
|
128
|
+
metadata: row.metadata ?? {},
|
|
129
|
+
priority: row.priority,
|
|
130
|
+
attemptsMax: row.attemptsMax,
|
|
131
|
+
backoff: row.backoff ?? undefined,
|
|
132
|
+
keep: row.keep ?? undefined,
|
|
133
|
+
timeoutMs: row.timeoutMs === null ? undefined : Number(row.timeoutMs),
|
|
134
|
+
nextRunAt: row.nextRunAt.getTime()
|
|
135
|
+
})
|
|
136
|
+
|
|
85
137
|
const toRecord = (row: JobRow): JobStore.JobRecord => ({
|
|
86
138
|
id: JobId(row.id),
|
|
87
139
|
name: row.name,
|
|
@@ -95,6 +147,8 @@ const toRecord = (row: JobRow): JobStore.JobRecord => ({
|
|
|
95
147
|
stalledCount: row.stalledCount,
|
|
96
148
|
backoff: row.backoff ?? undefined,
|
|
97
149
|
keep: row.keep ?? undefined,
|
|
150
|
+
timeoutMs: row.timeoutMs === null || row.timeoutMs === undefined ? undefined : Number(row.timeoutMs),
|
|
151
|
+
cancelRequested: row.cancelRequested,
|
|
98
152
|
runAt: row.runAt.getTime(),
|
|
99
153
|
enqueuedAt: row.enqueuedAt.getTime(),
|
|
100
154
|
processedAt: row.processedAt?.getTime(),
|
|
@@ -117,6 +171,8 @@ export const make = (
|
|
|
117
171
|
const client = db.$client
|
|
118
172
|
const jobs = options.jobs
|
|
119
173
|
const attempts = options.attempts
|
|
174
|
+
const schedules = options.schedules
|
|
175
|
+
const queues = options.queues
|
|
120
176
|
const jobsName = getTableConfig(jobs).name
|
|
121
177
|
const attemptsName = getTableConfig(attempts).name
|
|
122
178
|
const wakeChannel = `effect_mq_wake_${jobsName}`
|
|
@@ -124,7 +180,9 @@ export const make = (
|
|
|
124
180
|
if (options.validate ?? true) {
|
|
125
181
|
yield* Effect.all([
|
|
126
182
|
db.select({ id: jobs.id }).from(jobs).limit(0),
|
|
127
|
-
db.select({ jobId: attempts.jobId }).from(attempts).limit(0)
|
|
183
|
+
db.select({ jobId: attempts.jobId }).from(attempts).limit(0),
|
|
184
|
+
db.select({ key: schedules.key }).from(schedules).limit(0),
|
|
185
|
+
db.select({ queue: queues.queue }).from(queues).limit(0)
|
|
128
186
|
]).pipe(
|
|
129
187
|
Effect.mapError(storeError(
|
|
130
188
|
`effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
|
|
@@ -158,6 +216,24 @@ export const make = (
|
|
|
158
216
|
Effect.forever,
|
|
159
217
|
Effect.forkScoped
|
|
160
218
|
)
|
|
219
|
+
if (options.historyTtl !== undefined) {
|
|
220
|
+
const ttlMs = Duration.toMillis(options.historyTtl)
|
|
221
|
+
const sweepMs = Duration.toMillis(options.historySweepInterval ?? "1 minute")
|
|
222
|
+
yield* Effect.gen(function*() {
|
|
223
|
+
yield* Effect.sleep(sweepMs)
|
|
224
|
+
const now = yield* nowDate
|
|
225
|
+
yield* db.execute(sql`
|
|
226
|
+
DELETE FROM ${jobs}
|
|
227
|
+
WHERE ${jobs.state} IN ('completed', 'failed', 'cancelled')
|
|
228
|
+
AND ${jobs.finishedAt} <= ${new Date(now.getTime() - ttlMs)}
|
|
229
|
+
`)
|
|
230
|
+
}).pipe(
|
|
231
|
+
Effect.catchCause((cause) => Effect.logWarning("effect-mq: history sweep failed", cause)),
|
|
232
|
+
Effect.forever,
|
|
233
|
+
Effect.forkScoped
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
161
237
|
// Local mutation wake-up: bump synchronously, then best-effort NOTIFY so
|
|
162
238
|
// workers in other processes wake promptly too.
|
|
163
239
|
const wakeUp: Effect.Effect<void> = Effect.suspend(() => {
|
|
@@ -237,21 +313,31 @@ export const make = (
|
|
|
237
313
|
const now = yield* nowDate
|
|
238
314
|
const runAt = new Date(now.getTime() + Math.max(0, request.delayMs))
|
|
239
315
|
const state = request.delayMs > 0 ? "delayed" : "waiting"
|
|
240
|
-
// Store-assigned ids come from the
|
|
241
|
-
// (unlikely) collision with
|
|
316
|
+
// Store-assigned ids come from the configured generator (or the
|
|
317
|
+
// seq sequence); loop on the (unlikely) collision with an existing
|
|
318
|
+
// id — ON CONFLICT DO NOTHING makes the retry safe.
|
|
319
|
+
const generate = options.idGenerator
|
|
242
320
|
for (let i = 0; i < 5; i++) {
|
|
321
|
+
const generated = request.id === undefined && generate !== undefined
|
|
322
|
+
? yield* Effect.suspend(() => {
|
|
323
|
+
const raw = generate(request)
|
|
324
|
+
return Effect.isEffect(raw) ? raw : Effect.succeed(raw)
|
|
325
|
+
})
|
|
326
|
+
: undefined
|
|
243
327
|
const idExpr = request.id !== undefined
|
|
244
328
|
? sql`${request.id}`
|
|
329
|
+
: generated !== undefined
|
|
330
|
+
? sql`${generated}`
|
|
245
331
|
: sql`'j-' || ${seqExpr}::text`
|
|
246
332
|
const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
|
|
247
333
|
INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
|
|
248
|
-
attempts_max, backoff, keep, run_at, enqueued_at)
|
|
334
|
+
attempts_max, backoff, keep, timeout_ms, run_at, enqueued_at)
|
|
249
335
|
VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
|
|
250
336
|
${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
|
|
251
337
|
${request.attemptsMax},
|
|
252
338
|
${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
|
|
253
339
|
${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
|
|
254
|
-
${runAt}, ${now})
|
|
340
|
+
${request.timeoutMs ?? null}, ${runAt}, ${now})
|
|
255
341
|
ON CONFLICT (id) DO NOTHING
|
|
256
342
|
RETURNING ${jobs.id} AS id
|
|
257
343
|
`).pipe(Effect.mapError(storeError("enqueue failed"))))
|
|
@@ -286,7 +372,12 @@ export const make = (
|
|
|
286
372
|
WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'delayed'
|
|
287
373
|
AND ${jobs.runAt} <= ${now}
|
|
288
374
|
`)
|
|
289
|
-
const
|
|
375
|
+
const pausedRows = rowsOf(yield* tx.execute<{ paused: boolean }>(sql`
|
|
376
|
+
SELECT ${queues.paused} AS "paused" FROM ${queues}
|
|
377
|
+
WHERE ${queues.queue} = ${claimOptions.queue}
|
|
378
|
+
`))
|
|
379
|
+
const isPaused = pausedRows[0]?.paused === true
|
|
380
|
+
const claimed = isPaused ? [] : rowsOf(yield* tx.execute<JobRow>(sql`
|
|
290
381
|
WITH candidate AS (
|
|
291
382
|
SELECT ${jobs.id} AS id FROM ${jobs}
|
|
292
383
|
WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'waiting'
|
|
@@ -304,7 +395,9 @@ export const make = (
|
|
|
304
395
|
${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
|
|
305
396
|
${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
|
|
306
397
|
${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
|
|
307
|
-
${jobs.keep} AS "keep", ${jobs.
|
|
398
|
+
${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
|
|
399
|
+
${jobs.cancelRequested} AS "cancelRequested",
|
|
400
|
+
${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
|
|
308
401
|
${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
|
|
309
402
|
${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
|
|
310
403
|
`))
|
|
@@ -335,12 +428,20 @@ export const make = (
|
|
|
335
428
|
Effect.gen(function*() {
|
|
336
429
|
const now = yield* nowDate
|
|
337
430
|
const update = outcome._tag === "Complete"
|
|
338
|
-
? sql`state = 'completed', exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
|
|
431
|
+
? sql`state = 'completed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
|
|
339
432
|
: outcome._tag === "Fail"
|
|
340
|
-
? sql`state = 'failed', exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
|
|
341
|
-
:
|
|
342
|
-
|
|
343
|
-
|
|
433
|
+
? sql`state = 'failed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
|
|
434
|
+
: outcome._tag === "Cancelled"
|
|
435
|
+
? sql`state = 'cancelled', cancel_requested = FALSE, finished_at = ${now}`
|
|
436
|
+
// A cancel that raced this natural failure wins over revival
|
|
437
|
+
// (mirrors release/recoverStalled).
|
|
438
|
+
: sql`state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled'
|
|
439
|
+
ELSE ${outcome.delayMs > 0 ? "delayed" : "waiting"} END,
|
|
440
|
+
finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
|
|
441
|
+
run_at = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.runAt}
|
|
442
|
+
ELSE ${new Date(now.getTime() + Math.max(0, outcome.delayMs))}::timestamptz END,
|
|
443
|
+
seq = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.seq} ELSE ${seqExpr} END,
|
|
444
|
+
cancel_requested = FALSE`
|
|
344
445
|
const rows = rowsOf(yield* tx.execute<
|
|
345
446
|
{ processedAt: Date | null; name: string; state: string; keep: JobStore.KeepPolicy | null }
|
|
346
447
|
>(sql`
|
|
@@ -354,13 +455,23 @@ export const make = (
|
|
|
354
455
|
if (row === undefined) {
|
|
355
456
|
return yield* explainMiss(id)
|
|
356
457
|
}
|
|
458
|
+
const cancelledRetry = outcome._tag === "Retry" && row.state === "cancelled"
|
|
357
459
|
const ledgerOutcome = outcome._tag === "Complete"
|
|
358
460
|
? "completed" as const
|
|
359
461
|
: outcome._tag === "Fail"
|
|
360
462
|
? "failed" as const
|
|
463
|
+
: outcome._tag === "Cancelled" || cancelledRetry
|
|
464
|
+
? "cancelled" as const
|
|
361
465
|
: "retried" as const
|
|
362
|
-
yield* insertAttempt(
|
|
363
|
-
|
|
466
|
+
yield* insertAttempt(
|
|
467
|
+
tx,
|
|
468
|
+
id,
|
|
469
|
+
ledgerOutcome,
|
|
470
|
+
row.processedAt,
|
|
471
|
+
now,
|
|
472
|
+
outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit
|
|
473
|
+
)
|
|
474
|
+
if (outcome._tag !== "Retry" || cancelledRetry) {
|
|
364
475
|
yield* applyKeep(tx, row, now)
|
|
365
476
|
}
|
|
366
477
|
})
|
|
@@ -376,22 +487,56 @@ export const make = (
|
|
|
376
487
|
|
|
377
488
|
release: (id, token) =>
|
|
378
489
|
Effect.gen(function*() {
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
490
|
+
const cancelled = yield* db.transaction((tx) =>
|
|
491
|
+
Effect.gen(function*() {
|
|
492
|
+
const now = yield* nowDate
|
|
493
|
+
// A cancel that arrived while the worker was shutting down is
|
|
494
|
+
// honoured instead of reviving the job.
|
|
495
|
+
const rows = rowsOf(yield* tx.execute<
|
|
496
|
+
{
|
|
497
|
+
id: string
|
|
498
|
+
cancelled: boolean
|
|
499
|
+
processedAt: Date | null
|
|
500
|
+
name: string
|
|
501
|
+
keep: JobStore.KeepPolicy | null
|
|
502
|
+
}
|
|
503
|
+
>(sql`
|
|
504
|
+
UPDATE ${jobs} SET
|
|
505
|
+
state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled' ELSE 'waiting' END,
|
|
506
|
+
finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
|
|
507
|
+
cancel_requested = FALSE,
|
|
508
|
+
lock_token = NULL, lock_expires_at = NULL
|
|
509
|
+
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
|
|
510
|
+
RETURNING ${jobs.id} AS id, (${jobs.state} = 'cancelled') AS cancelled,
|
|
511
|
+
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
|
|
512
|
+
`))
|
|
513
|
+
const row = rows[0]
|
|
514
|
+
if (row === undefined) return undefined
|
|
515
|
+
if (row.cancelled) {
|
|
516
|
+
yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
|
|
517
|
+
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
|
|
518
|
+
}
|
|
519
|
+
return row.cancelled
|
|
520
|
+
})
|
|
521
|
+
).pipe(Effect.mapError((error) =>
|
|
522
|
+
error instanceof JobStore.JobStoreError ? error : storeError("release failed")(error)
|
|
523
|
+
))
|
|
524
|
+
if (cancelled === undefined) {
|
|
385
525
|
return yield* explainMiss(id)
|
|
386
526
|
}
|
|
387
|
-
|
|
527
|
+
if (!cancelled) {
|
|
528
|
+
yield* wakeUp
|
|
529
|
+
}
|
|
388
530
|
}),
|
|
389
531
|
|
|
390
532
|
extendLocks: (locks, durationMs) =>
|
|
391
533
|
Effect.gen(function*() {
|
|
392
|
-
if (locks.length === 0)
|
|
534
|
+
if (locks.length === 0) {
|
|
535
|
+
const empty: JobStore.ExtendLocksResult = { lost: [], cancelRequested: [] }
|
|
536
|
+
return empty
|
|
537
|
+
}
|
|
393
538
|
const now = yield* nowDate
|
|
394
|
-
const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
|
|
539
|
+
const rows = rowsOf(yield* db.execute<{ id: string; status: "lost" | "cancel" }>(sql`
|
|
395
540
|
WITH input AS (
|
|
396
541
|
SELECT ids.job_id, toks.token
|
|
397
542
|
FROM unnest(${sql.param(locks.map((lock) => lock.id))}::text[]) WITH ORDINALITY AS ids(job_id, ord)
|
|
@@ -400,39 +545,78 @@ export const make = (
|
|
|
400
545
|
updated AS (
|
|
401
546
|
UPDATE ${jobs} SET lock_expires_at = ${new Date(now.getTime() + durationMs)}
|
|
402
547
|
FROM input
|
|
403
|
-
WHERE ${jobs.id} = input.job_id AND ${jobs.state} = 'active'
|
|
548
|
+
WHERE ${jobs.id} = input.job_id AND ${jobs.state} = 'active'
|
|
549
|
+
AND ${jobs.lockToken} = input.token AND ${jobs.cancelRequested} = FALSE
|
|
404
550
|
RETURNING ${jobs.id} AS id
|
|
405
551
|
)
|
|
406
|
-
SELECT input.job_id AS id
|
|
552
|
+
SELECT input.job_id AS id,
|
|
553
|
+
CASE WHEN ${jobs.id} IS NOT NULL AND ${jobs.state} = 'active'
|
|
554
|
+
AND ${jobs.lockToken} = input.token AND ${jobs.cancelRequested} THEN 'cancel'
|
|
555
|
+
ELSE 'lost' END AS status
|
|
556
|
+
FROM input
|
|
407
557
|
LEFT JOIN updated ON updated.id = input.job_id
|
|
558
|
+
LEFT JOIN ${jobs} ON ${jobs.id} = input.job_id
|
|
408
559
|
WHERE updated.id IS NULL
|
|
409
560
|
`).pipe(Effect.mapError(storeError("extendLocks failed"))))
|
|
410
|
-
|
|
561
|
+
const result: JobStore.ExtendLocksResult = {
|
|
562
|
+
lost: rows.filter((row) => row.status === "lost").map((row) => JobId(row.id)),
|
|
563
|
+
cancelRequested: rows.filter((row) => row.status === "cancel").map((row) => JobId(row.id))
|
|
564
|
+
}
|
|
565
|
+
return result
|
|
411
566
|
}),
|
|
412
567
|
|
|
413
568
|
recoverStalled: (recoverOptions) =>
|
|
414
569
|
db.transaction((tx) =>
|
|
415
570
|
Effect.gen(function*() {
|
|
416
571
|
const now = yield* nowDate
|
|
572
|
+
// A stalled job whose worker died before honouring a cancel
|
|
573
|
+
// request is finished as cancelled rather than revived.
|
|
417
574
|
const rows = rowsOf(yield* tx.execute<
|
|
418
|
-
{
|
|
575
|
+
{
|
|
576
|
+
id: string
|
|
577
|
+
state: string
|
|
578
|
+
processedAt: Date | null
|
|
579
|
+
name: string
|
|
580
|
+
keep: JobStore.KeepPolicy | null
|
|
581
|
+
}
|
|
419
582
|
>(sql`
|
|
420
583
|
UPDATE ${jobs} SET
|
|
421
|
-
stalled_count = ${jobs.
|
|
584
|
+
stalled_count = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.stalledCount}
|
|
585
|
+
ELSE ${jobs.stalledCount} + 1 END,
|
|
422
586
|
lock_token = NULL, lock_expires_at = NULL,
|
|
423
|
-
state = CASE
|
|
424
|
-
|
|
425
|
-
|
|
587
|
+
state = CASE
|
|
588
|
+
WHEN ${jobs.cancelRequested} THEN 'cancelled'
|
|
589
|
+
WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int THEN 'failed'
|
|
590
|
+
ELSE 'waiting' END,
|
|
591
|
+
finished_at = CASE
|
|
592
|
+
WHEN ${jobs.cancelRequested} OR ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
|
|
426
593
|
THEN ${now}::timestamptz ELSE NULL END,
|
|
427
|
-
failed_reason = CASE
|
|
428
|
-
|
|
594
|
+
failed_reason = CASE
|
|
595
|
+
WHEN ${jobs.cancelRequested} THEN NULL
|
|
596
|
+
WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
|
|
597
|
+
THEN 'job stalled more than allowable limit' ELSE NULL END,
|
|
598
|
+
cancel_requested = FALSE
|
|
429
599
|
WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
|
|
430
|
-
RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt"
|
|
600
|
+
RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt",
|
|
601
|
+
${jobs.name} AS "name", ${jobs.keep} AS "keep"
|
|
431
602
|
`))
|
|
603
|
+
const recovered: Array<{ id: JobStore.JobId; failed: boolean }> = []
|
|
432
604
|
for (const row of rows) {
|
|
433
|
-
yield* insertAttempt(
|
|
605
|
+
yield* insertAttempt(
|
|
606
|
+
tx,
|
|
607
|
+
row.id,
|
|
608
|
+
row.state === "cancelled" ? "cancelled" : "stalled",
|
|
609
|
+
row.processedAt,
|
|
610
|
+
now,
|
|
611
|
+
undefined
|
|
612
|
+
)
|
|
613
|
+
if (row.state === "cancelled") {
|
|
614
|
+
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
|
|
615
|
+
} else {
|
|
616
|
+
recovered.push({ id: JobId(row.id), failed: row.state === "failed" })
|
|
617
|
+
}
|
|
434
618
|
}
|
|
435
|
-
return
|
|
619
|
+
return recovered
|
|
436
620
|
})
|
|
437
621
|
).pipe(
|
|
438
622
|
Effect.mapError((error) =>
|
|
@@ -500,7 +684,9 @@ export const make = (
|
|
|
500
684
|
${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
|
|
501
685
|
${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
|
|
502
686
|
${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
|
|
503
|
-
${jobs.keep} AS "keep", ${jobs.
|
|
687
|
+
${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
|
|
688
|
+
${jobs.cancelRequested} AS "cancelRequested",
|
|
689
|
+
${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
|
|
504
690
|
${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
|
|
505
691
|
${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
|
|
506
692
|
FROM ${jobs}
|
|
@@ -523,6 +709,7 @@ export const make = (
|
|
|
523
709
|
const now = yield* nowDate
|
|
524
710
|
const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
|
|
525
711
|
UPDATE ${jobs} SET state = 'waiting', attempts_made = 0, stalled_count = 0,
|
|
712
|
+
cancel_requested = FALSE,
|
|
526
713
|
exit = NULL, failed_reason = NULL, finished_at = NULL, processed_at = NULL,
|
|
527
714
|
run_at = ${now}, seq = ${seqExpr}
|
|
528
715
|
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'failed'
|
|
@@ -540,6 +727,190 @@ export const make = (
|
|
|
540
727
|
yield* wakeUp
|
|
541
728
|
}),
|
|
542
729
|
|
|
730
|
+
cancel: (id) =>
|
|
731
|
+
db.transaction((tx) =>
|
|
732
|
+
Effect.gen(function*() {
|
|
733
|
+
const now = yield* nowDate
|
|
734
|
+
// One guarded statement: waiting/delayed become terminal, active
|
|
735
|
+
// gets the cancel-request flag; anything else is reported by state.
|
|
736
|
+
const rows = rowsOf(yield* tx.execute<
|
|
737
|
+
{
|
|
738
|
+
id: string
|
|
739
|
+
state: string
|
|
740
|
+
processedAt: Date | null
|
|
741
|
+
name: string
|
|
742
|
+
keep: JobStore.KeepPolicy | null
|
|
743
|
+
}
|
|
744
|
+
>(sql`
|
|
745
|
+
UPDATE ${jobs} SET
|
|
746
|
+
state = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN 'cancelled' ELSE ${jobs.state} END,
|
|
747
|
+
finished_at = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN ${now}::timestamptz ELSE ${jobs.finishedAt} END,
|
|
748
|
+
cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
|
|
749
|
+
WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
|
|
750
|
+
RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
|
|
751
|
+
${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
|
|
752
|
+
`))
|
|
753
|
+
const row = rows[0]
|
|
754
|
+
if (row === undefined) {
|
|
755
|
+
const existing = rowsOf(yield* tx.execute<{ state: JobStore.JobState }>(sql`
|
|
756
|
+
SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
|
|
757
|
+
`))
|
|
758
|
+
const found = existing[0]
|
|
759
|
+
if (found === undefined) {
|
|
760
|
+
return yield* new JobStore.JobNotFoundError({ jobId: id })
|
|
761
|
+
}
|
|
762
|
+
return yield* new JobStore.JobNotCancellableError({ jobId: id, state: found.state })
|
|
763
|
+
}
|
|
764
|
+
if (row.state === "cancelled") {
|
|
765
|
+
yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
|
|
766
|
+
yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
|
|
767
|
+
}
|
|
768
|
+
})
|
|
769
|
+
).pipe(
|
|
770
|
+
Effect.mapError((error) =>
|
|
771
|
+
error instanceof JobStore.JobNotFoundError ||
|
|
772
|
+
error instanceof JobStore.JobNotCancellableError ||
|
|
773
|
+
error instanceof JobStore.JobStoreError
|
|
774
|
+
? error
|
|
775
|
+
: storeError("cancel failed")(error)
|
|
776
|
+
),
|
|
777
|
+
Effect.asVoid
|
|
778
|
+
),
|
|
779
|
+
|
|
780
|
+
promote: (id) =>
|
|
781
|
+
Effect.gen(function*() {
|
|
782
|
+
const now = yield* nowDate
|
|
783
|
+
const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
|
|
784
|
+
UPDATE ${jobs} SET state = 'waiting', run_at = ${now}
|
|
785
|
+
WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'delayed'
|
|
786
|
+
RETURNING ${jobs.id} AS id
|
|
787
|
+
`).pipe(Effect.mapError(storeError("promote failed"))))
|
|
788
|
+
if (rows.length === 0) {
|
|
789
|
+
const existing = rowsOf(yield* db.execute<{ state: JobStore.JobState }>(sql`
|
|
790
|
+
SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
|
|
791
|
+
`).pipe(Effect.mapError(storeError("promote failed"))))
|
|
792
|
+
const found = existing[0]
|
|
793
|
+
if (found === undefined) {
|
|
794
|
+
return yield* new JobStore.JobNotFoundError({ jobId: id })
|
|
795
|
+
}
|
|
796
|
+
return yield* new JobStore.JobNotPromotableError({ jobId: id, state: found.state })
|
|
797
|
+
}
|
|
798
|
+
yield* wakeUp
|
|
799
|
+
}),
|
|
800
|
+
|
|
801
|
+
pause: (queue) =>
|
|
802
|
+
db.execute(sql`
|
|
803
|
+
INSERT INTO ${queues} (queue, paused) VALUES (${queue}, TRUE)
|
|
804
|
+
ON CONFLICT (queue) DO UPDATE SET paused = TRUE
|
|
805
|
+
`).pipe(
|
|
806
|
+
Effect.mapError(storeError("pause failed")),
|
|
807
|
+
Effect.asVoid
|
|
808
|
+
),
|
|
809
|
+
|
|
810
|
+
resume: (queue) =>
|
|
811
|
+
db.execute(sql`
|
|
812
|
+
UPDATE ${queues} SET paused = FALSE WHERE ${queues.queue} = ${queue}
|
|
813
|
+
`).pipe(
|
|
814
|
+
Effect.mapError(storeError("resume failed")),
|
|
815
|
+
Effect.andThen(wakeUp)
|
|
816
|
+
),
|
|
817
|
+
|
|
818
|
+
pausedQueues: () =>
|
|
819
|
+
db.execute<{ queue: string }>(sql`
|
|
820
|
+
SELECT ${queues.queue} AS queue FROM ${queues} WHERE ${queues.paused} = TRUE
|
|
821
|
+
`).pipe(
|
|
822
|
+
Effect.mapError(storeError("pausedQueues failed")),
|
|
823
|
+
Effect.map((result) => rowsOf(result).map((row) => JobStore.QueueName(row.queue)))
|
|
824
|
+
),
|
|
825
|
+
|
|
826
|
+
upsertSchedule: (schedule) =>
|
|
827
|
+
db.execute(sql`
|
|
828
|
+
INSERT INTO ${schedules} (key, job_name, queue, cron, tz, every_ms, payload, metadata,
|
|
829
|
+
priority, attempts_max, backoff, keep, timeout_ms, next_run_at)
|
|
830
|
+
VALUES (${schedule.key}, ${schedule.jobName}, ${schedule.queue},
|
|
831
|
+
${schedule.cron ?? null}, ${schedule.tz ?? null}, ${schedule.everyMs ?? null},
|
|
832
|
+
${JSON.stringify(schedule.payload ?? null)}::jsonb, ${JSON.stringify(schedule.metadata)}::jsonb,
|
|
833
|
+
${schedule.priority}, ${schedule.attemptsMax},
|
|
834
|
+
${schedule.backoff === undefined ? null : JSON.stringify(schedule.backoff)}::jsonb,
|
|
835
|
+
${schedule.keep === undefined ? null : JSON.stringify(schedule.keep)}::jsonb,
|
|
836
|
+
${schedule.timeoutMs ?? null}, ${new Date(schedule.nextRunAt)})
|
|
837
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
838
|
+
job_name = EXCLUDED.job_name, queue = EXCLUDED.queue, cron = EXCLUDED.cron,
|
|
839
|
+
tz = EXCLUDED.tz, every_ms = EXCLUDED.every_ms, payload = EXCLUDED.payload,
|
|
840
|
+
metadata = EXCLUDED.metadata, priority = EXCLUDED.priority,
|
|
841
|
+
attempts_max = EXCLUDED.attempts_max, backoff = EXCLUDED.backoff,
|
|
842
|
+
keep = EXCLUDED.keep, timeout_ms = EXCLUDED.timeout_ms,
|
|
843
|
+
next_run_at = CASE
|
|
844
|
+
WHEN ${schedules.cron} IS NOT DISTINCT FROM EXCLUDED.cron
|
|
845
|
+
AND ${schedules.tz} IS NOT DISTINCT FROM EXCLUDED.tz
|
|
846
|
+
AND ${schedules.everyMs} IS NOT DISTINCT FROM EXCLUDED.every_ms
|
|
847
|
+
THEN ${schedules.nextRunAt}
|
|
848
|
+
ELSE EXCLUDED.next_run_at END
|
|
849
|
+
`).pipe(
|
|
850
|
+
Effect.mapError(storeError("upsertSchedule failed")),
|
|
851
|
+
Effect.asVoid
|
|
852
|
+
),
|
|
853
|
+
|
|
854
|
+
removeSchedule: (key) =>
|
|
855
|
+
db.execute<{ key: string }>(sql`
|
|
856
|
+
DELETE FROM ${schedules} WHERE ${schedules.key} = ${key}
|
|
857
|
+
RETURNING ${schedules.key} AS key
|
|
858
|
+
`).pipe(
|
|
859
|
+
Effect.mapError(storeError("removeSchedule failed")),
|
|
860
|
+
Effect.map((result) => rowsOf(result).length > 0)
|
|
861
|
+
),
|
|
862
|
+
|
|
863
|
+
listSchedules: (listOptions) =>
|
|
864
|
+
Effect.gen(function*() {
|
|
865
|
+
const conditions = [sql`TRUE`]
|
|
866
|
+
if (listOptions?.jobName !== undefined) {
|
|
867
|
+
conditions.push(sql`${schedules.jobName} = ${listOptions.jobName}`)
|
|
868
|
+
}
|
|
869
|
+
if (listOptions?.queue !== undefined) {
|
|
870
|
+
conditions.push(sql`${schedules.queue} = ${listOptions.queue}`)
|
|
871
|
+
}
|
|
872
|
+
const rows = rowsOf(yield* db.execute<ScheduleRow>(sql`
|
|
873
|
+
SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
|
|
874
|
+
${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
|
|
875
|
+
${schedules.everyMs} AS "everyMs", ${schedules.payload} AS "payload",
|
|
876
|
+
${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
|
|
877
|
+
${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
|
|
878
|
+
${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
|
|
879
|
+
${schedules.nextRunAt} AS "nextRunAt"
|
|
880
|
+
FROM ${schedules}
|
|
881
|
+
WHERE ${sql.join(conditions, sql` AND `)}
|
|
882
|
+
ORDER BY ${schedules.key}
|
|
883
|
+
`).pipe(Effect.mapError(storeError("listSchedules failed"))))
|
|
884
|
+
return rows.map(toSchedule)
|
|
885
|
+
}),
|
|
886
|
+
|
|
887
|
+
dueSchedules: () =>
|
|
888
|
+
Effect.gen(function*() {
|
|
889
|
+
const now = yield* nowDate
|
|
890
|
+
const rows = rowsOf(yield* db.execute<ScheduleRow>(sql`
|
|
891
|
+
SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
|
|
892
|
+
${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
|
|
893
|
+
${schedules.everyMs} AS "everyMs", ${schedules.payload} AS "payload",
|
|
894
|
+
${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
|
|
895
|
+
${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
|
|
896
|
+
${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
|
|
897
|
+
${schedules.nextRunAt} AS "nextRunAt"
|
|
898
|
+
FROM ${schedules}
|
|
899
|
+
WHERE ${schedules.nextRunAt} <= ${now}
|
|
900
|
+
ORDER BY ${schedules.nextRunAt} ASC
|
|
901
|
+
`).pipe(Effect.mapError(storeError("dueSchedules failed"))))
|
|
902
|
+
return rows.map(toSchedule)
|
|
903
|
+
}),
|
|
904
|
+
|
|
905
|
+
advanceSchedule: (key, expectedRunAt, nextRunAt) =>
|
|
906
|
+
db.execute(sql`
|
|
907
|
+
UPDATE ${schedules} SET next_run_at = ${new Date(nextRunAt)}
|
|
908
|
+
WHERE ${schedules.key} = ${key} AND ${schedules.nextRunAt} = ${new Date(expectedRunAt)}
|
|
909
|
+
`).pipe(
|
|
910
|
+
Effect.mapError(storeError("advanceSchedule failed")),
|
|
911
|
+
Effect.asVoid
|
|
912
|
+
),
|
|
913
|
+
|
|
543
914
|
counts: (queue) =>
|
|
544
915
|
db.execute<{ state: JobStore.JobState; count: number }>(sql`
|
|
545
916
|
SELECT ${jobs.state} AS "state", count(*)::int AS "count" FROM ${jobs}
|
|
@@ -554,7 +925,8 @@ export const make = (
|
|
|
554
925
|
delayed: 0,
|
|
555
926
|
active: 0,
|
|
556
927
|
completed: 0,
|
|
557
|
-
failed: 0
|
|
928
|
+
failed: 0,
|
|
929
|
+
cancelled: 0
|
|
558
930
|
} satisfies Record<JobStore.JobState, number>
|
|
559
931
|
for (const row of rows) counts[row.state] = row.count
|
|
560
932
|
return counts
|