@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
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,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/queue",
|
|
3
|
-
"version": "0.4
|
|
3
|
+
"version": "1.0.0-alpha.4",
|
|
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/cli": "1.0.0-alpha.4",
|
|
23
|
+
"@strav/database": "1.0.0-alpha.4",
|
|
24
|
+
"@strav/kernel": "1.0.0-alpha.4"
|
|
29
25
|
},
|
|
30
26
|
"peerDependencies": {
|
|
31
|
-
"@
|
|
32
|
-
"@strav/database": "0.4.31"
|
|
27
|
+
"@types/bun": ">=1.3.14"
|
|
33
28
|
},
|
|
34
|
-
"
|
|
35
|
-
"test": "bun test tests/",
|
|
36
|
-
"typecheck": "tsc --noEmit"
|
|
37
|
-
}
|
|
29
|
+
"devDependencies": null
|
|
38
30
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Console subsystem — queue + scheduler commands + QueueConsoleProvider.
|
|
2
|
+
|
|
3
|
+
export { QueueConsoleProvider } from './queue_console_provider.ts'
|
|
4
|
+
export { QueueFailed } from './queue_failed.ts'
|
|
5
|
+
export { QueueFlush } from './queue_flush.ts'
|
|
6
|
+
export { QueueRetry } from './queue_retry.ts'
|
|
7
|
+
export { QueueWork } from './queue_work.ts'
|
|
8
|
+
export { SchedulerList } from './scheduler_list.ts'
|
|
9
|
+
export { SchedulerRun } from './scheduler_run.ts'
|
|
10
|
+
export { SchedulerWork } from './scheduler_work.ts'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `QueueConsoleProvider` — declares the queue + scheduler console commands.
|
|
3
|
+
*
|
|
4
|
+
* Apps add it to `bootstrap/providers.ts` alongside whatever provider binds
|
|
5
|
+
* `Worker` + `Scheduler` (apps wire those — see `docs/queue/guides/console.md`).
|
|
6
|
+
*
|
|
7
|
+
* The provider doesn't bind Worker/Scheduler itself because their
|
|
8
|
+
* construction is app-specific (queue names, registered jobs, scheduler
|
|
9
|
+
* entries) — too much variance for a sensible default.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { ConsoleProvider } from '@strav/cli'
|
|
13
|
+
import { QueueFailed } from './queue_failed.ts'
|
|
14
|
+
import { QueueFlush } from './queue_flush.ts'
|
|
15
|
+
import { QueueRetry } from './queue_retry.ts'
|
|
16
|
+
import { QueueWork } from './queue_work.ts'
|
|
17
|
+
import { SchedulerList } from './scheduler_list.ts'
|
|
18
|
+
import { SchedulerRun } from './scheduler_run.ts'
|
|
19
|
+
import { SchedulerWork } from './scheduler_work.ts'
|
|
20
|
+
|
|
21
|
+
export class QueueConsoleProvider extends ConsoleProvider {
|
|
22
|
+
override readonly name = 'console.queue'
|
|
23
|
+
override readonly commands = [
|
|
24
|
+
QueueWork,
|
|
25
|
+
QueueFailed,
|
|
26
|
+
QueueRetry,
|
|
27
|
+
QueueFlush,
|
|
28
|
+
SchedulerWork,
|
|
29
|
+
SchedulerList,
|
|
30
|
+
SchedulerRun,
|
|
31
|
+
] as const
|
|
32
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav queue:failed` — list rows in the dead-letter table.
|
|
3
|
+
*
|
|
4
|
+
* Reads `strav_failed_jobs` directly via the bound `Database`. The
|
|
5
|
+
* Worker moves terminal failures here (atomic INSERT + DELETE from
|
|
6
|
+
* `strav_jobs`); this command surfaces them for triage.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Command, ExitCode } from '@strav/cli'
|
|
10
|
+
import { PostgresDatabase } from '@strav/database'
|
|
11
|
+
|
|
12
|
+
interface FailedRow {
|
|
13
|
+
id: string
|
|
14
|
+
queue: string
|
|
15
|
+
job_name: string
|
|
16
|
+
attempts: number
|
|
17
|
+
failed_at: Date | string
|
|
18
|
+
exception: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class QueueFailed extends Command {
|
|
22
|
+
static signature = 'queue:failed'
|
|
23
|
+
static description = 'List jobs in the dead-letter table.'
|
|
24
|
+
static providers = ['config', 'logger', 'database']
|
|
25
|
+
|
|
26
|
+
override async execute(): Promise<number> {
|
|
27
|
+
const db = this.app.resolve(PostgresDatabase)
|
|
28
|
+
const rows = await db.query<FailedRow>(
|
|
29
|
+
`SELECT id, queue, job_name, attempts, failed_at, exception
|
|
30
|
+
FROM "strav_failed_jobs"
|
|
31
|
+
ORDER BY failed_at DESC`,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if (rows.length === 0) {
|
|
35
|
+
this.info('No failed jobs.')
|
|
36
|
+
return ExitCode.Success
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.table(
|
|
40
|
+
['ID', 'Queue', 'Job', 'Attempts', 'Failed at', 'Error'],
|
|
41
|
+
rows.map((r) => [
|
|
42
|
+
r.id,
|
|
43
|
+
r.queue,
|
|
44
|
+
r.job_name,
|
|
45
|
+
String(r.attempts),
|
|
46
|
+
(r.failed_at instanceof Date ? r.failed_at : new Date(r.failed_at)).toISOString(),
|
|
47
|
+
firstLine(r.exception),
|
|
48
|
+
]),
|
|
49
|
+
)
|
|
50
|
+
return ExitCode.Success
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function firstLine(text: string): string {
|
|
55
|
+
const newline = text.indexOf('\n')
|
|
56
|
+
return newline === -1 ? text : text.slice(0, newline)
|
|
57
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav queue:flush [--queue=name] [--force]` — drop pending jobs.
|
|
3
|
+
*
|
|
4
|
+
* `DELETE FROM strav_jobs` (optionally filtered by queue name). Confirms
|
|
5
|
+
* before running unless `--force` is set. Doesn't touch
|
|
6
|
+
* `strav_failed_jobs` — the dead-letter table is separate and managed
|
|
7
|
+
* via `queue:retry` / a separate operator-driven cleanup.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
11
|
+
import { PostgresDatabase } from '@strav/database'
|
|
12
|
+
|
|
13
|
+
export class QueueFlush extends Command {
|
|
14
|
+
static signature = 'queue:flush {--queue=} {--force}'
|
|
15
|
+
static description = 'Delete pending jobs (optionally filtered by --queue).'
|
|
16
|
+
static providers = ['config', 'logger', 'database']
|
|
17
|
+
|
|
18
|
+
override async execute({ flags }: ExecuteArgs): Promise<number> {
|
|
19
|
+
const queue = typeof flags.queue === 'string' && flags.queue.length > 0 ? flags.queue : null
|
|
20
|
+
|
|
21
|
+
if (flags.force !== true) {
|
|
22
|
+
const ok = await this.confirm(
|
|
23
|
+
queue
|
|
24
|
+
? `Delete every pending job on queue "${queue}"? This is irreversible.`
|
|
25
|
+
: 'Delete EVERY pending job across all queues? This is irreversible.',
|
|
26
|
+
)
|
|
27
|
+
if (!ok) {
|
|
28
|
+
this.info('Aborted.')
|
|
29
|
+
return ExitCode.Success
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const db = this.app.resolve(PostgresDatabase)
|
|
34
|
+
const deleted = queue
|
|
35
|
+
? await db.execute(`DELETE FROM "strav_jobs" WHERE queue = $1`, [queue])
|
|
36
|
+
: await db.execute(`DELETE FROM "strav_jobs"`)
|
|
37
|
+
|
|
38
|
+
this.success(`Deleted ${deleted} pending job(s)${queue ? ` from queue "${queue}"` : ''}.`)
|
|
39
|
+
return ExitCode.Success
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav queue:retry <id>` or `--all` — re-enqueue failed jobs.
|
|
3
|
+
*
|
|
4
|
+
* Copies the failed row(s) back into `strav_jobs` (attempts reset to 0,
|
|
5
|
+
* available_at = now()) and deletes them from `strav_failed_jobs` — all
|
|
6
|
+
* inside one transaction so a crash mid-move can't lose rows or
|
|
7
|
+
* double-enqueue.
|
|
8
|
+
*
|
|
9
|
+
* `<id>` re-enqueues exactly one job; `--all` re-enqueues every row in
|
|
10
|
+
* the dead-letter table. Without either, the command errors out with a
|
|
11
|
+
* usage message.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Command, type ExecuteArgs, ExitCode, UsageError } from '@strav/cli'
|
|
15
|
+
import { PostgresDatabase } from '@strav/database'
|
|
16
|
+
import { ulid } from '@strav/kernel'
|
|
17
|
+
|
|
18
|
+
interface FailedRow {
|
|
19
|
+
id: string
|
|
20
|
+
queue: string
|
|
21
|
+
job_name: string
|
|
22
|
+
payload: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class QueueRetry extends Command {
|
|
26
|
+
static signature = 'queue:retry {id?} {--all}'
|
|
27
|
+
static description = 'Re-enqueue a failed job by id, or every failed job with --all.'
|
|
28
|
+
static providers = ['config', 'logger', 'database']
|
|
29
|
+
|
|
30
|
+
override async execute({ args, flags }: ExecuteArgs): Promise<number> {
|
|
31
|
+
const all = flags.all === true
|
|
32
|
+
const id = args.id
|
|
33
|
+
if (!all && (id === undefined || id.length === 0)) {
|
|
34
|
+
throw new UsageError('queue:retry needs an <id> or the --all flag')
|
|
35
|
+
}
|
|
36
|
+
if (all && id !== undefined && id.length > 0) {
|
|
37
|
+
throw new UsageError('queue:retry: pass an <id> OR --all, not both')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const db = this.app.resolve(PostgresDatabase)
|
|
41
|
+
const moved = await db.transaction(async (tx) => {
|
|
42
|
+
const rows = all
|
|
43
|
+
? await tx.query<FailedRow>(`SELECT id, queue, job_name, payload FROM "strav_failed_jobs"`)
|
|
44
|
+
: await tx.query<FailedRow>(
|
|
45
|
+
`SELECT id, queue, job_name, payload FROM "strav_failed_jobs" WHERE id = $1`,
|
|
46
|
+
[id],
|
|
47
|
+
)
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
await tx.execute(
|
|
50
|
+
`INSERT INTO "strav_jobs" (id, queue, job_name, payload, attempts, max_attempts, available_at, reserved_at, created_at, updated_at)
|
|
51
|
+
VALUES ($1, $2, $3, $4, 0, 3, now(), NULL, now(), now())`,
|
|
52
|
+
[ulid(), row.queue, row.job_name, row.payload],
|
|
53
|
+
)
|
|
54
|
+
await tx.execute(`DELETE FROM "strav_failed_jobs" WHERE id = $1`, [row.id])
|
|
55
|
+
}
|
|
56
|
+
return rows
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if (moved.length === 0) {
|
|
60
|
+
if (all) this.info('No failed jobs to retry.')
|
|
61
|
+
else this.warn(`No failed job with id "${id}".`)
|
|
62
|
+
return ExitCode.Success
|
|
63
|
+
}
|
|
64
|
+
this.success(`Re-enqueued ${moved.length} job(s).`)
|
|
65
|
+
for (const row of moved) this.line(` ↻ ${row.id} ${row.job_name}`)
|
|
66
|
+
return ExitCode.Success
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav queue:work [--max=N]` — drain jobs forever (or up to N).
|
|
3
|
+
*
|
|
4
|
+
* Resolves the container-bound `Worker`. Apps construct the `Worker` in
|
|
5
|
+
* their provider (it needs `db`, `registry`, `container`, plus the
|
|
6
|
+
* `queues` slice config) — the command just drives it.
|
|
7
|
+
*
|
|
8
|
+
* Signal handling: registers `SIGINT` + `SIGTERM` listeners that abort
|
|
9
|
+
* the worker's loop. The Worker's own graceful-shutdown semantics
|
|
10
|
+
* (drain in-flight jobs, release SKIP LOCKED rows) handle the rest.
|
|
11
|
+
*
|
|
12
|
+
* `--max=N` exits after N completed jobs — useful for hosted runtimes
|
|
13
|
+
* (Render workers, supervised tasks) that prefer "exit cleanly so the
|
|
14
|
+
* supervisor restarts you" over an unbounded loop. When N is set we
|
|
15
|
+
* loop on `processOne()` so we can count cleanly; otherwise we hand
|
|
16
|
+
* off to `worker.run(signal)`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Command, type ExecuteArgs, ExitCode, UsageError } from '@strav/cli'
|
|
20
|
+
import { Worker } from '../worker.ts'
|
|
21
|
+
|
|
22
|
+
export class QueueWork extends Command {
|
|
23
|
+
static signature = 'queue:work {--queue=default} {--max=}'
|
|
24
|
+
static description = 'Run a queue worker until interrupted (or --max=N jobs).'
|
|
25
|
+
// Boot the full default list — the Worker pulls Database, JobRegistry,
|
|
26
|
+
// Logger, and any app-registered services through the container at
|
|
27
|
+
// resolution time.
|
|
28
|
+
|
|
29
|
+
override async execute({ flags }: ExecuteArgs): Promise<number> {
|
|
30
|
+
const maxStr = flags.max
|
|
31
|
+
let max: number | null = null
|
|
32
|
+
if (typeof maxStr === 'string' && maxStr.length > 0) {
|
|
33
|
+
const parsed = Number.parseInt(maxStr, 10)
|
|
34
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
35
|
+
throw new UsageError(`--max must be a positive integer (got "${maxStr}")`)
|
|
36
|
+
}
|
|
37
|
+
max = parsed
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const worker = this.app.resolve(Worker)
|
|
41
|
+
const controller = new AbortController()
|
|
42
|
+
const sigint = () => controller.abort()
|
|
43
|
+
const sigterm = () => controller.abort()
|
|
44
|
+
process.once('SIGINT', sigint)
|
|
45
|
+
process.once('SIGTERM', sigterm)
|
|
46
|
+
|
|
47
|
+
this.info(`Worker started — queue=${flags.queue}${max !== null ? `, max=${max}` : ''}.`)
|
|
48
|
+
try {
|
|
49
|
+
if (max === null) {
|
|
50
|
+
await worker.run(controller.signal)
|
|
51
|
+
} else {
|
|
52
|
+
// Bounded loop — `processOne()` returns null when nothing's claimable;
|
|
53
|
+
// sleep briefly so we don't spin-poll an empty queue.
|
|
54
|
+
let processed = 0
|
|
55
|
+
while (processed < max && !controller.signal.aborted) {
|
|
56
|
+
const result = await worker.processOne()
|
|
57
|
+
if (result === null) {
|
|
58
|
+
await sleep(1000, controller.signal)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
processed++
|
|
62
|
+
}
|
|
63
|
+
this.info(`Stopped after ${processed} job(s).`)
|
|
64
|
+
}
|
|
65
|
+
return ExitCode.Success
|
|
66
|
+
} finally {
|
|
67
|
+
process.off('SIGINT', sigint)
|
|
68
|
+
process.off('SIGTERM', sigterm)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
const timer = setTimeout(resolve, ms)
|
|
76
|
+
signal.addEventListener(
|
|
77
|
+
'abort',
|
|
78
|
+
() => {
|
|
79
|
+
clearTimeout(timer)
|
|
80
|
+
resolve()
|
|
81
|
+
},
|
|
82
|
+
{ once: true },
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav scheduler:list` — table of every registered scheduled entry.
|
|
3
|
+
*
|
|
4
|
+
* Read-only / pure introspection — never dispatches.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command, ExitCode } from '@strav/cli'
|
|
8
|
+
import { Scheduler } from '../scheduler.ts'
|
|
9
|
+
|
|
10
|
+
export class SchedulerList extends Command {
|
|
11
|
+
static signature = 'scheduler:list'
|
|
12
|
+
static description = 'List registered scheduler entries.'
|
|
13
|
+
|
|
14
|
+
override execute(): number {
|
|
15
|
+
const scheduler = this.app.resolve(Scheduler)
|
|
16
|
+
const entries = scheduler.all()
|
|
17
|
+
if (entries.length === 0) {
|
|
18
|
+
this.info('No schedules registered.')
|
|
19
|
+
return ExitCode.Success
|
|
20
|
+
}
|
|
21
|
+
this.table(
|
|
22
|
+
['Name', 'Cron', 'Job', 'OneServer'],
|
|
23
|
+
entries.map((e) => [e.name, e.cron.expression, e.job.jobName, e.oneServer ? 'yes' : 'no']),
|
|
24
|
+
)
|
|
25
|
+
return ExitCode.Success
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav scheduler:run <name>` — force-dispatch one named entry on demand.
|
|
3
|
+
*
|
|
4
|
+
* Bypasses the cron expression. When the entry was registered with
|
|
5
|
+
* `oneServer: true`, the advisory lock + run-tracking row still apply,
|
|
6
|
+
* so two `scheduler:run` invocations from different machines can't
|
|
7
|
+
* double-dispatch.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
11
|
+
import { Scheduler } from '../scheduler.ts'
|
|
12
|
+
|
|
13
|
+
export class SchedulerRun extends Command {
|
|
14
|
+
static signature = 'scheduler:run {name}'
|
|
15
|
+
static description = 'Force-run one named schedule entry.'
|
|
16
|
+
|
|
17
|
+
override async execute({ args }: ExecuteArgs): Promise<number> {
|
|
18
|
+
const name = args.name
|
|
19
|
+
if (!name) return ExitCode.UsageError // unreachable: bindArgv already enforced
|
|
20
|
+
|
|
21
|
+
const scheduler = this.app.resolve(Scheduler)
|
|
22
|
+
try {
|
|
23
|
+
await scheduler.runEntry(name)
|
|
24
|
+
} catch (err) {
|
|
25
|
+
this.error((err as Error).message)
|
|
26
|
+
return ExitCode.UsageError
|
|
27
|
+
}
|
|
28
|
+
this.success(`Dispatched "${name}".`)
|
|
29
|
+
return ExitCode.Success
|
|
30
|
+
}
|
|
31
|
+
}
|