@strav/queue 0.4.31 → 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.
@@ -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
+ }