@strav/queue 0.4.31 → 1.0.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `strav_failed_jobs` schema — terminal failure dead-letter table.
|
|
3
|
+
*
|
|
4
|
+
* When `Worker.processOne()` exhausts a job's `max_attempts`, the row
|
|
5
|
+
* moves from `strav_jobs` to `strav_failed_jobs` in a single
|
|
6
|
+
* transaction — INSERT here, DELETE there. Apps inspect this table
|
|
7
|
+
* to triage what blew up; the `queue:retry` / `queue:flush` console
|
|
8
|
+
* commands (lands with `@strav/cli` in M4) operate on these rows.
|
|
9
|
+
*
|
|
10
|
+
* Columns:
|
|
11
|
+
* - `id` (char(26), ULID PK) — fresh per failure, not the original
|
|
12
|
+
* job id (the original is gone with the row).
|
|
13
|
+
* - `queue` (varchar(64)) — copied from `strav_jobs.queue`.
|
|
14
|
+
* - `job_name` (varchar(128)) — copied from `strav_jobs.job_name`.
|
|
15
|
+
* - `payload` (jsonb) — copied verbatim so retries can replay it.
|
|
16
|
+
* - `exception` (text) — the thrown error, serialized via
|
|
17
|
+
* `error.stack ?? String(error)`. Long; not indexed.
|
|
18
|
+
* - `attempts` (integer) — how many total attempts the job got
|
|
19
|
+
* before terminal failure.
|
|
20
|
+
* - `failed_at` (timestamptz) — wall-clock time of the move.
|
|
21
|
+
* - `created_at` / `updated_at` — provided by `t.timestamps()`.
|
|
22
|
+
*
|
|
23
|
+
* Not `tenanted: true` — same system-level scope as `strav_jobs`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
27
|
+
|
|
28
|
+
export const failedJobsSchema = defineSchema('strav_failed_jobs', Archetype.Entity, (t) => {
|
|
29
|
+
t.id()
|
|
30
|
+
t.string('queue').max(64)
|
|
31
|
+
t.string('job_name').max(128)
|
|
32
|
+
t.json<unknown>('payload')
|
|
33
|
+
t.text('exception')
|
|
34
|
+
t.integer('attempts')
|
|
35
|
+
t.timestamp('failed_at')
|
|
36
|
+
t.timestamps()
|
|
37
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// Public API of @strav/queue.
|
|
2
|
+
//
|
|
3
|
+
// V1 ships:
|
|
4
|
+
// - Job + JobContext + JobClass + PayloadOf — the unit of work + types.
|
|
5
|
+
// - JobRegistry + Bun.Glob auto-discovery + isJobClass type-guard.
|
|
6
|
+
// - Queue interface with dispatch / dispatchLater / dispatchSync.
|
|
7
|
+
// - SyncQueue — in-process synchronous driver for tests + single-process dev.
|
|
8
|
+
// - DatabaseQueue — Postgres-backed driver with queue-until-commit semantics
|
|
9
|
+
// via @strav/database's transactional ALS.
|
|
10
|
+
// - jobSchema — the `strav_jobs` Schema apps register + migrate.
|
|
11
|
+
// - Worker — consumer side: SELECT FOR UPDATE SKIP LOCKED poll loop,
|
|
12
|
+
// attempt counter, exponential backoff with jitter, per-attempt
|
|
13
|
+
// timeout, graceful shutdown via AbortSignal.
|
|
14
|
+
// - CronExpression + helpers (everyMinute / everyMinutes / hourly /
|
|
15
|
+
// daily / dailyAt / cron) — 5-field cron with UTC-based matching.
|
|
16
|
+
// - Scheduler + schedulerRunsSchema — recurring dispatch with optional
|
|
17
|
+
// onOneServer advisory lock (built on TenantManager.withLock).
|
|
18
|
+
//
|
|
19
|
+
// - failedJobsSchema — the `strav_failed_jobs` dead-letter table.
|
|
20
|
+
// Worker moves terminal failures here atomically (INSERT + DELETE
|
|
21
|
+
// in one tx).
|
|
22
|
+
//
|
|
23
|
+
// Still deferred (waiting on @strav/cli — M4):
|
|
24
|
+
// - queue:retry / queue:flush console commands (re-enqueue / drop
|
|
25
|
+
// failed rows in bulk).
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
CronExpression,
|
|
29
|
+
cron,
|
|
30
|
+
daily,
|
|
31
|
+
dailyAt,
|
|
32
|
+
everyMinute,
|
|
33
|
+
everyMinutes,
|
|
34
|
+
hourly,
|
|
35
|
+
} from './cron.ts'
|
|
36
|
+
export { DatabaseQueue, type DatabaseQueueOptions } from './database_queue.ts'
|
|
37
|
+
export { failedJobsSchema } from './failed_jobs_schema.ts'
|
|
38
|
+
export {
|
|
39
|
+
Job,
|
|
40
|
+
type JobClass,
|
|
41
|
+
type JobConfig,
|
|
42
|
+
type JobContext,
|
|
43
|
+
type JobFailedContext,
|
|
44
|
+
type PayloadOf,
|
|
45
|
+
} from './job.ts'
|
|
46
|
+
export { isJobClass, JobRegistry } from './job_registry.ts'
|
|
47
|
+
export { jobSchema } from './job_schema.ts'
|
|
48
|
+
export type { DispatchLaterOptions, DispatchOptions, Queue } from './queue.ts'
|
|
49
|
+
export { type ScheduleOptions, Scheduler, type SchedulerOptions } from './scheduler.ts'
|
|
50
|
+
export { schedulerRunsSchema } from './scheduler_runs_schema.ts'
|
|
51
|
+
export { SyncQueue, type SyncQueueOptions } from './sync_queue.ts'
|
|
52
|
+
export { type JobResult, Worker, type WorkerOptions } from './worker.ts'
|
package/src/job.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Job` — the unit of background work.
|
|
3
|
+
*
|
|
4
|
+
* Subclasses declare a stable `static jobName` (the wire identifier the
|
|
5
|
+
* `JobRegistry` uses to route a serialized payload back to a class) and
|
|
6
|
+
* implement `handle(ctx)`. The Worker constructs jobs through the
|
|
7
|
+
* container so `@inject()`-marked subclasses get their dependencies
|
|
8
|
+
* resolved the usual way; the payload arrives as JSON on the
|
|
9
|
+
* `JobContext`.
|
|
10
|
+
*
|
|
11
|
+
* Configuration overrides (max attempts, backoff, timeout, queue name)
|
|
12
|
+
* are static on the subclass. The Worker reads them per-attempt — see
|
|
13
|
+
* the `JobConfig` interface below.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* @inject()
|
|
17
|
+
* class SendWelcomeEmail extends Job<{ userId: string }> {
|
|
18
|
+
* static override readonly jobName = 'mail.welcome'
|
|
19
|
+
*
|
|
20
|
+
* constructor(
|
|
21
|
+
* private readonly users: UserRepository,
|
|
22
|
+
* private readonly mail: MailManager,
|
|
23
|
+
* ) {
|
|
24
|
+
* super()
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* async handle(ctx: JobContext<{ userId: string }>): Promise<void> {
|
|
28
|
+
* const user = await this.users.findOrFail(ctx.payload.userId)
|
|
29
|
+
* await this.mail.send(new WelcomeEmail(user))
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Logger } from '@strav/kernel'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Per-invocation context passed to `Job.handle(ctx)`. Workers populate
|
|
39
|
+
* every field — testing harnesses can build one by hand.
|
|
40
|
+
*/
|
|
41
|
+
export interface JobContext<TPayload = unknown> {
|
|
42
|
+
/** Stable id assigned at dispatch (typically a ULID). */
|
|
43
|
+
jobId: string
|
|
44
|
+
/** 1-based attempt counter. `attempt === 1` is the first run. */
|
|
45
|
+
attempt: number
|
|
46
|
+
/** The deserialized payload. */
|
|
47
|
+
payload: TPayload
|
|
48
|
+
/**
|
|
49
|
+
* Cancellation signal. Aborted when the Worker's shutdown grace
|
|
50
|
+
* period elapses or the job exceeds its `timeout`. Handlers that
|
|
51
|
+
* loop / stream should check `ctx.signal.aborted` and bail.
|
|
52
|
+
*/
|
|
53
|
+
signal: AbortSignal
|
|
54
|
+
/**
|
|
55
|
+
* Job-scoped logger — prebound with `jobId` + `jobName` + `attempt`.
|
|
56
|
+
* Standard Logger surface (`debug`/`info`/`warn`/`error`).
|
|
57
|
+
*/
|
|
58
|
+
log: Logger
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Optional retry hook context — same shape as `JobContext` plus the
|
|
63
|
+
* error that caused the failure.
|
|
64
|
+
*/
|
|
65
|
+
export interface JobFailedContext<TPayload = unknown> extends JobContext<TPayload> {
|
|
66
|
+
/** The thrown value from the failed attempt. Already wrapped by the kernel error stack. */
|
|
67
|
+
error: unknown
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `static` config shape a Job subclass may set. Every field is
|
|
72
|
+
* optional — the Worker falls back to driver defaults when omitted.
|
|
73
|
+
*/
|
|
74
|
+
export interface JobConfig {
|
|
75
|
+
/** Total attempts (including the first). Default: driver-configured, typically 3. */
|
|
76
|
+
maxAttempts?: number
|
|
77
|
+
/**
|
|
78
|
+
* Backoff in seconds for `attempt` (1-based). The Worker calls this
|
|
79
|
+
* AFTER a failed attempt to schedule the next try. Default: exponential
|
|
80
|
+
* with jitter (driver-configured).
|
|
81
|
+
*/
|
|
82
|
+
backoff?(attempt: number): number
|
|
83
|
+
/** Per-attempt time limit (seconds). Default: driver-configured. */
|
|
84
|
+
timeout?: number
|
|
85
|
+
/** Named queue to dispatch onto. Default: `'default'`. */
|
|
86
|
+
queue?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The abstract Job base class. Subclasses implement `handle` and set
|
|
91
|
+
* `static jobName`. Other static fields are optional configuration —
|
|
92
|
+
* see {@link JobConfig}.
|
|
93
|
+
*
|
|
94
|
+
* Generic parameter `TPayload` is the shape the dispatcher serializes
|
|
95
|
+
* and the handler deserializes. Workers JSON.stringify/parse it
|
|
96
|
+
* verbatim — keep it serializable (no Dates without a custom revival
|
|
97
|
+
* step, no class instances).
|
|
98
|
+
*/
|
|
99
|
+
export abstract class Job<TPayload = unknown> {
|
|
100
|
+
/**
|
|
101
|
+
* Stable wire identifier mapping this class in the JobRegistry.
|
|
102
|
+
* Subclasses MUST override. Convention: `<package>.<verb>` or
|
|
103
|
+
* `<resource>.<verb>` (`mail.welcome`, `user.cleanup`).
|
|
104
|
+
*
|
|
105
|
+
* The base class declares an empty string so type-checking holds;
|
|
106
|
+
* `JobRegistry.register()` rejects classes that didn't override.
|
|
107
|
+
*/
|
|
108
|
+
static readonly jobName: string = ''
|
|
109
|
+
|
|
110
|
+
// Optional static configuration. Declared on the base so subclasses
|
|
111
|
+
// can shadow with `static override readonly <field> = <value>` under
|
|
112
|
+
// `noImplicitOverride`. The Worker reads these per-attempt; omitted
|
|
113
|
+
// fields fall back to driver defaults. Mirrors the {@link JobConfig}
|
|
114
|
+
// interface — the interface drives `JobClass`'s typing, the static
|
|
115
|
+
// fields here drive the override-friendly inheritance shape.
|
|
116
|
+
static readonly maxAttempts?: number
|
|
117
|
+
static readonly backoff?: (attempt: number) => number
|
|
118
|
+
static readonly timeout?: number
|
|
119
|
+
static readonly queue?: string
|
|
120
|
+
|
|
121
|
+
/** The handler. Workers call this once they've constructed the job + built the context. */
|
|
122
|
+
abstract handle(context: JobContext<TPayload>): Promise<void>
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Optional failure hook. Fires when an attempt throws AND there are
|
|
126
|
+
* still retries left; also fires once on the FINAL failure (so apps
|
|
127
|
+
* can route to a dead-letter, post a Slack message, etc.). The Worker
|
|
128
|
+
* runs this in a try/catch — a throw from `failed()` is logged but
|
|
129
|
+
* doesn't change the retry decision.
|
|
130
|
+
*/
|
|
131
|
+
failed?(context: JobFailedContext<TPayload>): Promise<void>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Constructor reference for a Job subclass. The Worker / Queue store
|
|
136
|
+
* these in the registry, look them up by `jobName`, and instantiate
|
|
137
|
+
* via the container.
|
|
138
|
+
*/
|
|
139
|
+
export interface JobClass<TPayload = unknown> extends JobConfig {
|
|
140
|
+
// biome-ignore lint/suspicious/noExplicitAny: ctor params are decided per-subclass; the @inject() flow resolves them.
|
|
141
|
+
new (...args: any[]): Job<TPayload>
|
|
142
|
+
readonly jobName: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Helper type: extract the payload type from a `JobClass`.
|
|
147
|
+
*
|
|
148
|
+
* ```ts
|
|
149
|
+
* type WelcomePayload = PayloadOf<typeof SendWelcomeEmail>
|
|
150
|
+
* // → { userId: string }
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export type PayloadOf<T> = T extends JobClass<infer P> ? P : never
|
|
@@ -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
|
+
}
|