@strav/queue 0.4.30 → 1.0.0-alpha.3
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 +20 -29
- 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 +52 -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 +242 -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
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Scheduler` — recurring job dispatch on a cron cadence.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces:
|
|
5
|
+
* - `.schedule(opts)` registers an entry (`{ job, cron, name?,
|
|
6
|
+
* payload?, oneServer? }`). Returns `this` for chaining.
|
|
7
|
+
* - `.tick(now)` processes every registered entry whose cron matches
|
|
8
|
+
* `now`. Dispatches via the wired `Queue`. Exposed for tests +
|
|
9
|
+
* one-shot CLI use.
|
|
10
|
+
* - `.run(signal)` is the long-running loop — calls `tick()` at each
|
|
11
|
+
* minute boundary, sleeps abort-aware between ticks.
|
|
12
|
+
*
|
|
13
|
+
* `oneServer: true` entries use `TenantManager.withLock` to acquire a
|
|
14
|
+
* fleet-wide advisory lock named after the entry. Inside the lock the
|
|
15
|
+
* dispatcher checks `strav_scheduler_runs.last_run_at` — if the
|
|
16
|
+
* current tick boundary already has a run recorded, this server skips
|
|
17
|
+
* (another server won). Otherwise dispatch + UPSERT atomically.
|
|
18
|
+
*
|
|
19
|
+
* `withLock` is built on `UnitOfWork.run`, which means
|
|
20
|
+
* `DatabaseQueue.dispatch` inside it auto-routes through the same
|
|
21
|
+
* transaction — the queue row INSERT + the run-tracking UPSERT commit
|
|
22
|
+
* together. A throw before COMMIT drops both. Exactly the
|
|
23
|
+
* queue-until-commit semantic from M2.
|
|
24
|
+
*
|
|
25
|
+
* Cron matching is UTC-based (see {@link CronExpression}). Apps that
|
|
26
|
+
* want local-time scheduling shift their schedule expressions; the
|
|
27
|
+
* Scheduler doesn't take a timezone option.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { TenantManager } from '@strav/database'
|
|
31
|
+
import { type Logger, ulid } from '@strav/kernel'
|
|
32
|
+
import type { CronExpression } from './cron.ts'
|
|
33
|
+
import type { JobClass } from './job.ts'
|
|
34
|
+
import type { Queue } from './queue.ts'
|
|
35
|
+
|
|
36
|
+
export interface ScheduleOptions {
|
|
37
|
+
/** Job to dispatch when the cron matches. */
|
|
38
|
+
job: JobClass
|
|
39
|
+
/** Payload handed to the Job. Default `undefined` → empty payload. */
|
|
40
|
+
payload?: unknown
|
|
41
|
+
/** Cron expression that gates the dispatch. */
|
|
42
|
+
cron: CronExpression
|
|
43
|
+
/**
|
|
44
|
+
* Identifier used for the advisory lock key + the
|
|
45
|
+
* `strav_scheduler_runs` row. Defaults to `job.jobName`.
|
|
46
|
+
* Specify when one job class is scheduled multiple times with
|
|
47
|
+
* different cadences / payloads, so each has its own lock + row.
|
|
48
|
+
*/
|
|
49
|
+
name?: string
|
|
50
|
+
/**
|
|
51
|
+
* When `true`, only one server in the fleet dispatches per tick
|
|
52
|
+
* (advisory lock + run-tracking row). When `false` (default), every
|
|
53
|
+
* server dispatches independently — fine for jobs whose work is
|
|
54
|
+
* itself idempotent or which want fan-out semantics.
|
|
55
|
+
*/
|
|
56
|
+
oneServer?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SchedulerOptions {
|
|
60
|
+
/** Queue used to dispatch each entry's job. */
|
|
61
|
+
queue: Queue
|
|
62
|
+
/**
|
|
63
|
+
* TenantManager — used for its `withLock` + UoW combo on
|
|
64
|
+
* `oneServer` entries. `Scheduler` doesn't itself do tenancy; this
|
|
65
|
+
* is the most ergonomic primitive for "advisory lock + transaction
|
|
66
|
+
* + ambient ALS so dispatch joins the same tx."
|
|
67
|
+
*/
|
|
68
|
+
tenants: TenantManager
|
|
69
|
+
/**
|
|
70
|
+
* Optional fallback executor used when an `oneServer: false` entry's
|
|
71
|
+
* dispatch raises. Only used to log the failure; the Queue itself
|
|
72
|
+
* already routes the SQL. Default: no-op logger.
|
|
73
|
+
*/
|
|
74
|
+
logger?: Logger
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ScheduledEntry {
|
|
78
|
+
name: string
|
|
79
|
+
cron: CronExpression
|
|
80
|
+
job: JobClass
|
|
81
|
+
payload: unknown
|
|
82
|
+
oneServer: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class Scheduler {
|
|
86
|
+
private readonly queue: Queue
|
|
87
|
+
private readonly tenants: TenantManager
|
|
88
|
+
private readonly logger: Logger
|
|
89
|
+
private readonly entries: ScheduledEntry[] = []
|
|
90
|
+
|
|
91
|
+
constructor(opts: SchedulerOptions) {
|
|
92
|
+
this.queue = opts.queue
|
|
93
|
+
this.tenants = opts.tenants
|
|
94
|
+
this.logger = opts.logger ?? createNoopLogger()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Register a recurring dispatch. Returns `this` for chaining. */
|
|
98
|
+
schedule(options: ScheduleOptions): this {
|
|
99
|
+
this.entries.push({
|
|
100
|
+
name: options.name ?? options.job.jobName,
|
|
101
|
+
cron: options.cron,
|
|
102
|
+
job: options.job,
|
|
103
|
+
payload: options.payload,
|
|
104
|
+
oneServer: options.oneServer ?? false,
|
|
105
|
+
})
|
|
106
|
+
return this
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every registered entry — exposed for inspection / tests. */
|
|
110
|
+
all(): readonly ScheduledEntry[] {
|
|
111
|
+
return [...this.entries]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Process every entry against `now`. The tick boundary is `now`
|
|
116
|
+
* floored to the start of its minute (seconds + millis cleared) —
|
|
117
|
+
* cron matches against that, and `oneServer` run-tracking writes
|
|
118
|
+
* that value into `strav_scheduler_runs.last_run_at`.
|
|
119
|
+
*/
|
|
120
|
+
async tick(now: Date = new Date()): Promise<void> {
|
|
121
|
+
const boundary = floorToMinute(now)
|
|
122
|
+
for (const entry of this.entries) {
|
|
123
|
+
if (!entry.cron.matches(boundary)) continue
|
|
124
|
+
try {
|
|
125
|
+
if (entry.oneServer) {
|
|
126
|
+
await this.dispatchOneServer(entry, boundary)
|
|
127
|
+
} else {
|
|
128
|
+
await this.queue.dispatch(entry.job, entry.payload as never)
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
this.logger.error('Scheduler: dispatch failed', {
|
|
132
|
+
name: entry.name,
|
|
133
|
+
jobName: entry.job.jobName,
|
|
134
|
+
error,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run the minute-tick loop until `signal` aborts. Each iteration
|
|
142
|
+
* sleeps until the next minute boundary, then calls `tick()`. The
|
|
143
|
+
* sleep is abort-aware — `signal.abort()` returns within one tick
|
|
144
|
+
* rather than waiting out the minute.
|
|
145
|
+
*
|
|
146
|
+
* On the FIRST iteration, sleeps to the next minute boundary
|
|
147
|
+
* BEFORE the first tick — so callers see one full minute pass
|
|
148
|
+
* between `run()` start and the first dispatch. (Calling `tick()`
|
|
149
|
+
* explicitly before `run()` is the way to dispatch immediately.)
|
|
150
|
+
*/
|
|
151
|
+
async run(signal: AbortSignal): Promise<void> {
|
|
152
|
+
while (!signal.aborted) {
|
|
153
|
+
const now = new Date()
|
|
154
|
+
const nextBoundary = nextMinuteBoundary(now)
|
|
155
|
+
const sleepMs = Math.max(0, nextBoundary.getTime() - now.getTime())
|
|
156
|
+
await sleep(sleepMs, signal)
|
|
157
|
+
if (signal.aborted) return
|
|
158
|
+
try {
|
|
159
|
+
await this.tick(nextBoundary)
|
|
160
|
+
} catch (loopError) {
|
|
161
|
+
this.logger.error('Scheduler: tick iteration failed', { error: loopError })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async dispatchOneServer(entry: ScheduledEntry, tickBoundary: Date): Promise<void> {
|
|
167
|
+
await this.tenants.withLock(`scheduler:${entry.name}`, async (tx) => {
|
|
168
|
+
const last = await tx.queryOne<{ last_run_at: Date }>(
|
|
169
|
+
`SELECT last_run_at FROM "strav_scheduler_runs" WHERE name = $1`,
|
|
170
|
+
[entry.name],
|
|
171
|
+
)
|
|
172
|
+
// The lock serializes the read+write window — if another server
|
|
173
|
+
// already recorded this tick boundary, skip cleanly.
|
|
174
|
+
if (last !== null && last.last_run_at.getTime() >= tickBoundary.getTime()) {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
// Inside withLock's UoW, the dispatch routes through the ambient
|
|
178
|
+
// tx — the queue row INSERT + the run-tracking UPSERT commit
|
|
179
|
+
// atomically. If anything throws, both roll back.
|
|
180
|
+
await this.queue.dispatch(entry.job, entry.payload as never)
|
|
181
|
+
await tx.execute(
|
|
182
|
+
`INSERT INTO "strav_scheduler_runs" (id, name, last_run_at, created_at, updated_at)
|
|
183
|
+
VALUES ($1, $2, $3, now(), now())
|
|
184
|
+
ON CONFLICT (name) DO UPDATE
|
|
185
|
+
SET last_run_at = EXCLUDED.last_run_at, updated_at = now()`,
|
|
186
|
+
[ulid(), entry.name, tickBoundary],
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Floor a Date to the start of its minute (seconds + millis cleared). */
|
|
193
|
+
function floorToMinute(date: Date): Date {
|
|
194
|
+
return new Date(
|
|
195
|
+
Date.UTC(
|
|
196
|
+
date.getUTCFullYear(),
|
|
197
|
+
date.getUTCMonth(),
|
|
198
|
+
date.getUTCDate(),
|
|
199
|
+
date.getUTCHours(),
|
|
200
|
+
date.getUTCMinutes(),
|
|
201
|
+
0,
|
|
202
|
+
0,
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Next-minute boundary strictly after `date`. */
|
|
208
|
+
function nextMinuteBoundary(date: Date): Date {
|
|
209
|
+
const floor = floorToMinute(date)
|
|
210
|
+
return new Date(floor.getTime() + 60_000)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Abort-aware sleep. */
|
|
214
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
215
|
+
return new Promise<void>((resolve) => {
|
|
216
|
+
if (signal.aborted) {
|
|
217
|
+
resolve()
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
const timer = setTimeout(resolve, ms)
|
|
221
|
+
const onAbort = () => {
|
|
222
|
+
clearTimeout(timer)
|
|
223
|
+
signal.removeEventListener('abort', onAbort)
|
|
224
|
+
resolve()
|
|
225
|
+
}
|
|
226
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** No-op Logger — same shape as the ones in DatabaseQueue / Worker. */
|
|
231
|
+
function createNoopLogger(): Logger {
|
|
232
|
+
const noop = () => undefined
|
|
233
|
+
return {
|
|
234
|
+
debug: noop,
|
|
235
|
+
info: noop,
|
|
236
|
+
warn: noop,
|
|
237
|
+
error: noop,
|
|
238
|
+
fatal: noop,
|
|
239
|
+
trace: noop,
|
|
240
|
+
child: () => createNoopLogger(),
|
|
241
|
+
} as unknown as Logger
|
|
242
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `strav_scheduler_runs` schema — tracks the last tick boundary at
|
|
3
|
+
* which a `Scheduler.onOneServer` entry dispatched.
|
|
4
|
+
*
|
|
5
|
+
* Why the table exists: `pg_advisory_xact_lock` releases on COMMIT —
|
|
6
|
+
* the lock is held only for the duration of the dispatch transaction
|
|
7
|
+
* (~10ms). Without a run-tracking row, two servers entering the lock
|
|
8
|
+
* block back-to-back at the start of the same minute would each see
|
|
9
|
+
* "cron matches now" and dispatch — double work.
|
|
10
|
+
*
|
|
11
|
+
* The check inside the lock block:
|
|
12
|
+
* 1. SELECT last_run_at WHERE name = $name.
|
|
13
|
+
* 2. If last_run_at >= tick_boundary, another server already won
|
|
14
|
+
* this minute. Skip.
|
|
15
|
+
* 3. Otherwise dispatch + UPSERT last_run_at = tick_boundary.
|
|
16
|
+
*
|
|
17
|
+
* The advisory lock serializes the read+write so the check is honest.
|
|
18
|
+
*
|
|
19
|
+
* Schema constraints: framework PK kinds are `id`/`uuid`/`bigSerial`/
|
|
20
|
+
* `tenantedBigSerial`. None are text, so we can't make `name` itself
|
|
21
|
+
* the PK; instead ULID PK + `name` UNIQUE. The ULID is dead weight on
|
|
22
|
+
* the application side but keeps the row shape consistent with the
|
|
23
|
+
* rest of the framework.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
27
|
+
|
|
28
|
+
export const schedulerRunsSchema = defineSchema('strav_scheduler_runs', Archetype.Entity, (t) => {
|
|
29
|
+
t.id()
|
|
30
|
+
t.string('name').max(128).unique()
|
|
31
|
+
t.timestamp('last_run_at')
|
|
32
|
+
t.timestamps()
|
|
33
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SyncQueue` — in-process synchronous Queue driver.
|
|
3
|
+
*
|
|
4
|
+
* Instantiates the Job via the container (so `@inject()` resolves
|
|
5
|
+
* dependencies the same way the real Worker would), builds a
|
|
6
|
+
* `JobContext`, runs `handle(ctx)`. No persistence, no retries — if
|
|
7
|
+
* the handler throws, the throw propagates to the dispatcher.
|
|
8
|
+
*
|
|
9
|
+
* Use cases:
|
|
10
|
+
* - **Tests** — drive a job end-to-end without standing up a
|
|
11
|
+
* `DatabaseQueue` + Worker.
|
|
12
|
+
* - **Single-process dev** — flatten the Queue → Worker hop so
|
|
13
|
+
* `bun dev` shows job output inline.
|
|
14
|
+
* - **Inline-by-design** — rare in production, but callers that
|
|
15
|
+
* genuinely want sync execution (e.g. an importer that's already
|
|
16
|
+
* in a transaction) call `dispatchSync` directly.
|
|
17
|
+
*
|
|
18
|
+
* The non-sync methods (`dispatch` / `dispatchLater`) on this driver
|
|
19
|
+
* also run the work synchronously — they exist so the same code path
|
|
20
|
+
* works in both sync-only test setups and async-Worker production
|
|
21
|
+
* setups. `dispatchLater`'s delay is ignored under SyncQueue; the work
|
|
22
|
+
* runs immediately.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { type Container, type Logger, ulid } from '@strav/kernel'
|
|
26
|
+
import type { JobClass, JobContext, PayloadOf } from './job.ts'
|
|
27
|
+
import type { DispatchLaterOptions, DispatchOptions, Queue } from './queue.ts'
|
|
28
|
+
|
|
29
|
+
export interface SyncQueueOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Container used to construct Job instances. `make(JobClass)` builds
|
|
32
|
+
* via `@inject()` metadata, so subclasses with constructor deps get
|
|
33
|
+
* the same wiring the production Worker provides.
|
|
34
|
+
*/
|
|
35
|
+
container: Container
|
|
36
|
+
/**
|
|
37
|
+
* Optional Logger to attach to every `JobContext.log`. When omitted,
|
|
38
|
+
* a no-op logger is used — useful in tests where log noise is
|
|
39
|
+
* unwelcome.
|
|
40
|
+
*/
|
|
41
|
+
logger?: Logger
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class SyncQueue implements Queue {
|
|
45
|
+
private readonly container: Container
|
|
46
|
+
private readonly logger: Logger
|
|
47
|
+
|
|
48
|
+
constructor(opts: SyncQueueOptions) {
|
|
49
|
+
this.container = opts.container
|
|
50
|
+
this.logger = opts.logger ?? createNoopLogger()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async dispatch<TJob extends JobClass>(
|
|
54
|
+
jobClass: TJob,
|
|
55
|
+
payload: PayloadOf<TJob>,
|
|
56
|
+
_opts?: DispatchOptions,
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const jobId = ulid()
|
|
59
|
+
await this.run(jobId, jobClass, payload)
|
|
60
|
+
return jobId
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async dispatchLater<TJob extends JobClass>(
|
|
64
|
+
at: Date | number,
|
|
65
|
+
jobClass: TJob,
|
|
66
|
+
payload: PayloadOf<TJob>,
|
|
67
|
+
_opts?: DispatchLaterOptions,
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
// Validate the delay even though we don't honor it — same contract
|
|
70
|
+
// surface as the production driver, so callers can't accidentally
|
|
71
|
+
// pass a negative delay that "works" under SyncQueue and fails on
|
|
72
|
+
// DatabaseQueue.
|
|
73
|
+
if (typeof at === 'number' && at < 0) {
|
|
74
|
+
throw new Error(`SyncQueue.dispatchLater: delay must be non-negative, got ${at}.`)
|
|
75
|
+
}
|
|
76
|
+
return this.dispatch(jobClass, payload)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async dispatchSync<TJob extends JobClass>(
|
|
80
|
+
jobClass: TJob,
|
|
81
|
+
payload: PayloadOf<TJob>,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
await this.run(ulid(), jobClass, payload)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async run<TJob extends JobClass>(
|
|
87
|
+
jobId: string,
|
|
88
|
+
jobClass: TJob,
|
|
89
|
+
payload: PayloadOf<TJob>,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
const job = this.container.make(jobClass)
|
|
92
|
+
const ctx: JobContext = {
|
|
93
|
+
jobId,
|
|
94
|
+
attempt: 1,
|
|
95
|
+
payload,
|
|
96
|
+
// SyncQueue runs to completion in one tick — abort isn't a thing
|
|
97
|
+
// here. We hand out a never-aborted signal so handlers written
|
|
98
|
+
// against the production contract don't need a separate code
|
|
99
|
+
// path.
|
|
100
|
+
signal: new AbortController().signal,
|
|
101
|
+
log: this.logger,
|
|
102
|
+
}
|
|
103
|
+
await job.handle(ctx)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Bare Logger that drops every call. Avoids pulling in the full
|
|
109
|
+
* LoggerProvider when callers just want SyncQueue in tests.
|
|
110
|
+
*/
|
|
111
|
+
function createNoopLogger(): Logger {
|
|
112
|
+
const noop = () => undefined
|
|
113
|
+
// The Logger contract is wider; the few methods downstream code may
|
|
114
|
+
// call are stubbed. Cast keeps the type signature honest at the
|
|
115
|
+
// boundary — Logger's full surface includes `child()` etc., which a
|
|
116
|
+
// future caller could exercise.
|
|
117
|
+
return {
|
|
118
|
+
debug: noop,
|
|
119
|
+
info: noop,
|
|
120
|
+
warn: noop,
|
|
121
|
+
error: noop,
|
|
122
|
+
fatal: noop,
|
|
123
|
+
trace: noop,
|
|
124
|
+
child: () => createNoopLogger(),
|
|
125
|
+
} as unknown as Logger
|
|
126
|
+
}
|