@strav/queue 0.4.31 → 1.0.0-alpha.4
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 +243 -0
- package/package.json +21 -29
- package/src/console/index.ts +10 -0
- package/src/console/queue_console_provider.ts +32 -0
- package/src/console/queue_failed.ts +57 -0
- package/src/console/queue_flush.ts +41 -0
- package/src/console/queue_retry.ts +68 -0
- package/src/console/queue_work.ts +85 -0
- package/src/console/scheduler_list.ts +27 -0
- package/src/console/scheduler_run.ts +31 -0
- package/src/console/scheduler_work.ts +32 -0
- package/src/cron.ts +194 -0
- package/src/database_queue.ts +177 -0
- package/src/failed_jobs_schema.ts +37 -0
- package/src/index.ts +62 -3
- package/src/job.ts +153 -0
- package/src/job_registry.ts +135 -0
- package/src/job_schema.ts +49 -0
- package/src/queue.ts +69 -0
- package/src/scheduler.ts +271 -0
- package/src/scheduler_runs_schema.ts +33 -0
- package/src/sync_queue.ts +126 -0
- package/src/worker.ts +351 -0
- package/src/providers/index.ts +0 -3
- package/src/providers/queue_provider.ts +0 -29
- package/src/queue/circuit_breaker.ts +0 -135
- package/src/queue/index.ts +0 -22
- package/src/queue/queue.ts +0 -493
- package/src/queue/worker.ts +0 -273
- package/src/scheduler/cron.ts +0 -146
- package/src/scheduler/index.ts +0 -8
- package/src/scheduler/runner.ts +0 -116
- package/src/scheduler/schedule.ts +0 -292
- package/src/scheduler/scheduler.ts +0 -71
- package/tsconfig.json +0 -5
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav scheduler:work` — run the minute-tick scheduler loop.
|
|
3
|
+
*
|
|
4
|
+
* Long-running; SIGINT / SIGTERM aborts the loop, which (per Scheduler's
|
|
5
|
+
* semantics) returns within one tick.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command, ExitCode } from '@strav/cli'
|
|
9
|
+
import { Scheduler } from '../scheduler.ts'
|
|
10
|
+
|
|
11
|
+
export class SchedulerWork extends Command {
|
|
12
|
+
static signature = 'scheduler:work'
|
|
13
|
+
static description = 'Run the scheduler tick loop until interrupted.'
|
|
14
|
+
|
|
15
|
+
override async execute(): Promise<number> {
|
|
16
|
+
const scheduler = this.app.resolve(Scheduler)
|
|
17
|
+
const controller = new AbortController()
|
|
18
|
+
const sigint = () => controller.abort()
|
|
19
|
+
const sigterm = () => controller.abort()
|
|
20
|
+
process.once('SIGINT', sigint)
|
|
21
|
+
process.once('SIGTERM', sigterm)
|
|
22
|
+
|
|
23
|
+
this.info(`Scheduler started (${scheduler.all().length} entries).`)
|
|
24
|
+
try {
|
|
25
|
+
await scheduler.run(controller.signal)
|
|
26
|
+
return ExitCode.Success
|
|
27
|
+
} finally {
|
|
28
|
+
process.off('SIGINT', sigint)
|
|
29
|
+
process.off('SIGTERM', sigterm)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CronExpression` — parses a 5-field cron string + matches it against
|
|
3
|
+
* a `Date`.
|
|
4
|
+
*
|
|
5
|
+
* Fields, in order:
|
|
6
|
+
* 1. minute (0–59)
|
|
7
|
+
* 2. hour (0–23)
|
|
8
|
+
* 3. day-of-month (1–31)
|
|
9
|
+
* 4. month (1–12)
|
|
10
|
+
* 5. day-of-week (0–6, Sunday = 0)
|
|
11
|
+
*
|
|
12
|
+
* Per-field syntax:
|
|
13
|
+
* - `*` — any value
|
|
14
|
+
* - `N` — exactly N
|
|
15
|
+
* - `A-B` — every value in `[A, B]` inclusive
|
|
16
|
+
* - `A,B,C` — list of values (each item is itself one of the
|
|
17
|
+
* above forms)
|
|
18
|
+
* - `*\/N` — every Nth value across the full range
|
|
19
|
+
* - `A-B/N` — every Nth value across `[A, B]`
|
|
20
|
+
*
|
|
21
|
+
* Time zone: matches against the UTC components of the `Date` —
|
|
22
|
+
* `.getUTCMinutes()` / `.getUTCHours()` / etc. Predictable across
|
|
23
|
+
* machines; apps that need wall-clock scheduling translate by hand at
|
|
24
|
+
* the call site (or supply a `Date` already shifted to local).
|
|
25
|
+
*
|
|
26
|
+
* Name aliases (`jan` / `mon` / etc.) are not supported in V1; use
|
|
27
|
+
* numbers.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const FIELDS = [
|
|
31
|
+
{ name: 'minute', min: 0, max: 59 },
|
|
32
|
+
{ name: 'hour', min: 0, max: 23 },
|
|
33
|
+
{ name: 'day-of-month', min: 1, max: 31 },
|
|
34
|
+
{ name: 'month', min: 1, max: 12 },
|
|
35
|
+
{ name: 'day-of-week', min: 0, max: 6 },
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
export class CronExpression {
|
|
39
|
+
/** The expanded set of acceptable values per field — `Set<number>` × 5. */
|
|
40
|
+
private readonly fields: ReadonlyArray<ReadonlySet<number>>
|
|
41
|
+
|
|
42
|
+
constructor(public readonly expression: string) {
|
|
43
|
+
const parts = expression.trim().split(/\s+/)
|
|
44
|
+
if (parts.length !== 5) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`CronExpression: expected 5 space-separated fields, got ${parts.length}: "${expression}"`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
this.fields = parts.map((part, i) => {
|
|
50
|
+
const spec = FIELDS[i] as (typeof FIELDS)[number]
|
|
51
|
+
return parseField(part, spec.min, spec.max, spec.name)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True iff `date`'s UTC components fall within every field's accepted set. */
|
|
56
|
+
matches(date: Date): boolean {
|
|
57
|
+
const minute = date.getUTCMinutes()
|
|
58
|
+
const hour = date.getUTCHours()
|
|
59
|
+
const dayOfMonth = date.getUTCDate()
|
|
60
|
+
// JS months are 0–11; cron is 1–12.
|
|
61
|
+
const month = date.getUTCMonth() + 1
|
|
62
|
+
const dayOfWeek = date.getUTCDay()
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
(this.fields[0] as ReadonlySet<number>).has(minute) &&
|
|
66
|
+
(this.fields[1] as ReadonlySet<number>).has(hour) &&
|
|
67
|
+
(this.fields[2] as ReadonlySet<number>).has(dayOfMonth) &&
|
|
68
|
+
(this.fields[3] as ReadonlySet<number>).has(month) &&
|
|
69
|
+
(this.fields[4] as ReadonlySet<number>).has(dayOfWeek)
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parse one field. Handles `,` lists by recursing on each segment. */
|
|
75
|
+
function parseField(part: string, min: number, max: number, label: string): ReadonlySet<number> {
|
|
76
|
+
if (part === '') {
|
|
77
|
+
throw new Error(`CronExpression: empty ${label} field.`)
|
|
78
|
+
}
|
|
79
|
+
const out = new Set<number>()
|
|
80
|
+
for (const segment of part.split(',')) {
|
|
81
|
+
parseSegment(segment, min, max, label, out)
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse a single segment: `*`, `N`, `A-B`, `*\/N`, `A-B/N`. */
|
|
87
|
+
function parseSegment(segment: string, min: number, max: number, label: string, into: Set<number>) {
|
|
88
|
+
// Step syntax: split off `/N` if present.
|
|
89
|
+
let baseSegment = segment
|
|
90
|
+
let step = 1
|
|
91
|
+
const slashIdx = segment.indexOf('/')
|
|
92
|
+
if (slashIdx !== -1) {
|
|
93
|
+
baseSegment = segment.slice(0, slashIdx)
|
|
94
|
+
const stepText = segment.slice(slashIdx + 1)
|
|
95
|
+
const parsed = Number.parseInt(stepText, 10)
|
|
96
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== stepText) {
|
|
97
|
+
throw new Error(`CronExpression: bad step in ${label} "${segment}".`)
|
|
98
|
+
}
|
|
99
|
+
step = parsed
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let start: number
|
|
103
|
+
let end: number
|
|
104
|
+
if (baseSegment === '*') {
|
|
105
|
+
start = min
|
|
106
|
+
end = max
|
|
107
|
+
} else if (baseSegment.includes('-')) {
|
|
108
|
+
const [aText, bText] = baseSegment.split('-')
|
|
109
|
+
if (aText === undefined || bText === undefined) {
|
|
110
|
+
throw new Error(`CronExpression: bad range in ${label} "${segment}".`)
|
|
111
|
+
}
|
|
112
|
+
const a = Number.parseInt(aText, 10)
|
|
113
|
+
const b = Number.parseInt(bText, 10)
|
|
114
|
+
if (
|
|
115
|
+
!Number.isInteger(a) ||
|
|
116
|
+
!Number.isInteger(b) ||
|
|
117
|
+
String(a) !== aText ||
|
|
118
|
+
String(b) !== bText
|
|
119
|
+
) {
|
|
120
|
+
throw new Error(`CronExpression: bad range in ${label} "${segment}".`)
|
|
121
|
+
}
|
|
122
|
+
start = a
|
|
123
|
+
end = b
|
|
124
|
+
} else {
|
|
125
|
+
const n = Number.parseInt(baseSegment, 10)
|
|
126
|
+
if (!Number.isInteger(n) || String(n) !== baseSegment) {
|
|
127
|
+
throw new Error(`CronExpression: bad value in ${label} "${segment}".`)
|
|
128
|
+
}
|
|
129
|
+
start = n
|
|
130
|
+
end = n
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (start < min || end > max) {
|
|
134
|
+
throw new Error(`CronExpression: ${label} value out of range [${min}, ${max}]: "${segment}".`)
|
|
135
|
+
}
|
|
136
|
+
if (start > end) {
|
|
137
|
+
throw new Error(`CronExpression: ${label} range start > end: "${segment}".`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (let v = start; v <= end; v += step) {
|
|
141
|
+
into.add(v)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
146
|
+
// Public helpers — convenience constructors for the common cadences.
|
|
147
|
+
// Apps reaching beyond these use `cron(expression)` directly.
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** Every minute — `* * * * *`. */
|
|
151
|
+
export function everyMinute(): CronExpression {
|
|
152
|
+
return new CronExpression('* * * * *')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Every N minutes — emits a cron expression with `*\/N` in the minute field. Throws on non-positive N. */
|
|
156
|
+
export function everyMinutes(n: number): CronExpression {
|
|
157
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
158
|
+
throw new Error(`everyMinutes: expected a positive integer, got ${n}.`)
|
|
159
|
+
}
|
|
160
|
+
return new CronExpression(`*/${n} * * * *`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Top of every hour — `0 * * * *`. */
|
|
164
|
+
export function hourly(): CronExpression {
|
|
165
|
+
return new CronExpression('0 * * * *')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Midnight UTC daily — `0 0 * * *`. */
|
|
169
|
+
export function daily(): CronExpression {
|
|
170
|
+
return new CronExpression('0 0 * * *')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Daily at a specific UTC time — `dailyAt('14:30')` → `30 14 * * *`. */
|
|
174
|
+
export function dailyAt(time: string): CronExpression {
|
|
175
|
+
const match = time.match(/^(\d{1,2}):(\d{2})$/)
|
|
176
|
+
if (!match) {
|
|
177
|
+
throw new Error(`dailyAt: expected HH:MM (24-hour), got "${time}".`)
|
|
178
|
+
}
|
|
179
|
+
const hour = Number.parseInt(match[1] as string, 10)
|
|
180
|
+
const minute = Number.parseInt(match[2] as string, 10)
|
|
181
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
182
|
+
throw new Error(`dailyAt: time out of range "${time}".`)
|
|
183
|
+
}
|
|
184
|
+
return new CronExpression(`${minute} ${hour} * * *`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Escape hatch for non-trivial schedules — accepts any valid 5-field cron
|
|
189
|
+
* expression. Apps that want weekly / monthly / "Mondays at 9" etc. use
|
|
190
|
+
* this directly.
|
|
191
|
+
*/
|
|
192
|
+
export function cron(expression: string): CronExpression {
|
|
193
|
+
return new CronExpression(expression)
|
|
194
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DatabaseQueue` — Postgres-backed `Queue` driver.
|
|
3
|
+
*
|
|
4
|
+
* Persists each `dispatch` / `dispatchLater` as a `_strav_jobs` row;
|
|
5
|
+
* Workers (next M3 slice) `SELECT FOR UPDATE SKIP LOCKED` to claim
|
|
6
|
+
* available rows and run `handle()`.
|
|
7
|
+
*
|
|
8
|
+
* **Queue-until-commit semantics.** When `dispatch()` is called inside
|
|
9
|
+
* a `UnitOfWork.run(...)` or `TenantManager.withTenant(...)` scope, the
|
|
10
|
+
* driver routes the INSERT through the ambient transaction's executor
|
|
11
|
+
* (read from `transactionalStorage`). The new row commits + rolls back
|
|
12
|
+
* atomically with the surrounding transaction:
|
|
13
|
+
*
|
|
14
|
+
* - If the transaction COMMITs, the queue row is visible to Workers.
|
|
15
|
+
* The dispatched job runs.
|
|
16
|
+
* - If the transaction ROLLBACKs, the row never existed. The job is
|
|
17
|
+
* dropped.
|
|
18
|
+
*
|
|
19
|
+
* This is exactly the spec's M3 spike ("flush queue on commit; drop
|
|
20
|
+
* on rollback") — Postgres's transactional atomicity gives us the
|
|
21
|
+
* semantic for free; no deferred-callback machinery needed.
|
|
22
|
+
*
|
|
23
|
+
* Outside a transactional scope, `dispatch` writes against
|
|
24
|
+
* `this.db` directly (auto-commit).
|
|
25
|
+
*
|
|
26
|
+
* `dispatchSync` bypasses persistence entirely — instantiates the Job
|
|
27
|
+
* via the container and runs `handle()` in-process, just like
|
|
28
|
+
* `SyncQueue.dispatchSync`. The caller's session continues without a
|
|
29
|
+
* Worker ever seeing the work.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
currentTransactionalContext,
|
|
34
|
+
type Database,
|
|
35
|
+
type DatabaseExecutor,
|
|
36
|
+
type PostgresDatabase,
|
|
37
|
+
} from '@strav/database'
|
|
38
|
+
import { type Container, type Logger, ulid } from '@strav/kernel'
|
|
39
|
+
import type { JobClass, JobContext, PayloadOf } from './job.ts'
|
|
40
|
+
import { jobSchema } from './job_schema.ts'
|
|
41
|
+
import type { DispatchLaterOptions, DispatchOptions, Queue } from './queue.ts'
|
|
42
|
+
|
|
43
|
+
export interface DatabaseQueueOptions {
|
|
44
|
+
/** Postgres pool used for INSERTs outside an ambient transaction. */
|
|
45
|
+
db: PostgresDatabase | Database
|
|
46
|
+
/**
|
|
47
|
+
* Container used to construct Job instances for `dispatchSync`. The
|
|
48
|
+
* Worker (separate slice) also goes through the container, so the
|
|
49
|
+
* same `@inject()`-driven wiring resolves consistently.
|
|
50
|
+
*/
|
|
51
|
+
container: Container
|
|
52
|
+
/** Optional Logger attached to `dispatchSync` `JobContext.log`. Default: no-op. */
|
|
53
|
+
logger?: Logger
|
|
54
|
+
/** Default `max_attempts` when neither the JobClass nor `DispatchOptions` specifies one. Default `3`. */
|
|
55
|
+
defaultAttempts?: number
|
|
56
|
+
/** Default queue name when neither the JobClass nor `DispatchOptions` specifies one. Default `'default'`. */
|
|
57
|
+
defaultQueue?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class DatabaseQueue implements Queue {
|
|
61
|
+
private readonly db: Database
|
|
62
|
+
private readonly container: Container
|
|
63
|
+
private readonly logger: Logger
|
|
64
|
+
private readonly defaultAttempts: number
|
|
65
|
+
private readonly defaultQueue: string
|
|
66
|
+
|
|
67
|
+
constructor(opts: DatabaseQueueOptions) {
|
|
68
|
+
this.db = opts.db
|
|
69
|
+
this.container = opts.container
|
|
70
|
+
this.logger = opts.logger ?? createNoopLogger()
|
|
71
|
+
this.defaultAttempts = opts.defaultAttempts ?? 3
|
|
72
|
+
this.defaultQueue = opts.defaultQueue ?? 'default'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async dispatch<TJob extends JobClass>(
|
|
76
|
+
jobClass: TJob,
|
|
77
|
+
payload: PayloadOf<TJob>,
|
|
78
|
+
opts?: DispatchOptions,
|
|
79
|
+
): Promise<string> {
|
|
80
|
+
return this.insertJob(jobClass, payload, /* delaySeconds */ 0, opts)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async dispatchLater<TJob extends JobClass>(
|
|
84
|
+
at: Date | number,
|
|
85
|
+
jobClass: TJob,
|
|
86
|
+
payload: PayloadOf<TJob>,
|
|
87
|
+
opts?: DispatchLaterOptions,
|
|
88
|
+
): Promise<string> {
|
|
89
|
+
const delaySeconds = computeDelaySeconds(at)
|
|
90
|
+
return this.insertJob(jobClass, payload, delaySeconds, opts)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async dispatchSync<TJob extends JobClass>(
|
|
94
|
+
jobClass: TJob,
|
|
95
|
+
payload: PayloadOf<TJob>,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const jobId = ulid()
|
|
98
|
+
const job = this.container.make(jobClass)
|
|
99
|
+
const ctx: JobContext = {
|
|
100
|
+
jobId,
|
|
101
|
+
attempt: 1,
|
|
102
|
+
payload,
|
|
103
|
+
// SyncQueue parity — dispatchSync runs to completion in one tick;
|
|
104
|
+
// a never-aborted signal keeps handlers written against the
|
|
105
|
+
// production contract working unchanged.
|
|
106
|
+
signal: new AbortController().signal,
|
|
107
|
+
log: this.logger,
|
|
108
|
+
}
|
|
109
|
+
await job.handle(ctx)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Single INSERT path shared by `dispatch` + `dispatchLater`. Reads
|
|
114
|
+
* the ambient transactional context — when present, the INSERT
|
|
115
|
+
* routes through `ctx.tx` so the row is part of the surrounding
|
|
116
|
+
* transaction's atomicity guarantee.
|
|
117
|
+
*/
|
|
118
|
+
private async insertJob<TJob extends JobClass>(
|
|
119
|
+
jobClass: TJob,
|
|
120
|
+
payload: PayloadOf<TJob>,
|
|
121
|
+
delaySeconds: number,
|
|
122
|
+
opts: DispatchOptions | undefined,
|
|
123
|
+
): Promise<string> {
|
|
124
|
+
const jobId = ulid()
|
|
125
|
+
const queue = opts?.queue ?? jobClass.queue ?? this.defaultQueue
|
|
126
|
+
const maxAttempts = opts?.attempts ?? jobClass.maxAttempts ?? this.defaultAttempts
|
|
127
|
+
|
|
128
|
+
const executor: DatabaseExecutor = currentTransactionalContext()?.tx ?? this.db
|
|
129
|
+
// `available_at` is computed in Postgres so the queue's notion of
|
|
130
|
+
// "now" is the DB clock — the only clock the Worker reads.
|
|
131
|
+
// Mixing wall-clock from the dispatcher with DB-clock from the
|
|
132
|
+
// Worker invites skew bugs.
|
|
133
|
+
const availableAtFragment =
|
|
134
|
+
delaySeconds > 0 ? `now() + interval '${delaySeconds} seconds'` : 'now()'
|
|
135
|
+
await executor.execute(
|
|
136
|
+
`INSERT INTO ${quoteIdent(jobSchema.name)} (
|
|
137
|
+
"id", "queue", "job_name", "payload", "attempts", "max_attempts", "available_at", "created_at", "updated_at"
|
|
138
|
+
) VALUES (
|
|
139
|
+
$1, $2, $3, $4::jsonb, 0, $5, ${availableAtFragment}, now(), now()
|
|
140
|
+
)`,
|
|
141
|
+
[jobId, queue, jobClass.jobName, JSON.stringify(payload), maxAttempts],
|
|
142
|
+
)
|
|
143
|
+
return jobId
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Normalize `at` (Date | seconds-from-now) → seconds-from-now ≥ 0. */
|
|
148
|
+
function computeDelaySeconds(at: Date | number): number {
|
|
149
|
+
if (typeof at === 'number') {
|
|
150
|
+
if (at < 0) {
|
|
151
|
+
throw new Error(`DatabaseQueue.dispatchLater: delay must be non-negative, got ${at}.`)
|
|
152
|
+
}
|
|
153
|
+
return at
|
|
154
|
+
}
|
|
155
|
+
// `at` is a Date — past values clamp to 0 (immediately available).
|
|
156
|
+
const deltaMs = at.getTime() - Date.now()
|
|
157
|
+
return Math.max(0, Math.ceil(deltaMs / 1000))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Single-quote-aware identifier quoter for the schema table name. */
|
|
161
|
+
function quoteIdent(name: string): string {
|
|
162
|
+
return `"${name.replace(/"/g, '""')}"`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Bare Logger that drops every call — same shape as SyncQueue's. */
|
|
166
|
+
function createNoopLogger(): Logger {
|
|
167
|
+
const noop = () => undefined
|
|
168
|
+
return {
|
|
169
|
+
debug: noop,
|
|
170
|
+
info: noop,
|
|
171
|
+
warn: noop,
|
|
172
|
+
error: noop,
|
|
173
|
+
fatal: noop,
|
|
174
|
+
trace: noop,
|
|
175
|
+
child: () => createNoopLogger(),
|
|
176
|
+
} as unknown as Logger
|
|
177
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `strav_failed_jobs` schema — terminal failure dead-letter table.
|
|
3
|
+
*
|
|
4
|
+
* When `Worker.processOne()` exhausts a job's `max_attempts`, the row
|
|
5
|
+
* moves from `strav_jobs` to `strav_failed_jobs` in a single
|
|
6
|
+
* transaction — INSERT here, DELETE there. Apps inspect this table
|
|
7
|
+
* to triage what blew up; the `queue:retry` / `queue:flush` console
|
|
8
|
+
* commands (lands with `@strav/cli` in M4) operate on these rows.
|
|
9
|
+
*
|
|
10
|
+
* Columns:
|
|
11
|
+
* - `id` (char(26), ULID PK) — fresh per failure, not the original
|
|
12
|
+
* job id (the original is gone with the row).
|
|
13
|
+
* - `queue` (varchar(64)) — copied from `strav_jobs.queue`.
|
|
14
|
+
* - `job_name` (varchar(128)) — copied from `strav_jobs.job_name`.
|
|
15
|
+
* - `payload` (jsonb) — copied verbatim so retries can replay it.
|
|
16
|
+
* - `exception` (text) — the thrown error, serialized via
|
|
17
|
+
* `error.stack ?? String(error)`. Long; not indexed.
|
|
18
|
+
* - `attempts` (integer) — how many total attempts the job got
|
|
19
|
+
* before terminal failure.
|
|
20
|
+
* - `failed_at` (timestamptz) — wall-clock time of the move.
|
|
21
|
+
* - `created_at` / `updated_at` — provided by `t.timestamps()`.
|
|
22
|
+
*
|
|
23
|
+
* Not `tenanted: true` — same system-level scope as `strav_jobs`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
27
|
+
|
|
28
|
+
export const failedJobsSchema = defineSchema('strav_failed_jobs', Archetype.Entity, (t) => {
|
|
29
|
+
t.id()
|
|
30
|
+
t.string('queue').max(64)
|
|
31
|
+
t.string('job_name').max(128)
|
|
32
|
+
t.json<unknown>('payload')
|
|
33
|
+
t.text('exception')
|
|
34
|
+
t.integer('attempts')
|
|
35
|
+
t.timestamp('failed_at')
|
|
36
|
+
t.timestamps()
|
|
37
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,62 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// Public API of @strav/queue.
|
|
2
|
+
//
|
|
3
|
+
// V1 ships:
|
|
4
|
+
// - Job + JobContext + JobClass + PayloadOf — the unit of work + types.
|
|
5
|
+
// - JobRegistry + Bun.Glob auto-discovery + isJobClass type-guard.
|
|
6
|
+
// - Queue interface with dispatch / dispatchLater / dispatchSync.
|
|
7
|
+
// - SyncQueue — in-process synchronous driver for tests + single-process dev.
|
|
8
|
+
// - DatabaseQueue — Postgres-backed driver with queue-until-commit semantics
|
|
9
|
+
// via @strav/database's transactional ALS.
|
|
10
|
+
// - jobSchema — the `strav_jobs` Schema apps register + migrate.
|
|
11
|
+
// - Worker — consumer side: SELECT FOR UPDATE SKIP LOCKED poll loop,
|
|
12
|
+
// attempt counter, exponential backoff with jitter, per-attempt
|
|
13
|
+
// timeout, graceful shutdown via AbortSignal.
|
|
14
|
+
// - CronExpression + helpers (everyMinute / everyMinutes / hourly /
|
|
15
|
+
// daily / dailyAt / cron) — 5-field cron with UTC-based matching.
|
|
16
|
+
// - Scheduler + schedulerRunsSchema — recurring dispatch with optional
|
|
17
|
+
// onOneServer advisory lock (built on TenantManager.withLock).
|
|
18
|
+
//
|
|
19
|
+
// - failedJobsSchema — the `strav_failed_jobs` dead-letter table.
|
|
20
|
+
// Worker moves terminal failures here atomically (INSERT + DELETE
|
|
21
|
+
// in one tx).
|
|
22
|
+
//
|
|
23
|
+
// Still deferred (waiting on @strav/cli — M4):
|
|
24
|
+
// - queue:retry / queue:flush console commands (re-enqueue / drop
|
|
25
|
+
// failed rows in bulk).
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
QueueConsoleProvider,
|
|
29
|
+
QueueFailed,
|
|
30
|
+
QueueFlush,
|
|
31
|
+
QueueRetry,
|
|
32
|
+
QueueWork,
|
|
33
|
+
SchedulerList,
|
|
34
|
+
SchedulerRun,
|
|
35
|
+
SchedulerWork,
|
|
36
|
+
} from './console/index.ts'
|
|
37
|
+
export {
|
|
38
|
+
CronExpression,
|
|
39
|
+
cron,
|
|
40
|
+
daily,
|
|
41
|
+
dailyAt,
|
|
42
|
+
everyMinute,
|
|
43
|
+
everyMinutes,
|
|
44
|
+
hourly,
|
|
45
|
+
} from './cron.ts'
|
|
46
|
+
export { DatabaseQueue, type DatabaseQueueOptions } from './database_queue.ts'
|
|
47
|
+
export { failedJobsSchema } from './failed_jobs_schema.ts'
|
|
48
|
+
export {
|
|
49
|
+
Job,
|
|
50
|
+
type JobClass,
|
|
51
|
+
type JobConfig,
|
|
52
|
+
type JobContext,
|
|
53
|
+
type JobFailedContext,
|
|
54
|
+
type PayloadOf,
|
|
55
|
+
} from './job.ts'
|
|
56
|
+
export { isJobClass, JobRegistry } from './job_registry.ts'
|
|
57
|
+
export { jobSchema } from './job_schema.ts'
|
|
58
|
+
export type { DispatchLaterOptions, DispatchOptions, Queue } from './queue.ts'
|
|
59
|
+
export { type ScheduleOptions, Scheduler, type SchedulerOptions } from './scheduler.ts'
|
|
60
|
+
export { schedulerRunsSchema } from './scheduler_runs_schema.ts'
|
|
61
|
+
export { SyncQueue, type SyncQueueOptions } from './sync_queue.ts'
|
|
62
|
+
export { type JobResult, Worker, type WorkerOptions } from './worker.ts'
|
package/src/job.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Job` — the unit of background work.
|
|
3
|
+
*
|
|
4
|
+
* Subclasses declare a stable `static jobName` (the wire identifier the
|
|
5
|
+
* `JobRegistry` uses to route a serialized payload back to a class) and
|
|
6
|
+
* implement `handle(ctx)`. The Worker constructs jobs through the
|
|
7
|
+
* container so `@inject()`-marked subclasses get their dependencies
|
|
8
|
+
* resolved the usual way; the payload arrives as JSON on the
|
|
9
|
+
* `JobContext`.
|
|
10
|
+
*
|
|
11
|
+
* Configuration overrides (max attempts, backoff, timeout, queue name)
|
|
12
|
+
* are static on the subclass. The Worker reads them per-attempt — see
|
|
13
|
+
* the `JobConfig` interface below.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* @inject()
|
|
17
|
+
* class SendWelcomeEmail extends Job<{ userId: string }> {
|
|
18
|
+
* static override readonly jobName = 'mail.welcome'
|
|
19
|
+
*
|
|
20
|
+
* constructor(
|
|
21
|
+
* private readonly users: UserRepository,
|
|
22
|
+
* private readonly mail: MailManager,
|
|
23
|
+
* ) {
|
|
24
|
+
* super()
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* async handle(ctx: JobContext<{ userId: string }>): Promise<void> {
|
|
28
|
+
* const user = await this.users.findOrFail(ctx.payload.userId)
|
|
29
|
+
* await this.mail.send(new WelcomeEmail(user))
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Logger } from '@strav/kernel'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Per-invocation context passed to `Job.handle(ctx)`. Workers populate
|
|
39
|
+
* every field — testing harnesses can build one by hand.
|
|
40
|
+
*/
|
|
41
|
+
export interface JobContext<TPayload = unknown> {
|
|
42
|
+
/** Stable id assigned at dispatch (typically a ULID). */
|
|
43
|
+
jobId: string
|
|
44
|
+
/** 1-based attempt counter. `attempt === 1` is the first run. */
|
|
45
|
+
attempt: number
|
|
46
|
+
/** The deserialized payload. */
|
|
47
|
+
payload: TPayload
|
|
48
|
+
/**
|
|
49
|
+
* Cancellation signal. Aborted when the Worker's shutdown grace
|
|
50
|
+
* period elapses or the job exceeds its `timeout`. Handlers that
|
|
51
|
+
* loop / stream should check `ctx.signal.aborted` and bail.
|
|
52
|
+
*/
|
|
53
|
+
signal: AbortSignal
|
|
54
|
+
/**
|
|
55
|
+
* Job-scoped logger — prebound with `jobId` + `jobName` + `attempt`.
|
|
56
|
+
* Standard Logger surface (`debug`/`info`/`warn`/`error`).
|
|
57
|
+
*/
|
|
58
|
+
log: Logger
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Optional retry hook context — same shape as `JobContext` plus the
|
|
63
|
+
* error that caused the failure.
|
|
64
|
+
*/
|
|
65
|
+
export interface JobFailedContext<TPayload = unknown> extends JobContext<TPayload> {
|
|
66
|
+
/** The thrown value from the failed attempt. Already wrapped by the kernel error stack. */
|
|
67
|
+
error: unknown
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `static` config shape a Job subclass may set. Every field is
|
|
72
|
+
* optional — the Worker falls back to driver defaults when omitted.
|
|
73
|
+
*/
|
|
74
|
+
export interface JobConfig {
|
|
75
|
+
/** Total attempts (including the first). Default: driver-configured, typically 3. */
|
|
76
|
+
maxAttempts?: number
|
|
77
|
+
/**
|
|
78
|
+
* Backoff in seconds for `attempt` (1-based). The Worker calls this
|
|
79
|
+
* AFTER a failed attempt to schedule the next try. Default: exponential
|
|
80
|
+
* with jitter (driver-configured).
|
|
81
|
+
*/
|
|
82
|
+
backoff?(attempt: number): number
|
|
83
|
+
/** Per-attempt time limit (seconds). Default: driver-configured. */
|
|
84
|
+
timeout?: number
|
|
85
|
+
/** Named queue to dispatch onto. Default: `'default'`. */
|
|
86
|
+
queue?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The abstract Job base class. Subclasses implement `handle` and set
|
|
91
|
+
* `static jobName`. Other static fields are optional configuration —
|
|
92
|
+
* see {@link JobConfig}.
|
|
93
|
+
*
|
|
94
|
+
* Generic parameter `TPayload` is the shape the dispatcher serializes
|
|
95
|
+
* and the handler deserializes. Workers JSON.stringify/parse it
|
|
96
|
+
* verbatim — keep it serializable (no Dates without a custom revival
|
|
97
|
+
* step, no class instances).
|
|
98
|
+
*/
|
|
99
|
+
export abstract class Job<TPayload = unknown> {
|
|
100
|
+
/**
|
|
101
|
+
* Stable wire identifier mapping this class in the JobRegistry.
|
|
102
|
+
* Subclasses MUST override. Convention: `<package>.<verb>` or
|
|
103
|
+
* `<resource>.<verb>` (`mail.welcome`, `user.cleanup`).
|
|
104
|
+
*
|
|
105
|
+
* The base class declares an empty string so type-checking holds;
|
|
106
|
+
* `JobRegistry.register()` rejects classes that didn't override.
|
|
107
|
+
*/
|
|
108
|
+
static readonly jobName: string = ''
|
|
109
|
+
|
|
110
|
+
// Optional static configuration. Declared on the base so subclasses
|
|
111
|
+
// can shadow with `static override readonly <field> = <value>` under
|
|
112
|
+
// `noImplicitOverride`. The Worker reads these per-attempt; omitted
|
|
113
|
+
// fields fall back to driver defaults. Mirrors the {@link JobConfig}
|
|
114
|
+
// interface — the interface drives `JobClass`'s typing, the static
|
|
115
|
+
// fields here drive the override-friendly inheritance shape.
|
|
116
|
+
static readonly maxAttempts?: number
|
|
117
|
+
static readonly backoff?: (attempt: number) => number
|
|
118
|
+
static readonly timeout?: number
|
|
119
|
+
static readonly queue?: string
|
|
120
|
+
|
|
121
|
+
/** The handler. Workers call this once they've constructed the job + built the context. */
|
|
122
|
+
abstract handle(context: JobContext<TPayload>): Promise<void>
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Optional failure hook. Fires when an attempt throws AND there are
|
|
126
|
+
* still retries left; also fires once on the FINAL failure (so apps
|
|
127
|
+
* can route to a dead-letter, post a Slack message, etc.). The Worker
|
|
128
|
+
* runs this in a try/catch — a throw from `failed()` is logged but
|
|
129
|
+
* doesn't change the retry decision.
|
|
130
|
+
*/
|
|
131
|
+
failed?(context: JobFailedContext<TPayload>): Promise<void>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Constructor reference for a Job subclass. The Worker / Queue store
|
|
136
|
+
* these in the registry, look them up by `jobName`, and instantiate
|
|
137
|
+
* via the container.
|
|
138
|
+
*/
|
|
139
|
+
export interface JobClass<TPayload = unknown> extends JobConfig {
|
|
140
|
+
// biome-ignore lint/suspicious/noExplicitAny: ctor params are decided per-subclass; the @inject() flow resolves them.
|
|
141
|
+
new (...args: any[]): Job<TPayload>
|
|
142
|
+
readonly jobName: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Helper type: extract the payload type from a `JobClass`.
|
|
147
|
+
*
|
|
148
|
+
* ```ts
|
|
149
|
+
* type WelcomePayload = PayloadOf<typeof SendWelcomeEmail>
|
|
150
|
+
* // → { userId: string }
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export type PayloadOf<T> = T extends JobClass<infer P> ? P : never
|