@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/README.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# @strav/queue
|
|
2
|
+
|
|
3
|
+
Background-job primitives for Strav 1.0 — the `Job` base class, the `JobRegistry`, the `Queue` contract, and a synchronous in-process driver. Postgres-backed `DatabaseQueue` + `Worker` + `Scheduler` land in follow-up M3 slices.
|
|
4
|
+
|
|
5
|
+
> **Status: 1.0.0-alpha — queue package functionally complete.** Contract layer + `SyncQueue` + `DatabaseQueue` (queue-until-commit) + `Worker` (SKIP LOCKED + backoff + atomic-move-to-failed) + `Scheduler` (cron + `onOneServer`) + `failedJobsSchema` all shipped. Only the `queue:retry` / `queue:flush` console commands remain — they wait on `@strav/cli` (M4).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @strav/queue
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dep: `@strav/kernel` (already in the workspace).
|
|
14
|
+
|
|
15
|
+
## Defining a Job
|
|
16
|
+
|
|
17
|
+
Subclass `Job<TPayload>`, declare a stable `static jobName`, implement `handle(ctx)`:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { Job, type JobContext } from '@strav/queue'
|
|
21
|
+
import { inject } from '@strav/kernel'
|
|
22
|
+
|
|
23
|
+
@inject()
|
|
24
|
+
export class SendWelcomeEmail extends Job<{ userId: string }> {
|
|
25
|
+
static override readonly jobName = 'mail.welcome'
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly users: UserRepository,
|
|
29
|
+
private readonly mail: MailManager,
|
|
30
|
+
) {
|
|
31
|
+
super()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async handle(ctx: JobContext<{ userId: string }>): Promise<void> {
|
|
35
|
+
const user = await this.users.findOrFail(ctx.payload.userId)
|
|
36
|
+
await this.mail.send(new WelcomeEmail(user))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The Worker constructs the Job via the container — so `@inject()`-marked subclasses get their dependencies the same way Repositories + controllers do. The payload arrives on `JobContext.payload` (after JSON round-trip through the queue backend).
|
|
42
|
+
|
|
43
|
+
### Configuration
|
|
44
|
+
|
|
45
|
+
Optional static overrides — the Worker reads these per-attempt:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
class SendWelcomeEmail extends Job<...> {
|
|
49
|
+
static override readonly jobName = 'mail.welcome'
|
|
50
|
+
static override readonly maxAttempts = 5
|
|
51
|
+
static override readonly timeout = 30 // seconds
|
|
52
|
+
static override readonly queue = 'mail' // named queue
|
|
53
|
+
static override backoff(attempt: number): number {
|
|
54
|
+
return Math.min(60, 2 ** attempt) // seconds before next retry
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Omitted fields fall back to driver defaults.
|
|
60
|
+
|
|
61
|
+
### `failed(ctx)` hook
|
|
62
|
+
|
|
63
|
+
Fires when a `handle()` attempt throws — both on intermediate retryable failures AND on the final failure. Useful for routing to a dead-letter, posting to Slack, etc.:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
async failed(ctx: JobFailedContext<{ userId: string }>): Promise<void> {
|
|
67
|
+
await slack.post(`Welcome email failed for ${ctx.payload.userId}: ${(ctx.error as Error).message}`)
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A throw from `failed()` is logged but doesn't change the retry decision.
|
|
72
|
+
|
|
73
|
+
## Registering jobs
|
|
74
|
+
|
|
75
|
+
`JobRegistry` maps `jobName` strings back to Job classes — the Worker uses it to deserialize the queue row's `type` column into a class to instantiate.
|
|
76
|
+
|
|
77
|
+
### Explicit
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { JobRegistry } from '@strav/queue'
|
|
81
|
+
import { SendWelcomeEmail } from '../app/Jobs/send_welcome_email.ts'
|
|
82
|
+
|
|
83
|
+
const registry = new JobRegistry().registerAll([SendWelcomeEmail, /* ... */])
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Auto-discovery
|
|
87
|
+
|
|
88
|
+
`discover(pattern)` uses `Bun.Glob` to scan files, dynamically imports each, and registers every export that satisfies `isJobClass()`. Same shape as `SchemaRegistry.discover` in `@strav/database`.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
await registry.discover('app/Jobs/**/*.ts')
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Re-exports of the same class (barrel patterns) dedupe by identity; two DIFFERENT classes sharing a `jobName` throw `ConfigError`.
|
|
95
|
+
|
|
96
|
+
## Dispatching
|
|
97
|
+
|
|
98
|
+
`Queue` is an interface with three methods:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
queue.dispatch(JobClass, payload, opts?) // returns jobId (ULID)
|
|
102
|
+
queue.dispatchLater(at, JobClass, payload, opts?)
|
|
103
|
+
queue.dispatchSync(JobClass, payload) // run in-process, no persistence
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`dispatchLater`'s `at` is either a `Date` (absolute) or a positive number of seconds from now. Past dates / zero clamp to "now". Negative numbers throw.
|
|
107
|
+
|
|
108
|
+
`opts.queue` and `opts.attempts` override the JobClass defaults.
|
|
109
|
+
|
|
110
|
+
## SyncQueue — in-process driver
|
|
111
|
+
|
|
112
|
+
The V1 driver for tests and single-process dev. Instantiates the Job via the container, builds a `JobContext`, calls `handle()` synchronously. No persistence, no retries — if `handle()` throws, the throw propagates.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { Application } from '@strav/kernel'
|
|
116
|
+
import { SyncQueue } from '@strav/queue'
|
|
117
|
+
|
|
118
|
+
const app = new Application()
|
|
119
|
+
const queue = new SyncQueue({ container: app, logger: app.resolve(Logger) })
|
|
120
|
+
|
|
121
|
+
await queue.dispatch(SendWelcomeEmail, { userId: 'u-1' }) // runs immediately
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`dispatchLater` under `SyncQueue` ignores the delay (still runs immediately) but validates the delay shape — so callers can't pass `-5` here and have it silently work, only to fail on `DatabaseQueue` later.
|
|
125
|
+
|
|
126
|
+
## DatabaseQueue — Postgres-backed driver
|
|
127
|
+
|
|
128
|
+
The production driver. `dispatch` writes a `strav_jobs` row; the Worker (next M3 slice) picks it up via `SELECT FOR UPDATE SKIP LOCKED`. Apps register `jobSchema` with their `SchemaRegistry` and migrate the table.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { Application, EventBus } from '@strav/kernel'
|
|
132
|
+
import { DatabaseProvider, PostgresDatabase, SchemaRegistry } from '@strav/database'
|
|
133
|
+
import { DatabaseQueue, jobSchema } from '@strav/queue'
|
|
134
|
+
|
|
135
|
+
// In SchemasProvider (or wherever you register schemas):
|
|
136
|
+
registry.registerAll([jobSchema, /* … */])
|
|
137
|
+
|
|
138
|
+
// In QueueProvider:
|
|
139
|
+
new DatabaseQueue({
|
|
140
|
+
db: app.resolve(PostgresDatabase),
|
|
141
|
+
container: app,
|
|
142
|
+
logger: app.resolve(Logger),
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Queue-until-commit
|
|
147
|
+
|
|
148
|
+
When `dispatch` is called inside `UnitOfWork.run(...)` or `TenantManager.withTenant(...)`, the INSERT routes through the ambient transaction. Atomic with the surrounding work:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
await uow.run(async () => {
|
|
152
|
+
await userRepo.create({ email })
|
|
153
|
+
await queue.dispatch(SendWelcomeEmail, { userId: '...' })
|
|
154
|
+
// If the transaction commits, both the user row AND the queue row
|
|
155
|
+
// are visible. If it rolls back, neither exists.
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
This is the M3 spike from the spec — Postgres's transactional atomicity gives us the semantic for free. See [`docs/queue/api.md`](../../docs/queue/api.md#queue-until-commit-semantics) for the full mechanics.
|
|
160
|
+
|
|
161
|
+
### Delay mechanics
|
|
162
|
+
|
|
163
|
+
`dispatchLater` computes delays in Postgres (`now() + interval 'N seconds'`) so the Worker reads `available_at` from the same DB clock the dispatcher wrote against — no clock-skew bugs.
|
|
164
|
+
|
|
165
|
+
## Worker — the consumer side
|
|
166
|
+
|
|
167
|
+
Polls `strav_jobs`, claims via `SELECT FOR UPDATE SKIP LOCKED`, runs `handle()` with a per-attempt timeout, deletes on success, retries with backoff on failure.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { Worker } from '@strav/queue'
|
|
171
|
+
|
|
172
|
+
const worker = new Worker({
|
|
173
|
+
db: app.resolve(PostgresDatabase),
|
|
174
|
+
registry: app.resolve(JobRegistry),
|
|
175
|
+
container: app,
|
|
176
|
+
logger: app.resolve(Logger),
|
|
177
|
+
queues: ['default'],
|
|
178
|
+
pollInterval: 1000, // ms between empty polls
|
|
179
|
+
timeoutSeconds: 60, // per-attempt timeout
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const controller = new AbortController()
|
|
183
|
+
process.on('SIGTERM', () => controller.abort())
|
|
184
|
+
process.on('SIGINT', () => controller.abort())
|
|
185
|
+
|
|
186
|
+
await worker.run(controller.signal)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`SKIP LOCKED` is the load-bearing primitive — multiple Worker processes can poll the same queue concurrently without picking the same row. Scale horizontally by running more processes.
|
|
190
|
+
|
|
191
|
+
Default backoff: exponential with ±25% jitter, capped at 300 seconds. Per-job override via `static backoff(attempt: number)`, per-Worker via `defaultBackoff`. Jitter prevents thundering-herd retries when many jobs fail simultaneously.
|
|
192
|
+
|
|
193
|
+
`processOne()` (single-shot) is also exposed — useful for tests + one-off CLI invocations.
|
|
194
|
+
|
|
195
|
+
## Scheduler — cron-driven dispatch
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { Scheduler, dailyAt, everyMinutes, hourly } from '@strav/queue'
|
|
199
|
+
|
|
200
|
+
const scheduler = new Scheduler({
|
|
201
|
+
queue: app.resolve(Queue),
|
|
202
|
+
tenants: app.resolve(TenantManager),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
scheduler
|
|
206
|
+
.schedule({ job: CleanupOldSessions, cron: hourly() })
|
|
207
|
+
.schedule({ job: GenerateNightlyReports, cron: dailyAt('02:00'), oneServer: true })
|
|
208
|
+
.schedule({ job: SyncStripe, cron: everyMinutes(15), oneServer: true })
|
|
209
|
+
|
|
210
|
+
const controller = new AbortController()
|
|
211
|
+
process.on('SIGTERM', () => controller.abort())
|
|
212
|
+
process.on('SIGINT', () => controller.abort())
|
|
213
|
+
await scheduler.run(controller.signal)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`oneServer: true` uses `TenantManager.withLock` to acquire a fleet-wide advisory lock + `strav_scheduler_runs.last_run_at` to track which tick boundary already dispatched. Only one server in the fleet dispatches per tick — exactly-once across however many scheduler processes you run.
|
|
217
|
+
|
|
218
|
+
Register `schedulerRunsSchema` alongside `jobSchema` in your `SchemaRegistry` so `generateMigration` picks up the table.
|
|
219
|
+
|
|
220
|
+
Cron matching is UTC-based for predictability. Helper builders cover the common cases (`everyMinute`, `everyMinutes`, `hourly`, `daily`, `dailyAt`) — reach for `cron(expression)` directly when you need weekly / monthly / arbitrary expressions.
|
|
221
|
+
|
|
222
|
+
## Failed jobs — `strav_failed_jobs` dead-letter
|
|
223
|
+
|
|
224
|
+
When `Worker.processOne()` exhausts a job's `max_attempts`, the row moves from `strav_jobs` to `strav_failed_jobs` atomically (INSERT + DELETE in one transaction). Apps inspect this table to triage what blew up:
|
|
225
|
+
|
|
226
|
+
```sql
|
|
227
|
+
SELECT job_name, exception, attempts, failed_at
|
|
228
|
+
FROM strav_failed_jobs
|
|
229
|
+
WHERE job_name = 'mail.welcome'
|
|
230
|
+
ORDER BY failed_at DESC;
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Register `failedJobsSchema` alongside `jobSchema` + `schedulerRunsSchema` so `generateMigration` picks up the table:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
registry.registerAll([userSchema, jobSchema, schedulerRunsSchema, failedJobsSchema])
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The `queue:retry` / `queue:flush` console commands (bulk re-enqueue / drop) ship with `@strav/cli` in M4. Until then, retry by hand: SELECT the failed row, INSERT into `strav_jobs` with the same payload, DELETE from `strav_failed_jobs`.
|
|
240
|
+
|
|
241
|
+
## What's NOT here yet
|
|
242
|
+
|
|
243
|
+
- **`queue:retry` / `queue:flush` console commands** — bulk operations on the `strav_failed_jobs` table. Waits on `@strav/cli` in M4.
|
package/package.json
CHANGED
|
@@ -1,38 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/queue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-alpha.3",
|
|
4
|
+
"description": "Strav queue layer — Job + JobRegistry + dispatch contract; backends ship as drivers",
|
|
4
5
|
"type": "module",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
"typescript",
|
|
11
|
-
"strav",
|
|
12
|
-
"queue",
|
|
13
|
-
"scheduler"
|
|
14
|
-
],
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
15
11
|
"files": [
|
|
16
|
-
"src
|
|
17
|
-
"
|
|
18
|
-
"tsconfig.json",
|
|
19
|
-
"CHANGELOG.md"
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
20
14
|
],
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.3.14"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@strav/database": "1.0.0-alpha.3",
|
|
23
|
+
"@strav/kernel": "1.0.0-alpha.3"
|
|
29
24
|
},
|
|
30
25
|
"peerDependencies": {
|
|
31
|
-
"@
|
|
32
|
-
"@strav/database": "0.4.30"
|
|
26
|
+
"@types/bun": ">=1.3.14"
|
|
33
27
|
},
|
|
34
|
-
"
|
|
35
|
-
"test": "bun test tests/",
|
|
36
|
-
"typecheck": "tsc --noEmit"
|
|
37
|
-
}
|
|
28
|
+
"devDependencies": null
|
|
38
29
|
}
|
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
|
+
}
|