@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,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `JobRegistry` — runtime catalog of every registered `Job` class.
|
|
3
|
+
*
|
|
4
|
+
* Apps register jobs explicitly (`register(JobClass)` / `registerAll
|
|
5
|
+
* ([...])`) or via `discover(pattern)`, which uses `Bun.Glob` to scan
|
|
6
|
+
* files + dynamically `import()` each, registering every exported
|
|
7
|
+
* class that satisfies {@link isJobClass}. Same shape as
|
|
8
|
+
* `SchemaRegistry.discover` in `@strav/database`.
|
|
9
|
+
*
|
|
10
|
+
* The registry rejects two different classes claiming the same
|
|
11
|
+
* `jobName` (a wire-protocol collision — the Worker can't disambiguate
|
|
12
|
+
* which class to instantiate). Re-imports of the same class via a
|
|
13
|
+
* barrel are deduped by identity.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ConfigError } from '@strav/kernel'
|
|
17
|
+
import { Job, type JobClass } from './job.ts'
|
|
18
|
+
|
|
19
|
+
export class JobRegistry {
|
|
20
|
+
private readonly byName = new Map<string, JobClass>()
|
|
21
|
+
|
|
22
|
+
/** Register one Job class. Throws when `jobName` is empty or already taken. */
|
|
23
|
+
register(jobClass: JobClass): this {
|
|
24
|
+
if (!jobClass.jobName) {
|
|
25
|
+
throw new ConfigError(
|
|
26
|
+
`JobRegistry: ${jobClass.name || 'anonymous job'} must declare a non-empty \`static jobName\` — that's the wire identifier the Worker dispatches against.`,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
const existing = this.byName.get(jobClass.jobName)
|
|
30
|
+
if (existing === jobClass) return this
|
|
31
|
+
if (existing) {
|
|
32
|
+
throw new ConfigError(
|
|
33
|
+
`JobRegistry: jobName "${jobClass.jobName}" is already registered to a different class (${existing.name} vs ${jobClass.name}). Pick a different jobName — the Worker uses it to route serialized payloads back to a class.`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
this.byName.set(jobClass.jobName, jobClass)
|
|
37
|
+
return this
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Register several. Order matters only insofar as register() throws on conflict. */
|
|
41
|
+
registerAll(jobClasses: readonly JobClass[]): this {
|
|
42
|
+
for (const cls of jobClasses) this.register(cls)
|
|
43
|
+
return this
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Auto-discover Job classes by glob pattern. For each matched file:
|
|
48
|
+
* 1. dynamically `import()` it,
|
|
49
|
+
* 2. iterate every exported value,
|
|
50
|
+
* 3. register every one that satisfies {@link isJobClass}.
|
|
51
|
+
*
|
|
52
|
+
* `pattern` is a `Bun.Glob`-compatible string (or array). `cwd`
|
|
53
|
+
* defaults to `process.cwd()` (typically the repo root). Returns
|
|
54
|
+
* `this` for chaining.
|
|
55
|
+
*
|
|
56
|
+
* Re-exports of the same class via multiple files dedupe by object
|
|
57
|
+
* identity — typical barrel patterns work. Two DIFFERENT classes
|
|
58
|
+
* sharing a `jobName` still throw `ConfigError`.
|
|
59
|
+
*
|
|
60
|
+
* Files exporting no Job classes (helpers, type-only re-exports) are
|
|
61
|
+
* silently skipped.
|
|
62
|
+
*/
|
|
63
|
+
async discover(pattern: string | string[], options: { cwd?: string } = {}): Promise<this> {
|
|
64
|
+
const patterns = Array.isArray(pattern) ? pattern : [pattern]
|
|
65
|
+
const cwd = options.cwd ?? process.cwd()
|
|
66
|
+
const files = new Set<string>()
|
|
67
|
+
for (const p of patterns) {
|
|
68
|
+
const glob = new Bun.Glob(p)
|
|
69
|
+
for await (const file of glob.scan({ cwd, absolute: true })) {
|
|
70
|
+
files.add(file)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
const mod = (await import(file)) as Record<string, unknown>
|
|
75
|
+
for (const value of Object.values(mod)) {
|
|
76
|
+
if (!isJobClass(value)) continue
|
|
77
|
+
this.register(value)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve by `jobName`. Returns `undefined` for unknown names. */
|
|
84
|
+
get(jobName: string): JobClass | undefined {
|
|
85
|
+
return this.byName.get(jobName)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Throwing variant — use when "this job must exist" is a hard precondition. */
|
|
89
|
+
getOrFail(jobName: string): JobClass {
|
|
90
|
+
const cls = this.byName.get(jobName)
|
|
91
|
+
if (!cls) {
|
|
92
|
+
throw new ConfigError(
|
|
93
|
+
`JobRegistry: no Job is registered under "${jobName}". ` +
|
|
94
|
+
'Either the dispatcher serialized an unknown class, or the Worker is running against a different registry than the dispatcher used.',
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
return cls
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True when a Job with this `jobName` is registered. */
|
|
101
|
+
has(jobName: string): boolean {
|
|
102
|
+
return this.byName.has(jobName)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every registered class, in insertion order. */
|
|
106
|
+
all(): readonly JobClass[] {
|
|
107
|
+
return [...this.byName.values()]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Test helper: wipe the registry. */
|
|
111
|
+
clear(): void {
|
|
112
|
+
this.byName.clear()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Type-guard: a value looks like a Job class — a function whose
|
|
118
|
+
* prototype extends `Job` and which declares a non-empty `jobName`.
|
|
119
|
+
* Used by `discover()` to filter exported values; exported so apps can
|
|
120
|
+
* hand-roll their own discovery loops.
|
|
121
|
+
*
|
|
122
|
+
* The prototype check is conservative — a class that doesn't extend
|
|
123
|
+
* `Job` (even one that happens to look duck-typed) is rejected. That's
|
|
124
|
+
* intentional: the `failed?` hook + future Worker extensions key off
|
|
125
|
+
* `instanceof Job`, so the registry's identity guarantee has to match.
|
|
126
|
+
*/
|
|
127
|
+
export function isJobClass(value: unknown): value is JobClass {
|
|
128
|
+
if (typeof value !== 'function') return false
|
|
129
|
+
// The Job constructor itself isn't registerable (it's abstract); only
|
|
130
|
+
// subclasses are.
|
|
131
|
+
if (value === Job) return false
|
|
132
|
+
if (!(value.prototype instanceof Job)) return false
|
|
133
|
+
const jobName = (value as { jobName?: unknown }).jobName
|
|
134
|
+
return typeof jobName === 'string' && jobName.length > 0
|
|
135
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `strav_jobs` schema — the backing table for `DatabaseQueue`.
|
|
3
|
+
*
|
|
4
|
+
* Naming convention: framework-managed tables that apps register +
|
|
5
|
+
* migrate use the `strav_*` prefix (no leading underscore — those are
|
|
6
|
+
* reserved for raw-DDL tables created by the runner / DDL emitter:
|
|
7
|
+
* `_strav_migrations`, `_strav_tenant_sequences`). Apps register
|
|
8
|
+
* `jobSchema` with their SchemaRegistry and `generateMigration` picks
|
|
9
|
+
* it up like any other schema.
|
|
10
|
+
*
|
|
11
|
+
* Columns:
|
|
12
|
+
* - `id` (char(26), ULID PK) — globally unique across queues.
|
|
13
|
+
* - `queue` (varchar(64)) — named queue. Default `'default'`. Workers
|
|
14
|
+
* poll one queue at a time, so this is the routing key.
|
|
15
|
+
* - `job_name` (varchar(128)) — `JobRegistry` lookup key. The Worker
|
|
16
|
+
* deserializes the row by mapping this back to a `JobClass`.
|
|
17
|
+
* - `payload` (jsonb) — the `JSON.stringify`'d job payload.
|
|
18
|
+
* - `attempts` (integer, default 0) — count of attempts so far. The
|
|
19
|
+
* Worker increments before running `handle()`.
|
|
20
|
+
* - `max_attempts` (integer, default 3) — total retries allowed.
|
|
21
|
+
* `attempts >= max_attempts` after a failed attempt → terminal
|
|
22
|
+
* failure, moves to `failed_jobs` (V1 just leaves it; failed-jobs
|
|
23
|
+
* handling is its own slice).
|
|
24
|
+
* - `available_at` (timestamptz) — the earliest time a Worker may
|
|
25
|
+
* pick this row up. `now()` for immediate dispatch; `dispatchLater`
|
|
26
|
+
* pushes this into the future.
|
|
27
|
+
* - `reserved_at` (timestamptz, nullable) — when a Worker locked
|
|
28
|
+
* the row via `SELECT FOR UPDATE SKIP LOCKED`. NULL means
|
|
29
|
+
* unreserved.
|
|
30
|
+
* - `created_at` / `updated_at` — provided by `t.timestamps()`.
|
|
31
|
+
*
|
|
32
|
+
* Not `tenanted: true` — the queue is system-level. Multi-tenant apps
|
|
33
|
+
* that want per-tenant queues can clone this schema with a tenant_id
|
|
34
|
+
* FK + RLS, but that's a follow-up.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
38
|
+
|
|
39
|
+
export const jobSchema = defineSchema('strav_jobs', Archetype.Entity, (t) => {
|
|
40
|
+
t.id()
|
|
41
|
+
t.string('queue').max(64).default('default')
|
|
42
|
+
t.string('job_name').max(128)
|
|
43
|
+
t.json<unknown>('payload')
|
|
44
|
+
t.integer('attempts').default(0)
|
|
45
|
+
t.integer('max_attempts').default(3)
|
|
46
|
+
t.timestamp('available_at')
|
|
47
|
+
t.timestamp('reserved_at').nullable()
|
|
48
|
+
t.timestamps()
|
|
49
|
+
})
|
package/src/queue.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Queue` — the contract every backend implements.
|
|
3
|
+
*
|
|
4
|
+
* V1 ships two: `SyncQueue` (in-process, no persistence — for tests +
|
|
5
|
+
* single-process dev) and `DatabaseQueue` (Postgres-backed, the
|
|
6
|
+
* production driver). Both implement this interface; apps depend on
|
|
7
|
+
* the abstract `Queue` symbol via DI.
|
|
8
|
+
*
|
|
9
|
+
* Method semantics:
|
|
10
|
+
* - `dispatch(JobClass, payload, opts?)` — enqueue for a Worker to
|
|
11
|
+
* pick up. Returns the assigned `jobId` (ULID).
|
|
12
|
+
* - `dispatchLater(at, JobClass, payload, opts?)` — same as dispatch
|
|
13
|
+
* but the row isn't picked up until `at`. `at` is either a `Date`
|
|
14
|
+
* (absolute) or a number of seconds from now (relative).
|
|
15
|
+
* - `dispatchSync(JobClass, payload)` — instantiate + run `handle()`
|
|
16
|
+
* synchronously in the caller's process. No persistence, no
|
|
17
|
+
* retries, no Worker required. Useful for tests, for dev mode,
|
|
18
|
+
* and for callers that genuinely want the work done inline (rare
|
|
19
|
+
* in production — most jobs exist precisely to defer work).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { JobClass, PayloadOf } from './job.ts'
|
|
23
|
+
|
|
24
|
+
export interface DispatchOptions {
|
|
25
|
+
/** Named queue to dispatch onto. Default: the JobClass's `static queue` or `'default'`. */
|
|
26
|
+
queue?: string
|
|
27
|
+
/**
|
|
28
|
+
* Override total attempts (including the first). Default: the
|
|
29
|
+
* JobClass's `static maxAttempts`, or the driver default (typically 3).
|
|
30
|
+
*/
|
|
31
|
+
attempts?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DispatchLaterOptions extends DispatchOptions {
|
|
35
|
+
// No additional options today; the slot exists so future drivers can
|
|
36
|
+
// grow it (e.g. `priority` or `deduplicationKey`) without breaking
|
|
37
|
+
// the call sites.
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Queue {
|
|
41
|
+
/** Enqueue immediately. Returns the assigned job id (ULID). */
|
|
42
|
+
dispatch<TJob extends JobClass>(
|
|
43
|
+
jobClass: TJob,
|
|
44
|
+
payload: PayloadOf<TJob>,
|
|
45
|
+
opts?: DispatchOptions,
|
|
46
|
+
): Promise<string>
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Enqueue with a delay. `at` is either a `Date` (absolute wall-clock
|
|
50
|
+
* time) or a positive number of seconds from now. Returns the
|
|
51
|
+
* assigned job id (ULID).
|
|
52
|
+
*
|
|
53
|
+
* `Date` values in the past are clamped to "now" — i.e. the job
|
|
54
|
+
* becomes immediately eligible. Negative numbers throw.
|
|
55
|
+
*/
|
|
56
|
+
dispatchLater<TJob extends JobClass>(
|
|
57
|
+
at: Date | number,
|
|
58
|
+
jobClass: TJob,
|
|
59
|
+
payload: PayloadOf<TJob>,
|
|
60
|
+
opts?: DispatchLaterOptions,
|
|
61
|
+
): Promise<string>
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run the job synchronously in the caller's process. No persistence,
|
|
65
|
+
* no retries — if `handle()` throws, the throw propagates. Returns
|
|
66
|
+
* when `handle()` resolves.
|
|
67
|
+
*/
|
|
68
|
+
dispatchSync<TJob extends JobClass>(jobClass: TJob, payload: PayloadOf<TJob>): Promise<void>
|
|
69
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
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
|
+
* Force-dispatch one named entry once, regardless of its cron expression.
|
|
116
|
+
* Useful for `bun strav scheduler:run <name>` — run an entry on demand
|
|
117
|
+
* without waiting for the next tick boundary.
|
|
118
|
+
*
|
|
119
|
+
* Honors `oneServer`: when set, the lock + run-tracking row still apply,
|
|
120
|
+
* so two `scheduler:run` invocations against the same name from
|
|
121
|
+
* different machines don't double-dispatch. The "tick boundary" used
|
|
122
|
+
* for run-tracking is `now` floored to the minute, the same convention
|
|
123
|
+
* `tick()` uses.
|
|
124
|
+
*
|
|
125
|
+
* Throws when `name` doesn't match any registered entry — silent failure
|
|
126
|
+
* here would be a footgun in a deploy script.
|
|
127
|
+
*/
|
|
128
|
+
async runEntry(name: string, now: Date = new Date()): Promise<void> {
|
|
129
|
+
const entry = this.entries.find((e) => e.name === name)
|
|
130
|
+
if (!entry) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Scheduler.runEntry("${name}"): no schedule with that name registered. ` +
|
|
133
|
+
`Known names: ${this.entries.map((e) => e.name).join(', ') || '(none)'}`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
if (entry.oneServer) {
|
|
137
|
+
await this.dispatchOneServer(entry, floorToMinute(now))
|
|
138
|
+
} else {
|
|
139
|
+
await this.queue.dispatch(entry.job, entry.payload as never)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Process every entry against `now`. The tick boundary is `now`
|
|
145
|
+
* floored to the start of its minute (seconds + millis cleared) —
|
|
146
|
+
* cron matches against that, and `oneServer` run-tracking writes
|
|
147
|
+
* that value into `strav_scheduler_runs.last_run_at`.
|
|
148
|
+
*/
|
|
149
|
+
async tick(now: Date = new Date()): Promise<void> {
|
|
150
|
+
const boundary = floorToMinute(now)
|
|
151
|
+
for (const entry of this.entries) {
|
|
152
|
+
if (!entry.cron.matches(boundary)) continue
|
|
153
|
+
try {
|
|
154
|
+
if (entry.oneServer) {
|
|
155
|
+
await this.dispatchOneServer(entry, boundary)
|
|
156
|
+
} else {
|
|
157
|
+
await this.queue.dispatch(entry.job, entry.payload as never)
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.logger.error('Scheduler: dispatch failed', {
|
|
161
|
+
name: entry.name,
|
|
162
|
+
jobName: entry.job.jobName,
|
|
163
|
+
error,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run the minute-tick loop until `signal` aborts. Each iteration
|
|
171
|
+
* sleeps until the next minute boundary, then calls `tick()`. The
|
|
172
|
+
* sleep is abort-aware — `signal.abort()` returns within one tick
|
|
173
|
+
* rather than waiting out the minute.
|
|
174
|
+
*
|
|
175
|
+
* On the FIRST iteration, sleeps to the next minute boundary
|
|
176
|
+
* BEFORE the first tick — so callers see one full minute pass
|
|
177
|
+
* between `run()` start and the first dispatch. (Calling `tick()`
|
|
178
|
+
* explicitly before `run()` is the way to dispatch immediately.)
|
|
179
|
+
*/
|
|
180
|
+
async run(signal: AbortSignal): Promise<void> {
|
|
181
|
+
while (!signal.aborted) {
|
|
182
|
+
const now = new Date()
|
|
183
|
+
const nextBoundary = nextMinuteBoundary(now)
|
|
184
|
+
const sleepMs = Math.max(0, nextBoundary.getTime() - now.getTime())
|
|
185
|
+
await sleep(sleepMs, signal)
|
|
186
|
+
if (signal.aborted) return
|
|
187
|
+
try {
|
|
188
|
+
await this.tick(nextBoundary)
|
|
189
|
+
} catch (loopError) {
|
|
190
|
+
this.logger.error('Scheduler: tick iteration failed', { error: loopError })
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async dispatchOneServer(entry: ScheduledEntry, tickBoundary: Date): Promise<void> {
|
|
196
|
+
await this.tenants.withLock(`scheduler:${entry.name}`, async (tx) => {
|
|
197
|
+
const last = await tx.queryOne<{ last_run_at: Date }>(
|
|
198
|
+
`SELECT last_run_at FROM "strav_scheduler_runs" WHERE name = $1`,
|
|
199
|
+
[entry.name],
|
|
200
|
+
)
|
|
201
|
+
// The lock serializes the read+write window — if another server
|
|
202
|
+
// already recorded this tick boundary, skip cleanly.
|
|
203
|
+
if (last !== null && last.last_run_at.getTime() >= tickBoundary.getTime()) {
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
// Inside withLock's UoW, the dispatch routes through the ambient
|
|
207
|
+
// tx — the queue row INSERT + the run-tracking UPSERT commit
|
|
208
|
+
// atomically. If anything throws, both roll back.
|
|
209
|
+
await this.queue.dispatch(entry.job, entry.payload as never)
|
|
210
|
+
await tx.execute(
|
|
211
|
+
`INSERT INTO "strav_scheduler_runs" (id, name, last_run_at, created_at, updated_at)
|
|
212
|
+
VALUES ($1, $2, $3, now(), now())
|
|
213
|
+
ON CONFLICT (name) DO UPDATE
|
|
214
|
+
SET last_run_at = EXCLUDED.last_run_at, updated_at = now()`,
|
|
215
|
+
[ulid(), entry.name, tickBoundary],
|
|
216
|
+
)
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Floor a Date to the start of its minute (seconds + millis cleared). */
|
|
222
|
+
function floorToMinute(date: Date): Date {
|
|
223
|
+
return new Date(
|
|
224
|
+
Date.UTC(
|
|
225
|
+
date.getUTCFullYear(),
|
|
226
|
+
date.getUTCMonth(),
|
|
227
|
+
date.getUTCDate(),
|
|
228
|
+
date.getUTCHours(),
|
|
229
|
+
date.getUTCMinutes(),
|
|
230
|
+
0,
|
|
231
|
+
0,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Next-minute boundary strictly after `date`. */
|
|
237
|
+
function nextMinuteBoundary(date: Date): Date {
|
|
238
|
+
const floor = floorToMinute(date)
|
|
239
|
+
return new Date(floor.getTime() + 60_000)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Abort-aware sleep. */
|
|
243
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
244
|
+
return new Promise<void>((resolve) => {
|
|
245
|
+
if (signal.aborted) {
|
|
246
|
+
resolve()
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
const timer = setTimeout(resolve, ms)
|
|
250
|
+
const onAbort = () => {
|
|
251
|
+
clearTimeout(timer)
|
|
252
|
+
signal.removeEventListener('abort', onAbort)
|
|
253
|
+
resolve()
|
|
254
|
+
}
|
|
255
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** No-op Logger — same shape as the ones in DatabaseQueue / Worker. */
|
|
260
|
+
function createNoopLogger(): Logger {
|
|
261
|
+
const noop = () => undefined
|
|
262
|
+
return {
|
|
263
|
+
debug: noop,
|
|
264
|
+
info: noop,
|
|
265
|
+
warn: noop,
|
|
266
|
+
error: noop,
|
|
267
|
+
fatal: noop,
|
|
268
|
+
trace: noop,
|
|
269
|
+
child: () => createNoopLogger(),
|
|
270
|
+
} as unknown as Logger
|
|
271
|
+
}
|
|
@@ -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
|
+
}
|