@strav/queue 1.0.0-alpha.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/queue",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "Strav queue layer — Job + JobRegistry + dispatch contract; backends ship as drivers",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -19,8 +19,9 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@strav/database": "1.0.0-alpha.3",
23
- "@strav/kernel": "1.0.0-alpha.3"
22
+ "@strav/cli": "1.0.0-alpha.4",
23
+ "@strav/database": "1.0.0-alpha.4",
24
+ "@strav/kernel": "1.0.0-alpha.4"
24
25
  },
25
26
  "peerDependencies": {
26
27
  "@types/bun": ">=1.3.14"
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `bun strav scheduler:work` — run the minute-tick scheduler loop.
3
+ *
4
+ * Long-running; SIGINT / SIGTERM aborts the loop, which (per Scheduler's
5
+ * semantics) returns within one tick.
6
+ */
7
+
8
+ import { Command, ExitCode } from '@strav/cli'
9
+ import { Scheduler } from '../scheduler.ts'
10
+
11
+ export class SchedulerWork extends Command {
12
+ static signature = 'scheduler:work'
13
+ static description = 'Run the scheduler tick loop until interrupted.'
14
+
15
+ override async execute(): Promise<number> {
16
+ const scheduler = this.app.resolve(Scheduler)
17
+ const controller = new AbortController()
18
+ const sigint = () => controller.abort()
19
+ const sigterm = () => controller.abort()
20
+ process.once('SIGINT', sigint)
21
+ process.once('SIGTERM', sigterm)
22
+
23
+ this.info(`Scheduler started (${scheduler.all().length} entries).`)
24
+ try {
25
+ await scheduler.run(controller.signal)
26
+ return ExitCode.Success
27
+ } finally {
28
+ process.off('SIGINT', sigint)
29
+ process.off('SIGTERM', sigterm)
30
+ }
31
+ }
32
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,16 @@
24
24
  // - queue:retry / queue:flush console commands (re-enqueue / drop
25
25
  // failed rows in bulk).
26
26
 
27
+ export {
28
+ QueueConsoleProvider,
29
+ QueueFailed,
30
+ QueueFlush,
31
+ QueueRetry,
32
+ QueueWork,
33
+ SchedulerList,
34
+ SchedulerRun,
35
+ SchedulerWork,
36
+ } from './console/index.ts'
27
37
  export {
28
38
  CronExpression,
29
39
  cron,
package/src/scheduler.ts CHANGED
@@ -111,6 +111,35 @@ export class Scheduler {
111
111
  return [...this.entries]
112
112
  }
113
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
+
114
143
  /**
115
144
  * Process every entry against `now`. The tick boundary is `now`
116
145
  * floored to the start of its minute (seconds + millis cleared) —