bunderstack 0.15.1 → 0.16.0
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 +7 -7
- package/package.json +2 -1
- package/src/access.ts +18 -0
- package/src/blueprint-generator.ts +63 -17
- package/src/blueprint.ts +123 -30
- package/src/cli.ts +10 -2
- package/src/config.ts +22 -44
- package/src/cron.ts +2 -1
- package/src/crud.ts +28 -14
- package/src/env.ts +17 -9
- package/src/handler.ts +5 -5
- package/src/index.ts +86 -86
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -28
- package/src/jobs/define.ts +68 -12
- package/src/jobs/index.ts +7 -9
- package/src/jobs/queue.ts +10 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +145 -45
- package/src/list-query.ts +2 -4
- package/src/manifest.ts +65 -21
- package/src/realtime/index.ts +3 -11
- package/src/realtime/redis.ts +3 -12
- package/src/routes.ts +137 -0
- package/src/storage/buckets.ts +2 -1
- package/src/storage/file-meta.ts +1 -1
- package/src/storage/router.ts +2 -6
- package/src/storage/s3.ts +9 -3
- package/src/trpc.ts +1 -1
- package/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -131
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
package/src/jobs/define.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type { StorageFacade } from '../index'
|
|
|
9
9
|
|
|
10
10
|
import { parseCron } from './cron'
|
|
11
11
|
|
|
12
|
+
import { CRON_PREFIX, type CatchUp } from './slots'
|
|
13
|
+
|
|
12
14
|
export const DEFAULT_RETRIES = 3
|
|
13
15
|
export const DEFAULT_TIMEOUT_MS = 60_000
|
|
14
16
|
|
|
@@ -21,6 +23,15 @@ export type EnqueueOptions = {
|
|
|
21
23
|
runAt?: Date | number
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
export type TickResult = {
|
|
27
|
+
/** Rows moved from pending to running this tick. */
|
|
28
|
+
claimed: number
|
|
29
|
+
/** Handlers that completed successfully. */
|
|
30
|
+
ran: number
|
|
31
|
+
/** Handlers that threw, whether or not they will be retried. */
|
|
32
|
+
failed: number
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
/**
|
|
25
36
|
* The untyped runtime facade. Handler ctx and tRPC ctx expose this shape;
|
|
26
37
|
* `app.jobs` narrows `enqueue` to the declared job names/payloads.
|
|
@@ -32,7 +43,7 @@ export type JobsRuntimeFacade = {
|
|
|
32
43
|
opts?: EnqueueOptions,
|
|
33
44
|
): Promise<{ id: string }>
|
|
34
45
|
/** Run one poll cycle deterministically (tests). `now` defaults to Date.now(). */
|
|
35
|
-
tick(now?: number): Promise<
|
|
46
|
+
tick(now?: number): Promise<TickResult>
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
import type { RealtimeFacade } from '../realtime/facade'
|
|
@@ -66,7 +77,7 @@ export type QueueJobDefinition<
|
|
|
66
77
|
retries?: number
|
|
67
78
|
/** Delay before retry N (1-based). Default exponential: 1s, 2s, 4s, … */
|
|
68
79
|
backoff?: ((attempt: number) => number) | { baseMs?: number; factor?: number }
|
|
69
|
-
/** Max simultaneous `running` rows of this type, enforced
|
|
80
|
+
/** Max simultaneous `running` rows of this type, enforced per worker. */
|
|
70
81
|
concurrency?: number
|
|
71
82
|
/** Lease duration in ms; an expired lease sends the job back to pending. */
|
|
72
83
|
timeout?: number
|
|
@@ -91,10 +102,26 @@ export type CronDefinition<
|
|
|
91
102
|
> = {
|
|
92
103
|
kind: 'cron'
|
|
93
104
|
schedule: TSchedule
|
|
105
|
+
/** Attempts after the first failure. Default 3 (so 4 total attempts). */
|
|
106
|
+
retries?: number
|
|
107
|
+
/** Delay before retry N (1-based). Default exponential: 1s, 2s, 4s, … */
|
|
108
|
+
backoff?: ((attempt: number) => number) | { baseMs?: number; factor?: number }
|
|
109
|
+
/** Lease duration in ms; an expired lease sends the slot back to pending. */
|
|
110
|
+
timeout?: number
|
|
111
|
+
/** How missed slots are handled on wake. Default 'latest'. */
|
|
112
|
+
catchUp?: CatchUp
|
|
113
|
+
/** How far back catch-up looks, in ms. Default 1 hour. */
|
|
114
|
+
catchUpWindow?: number
|
|
94
115
|
handler: (
|
|
95
116
|
invocation: CronInvocation,
|
|
96
117
|
ctx: JobContext<TSchema, TEnvResult>,
|
|
97
118
|
) => Promise<void> | void
|
|
119
|
+
/** Fires once, after the final attempt fails. Errors here are logged, never retried. */
|
|
120
|
+
onFailed?: (
|
|
121
|
+
invocation: CronInvocation,
|
|
122
|
+
error: Error,
|
|
123
|
+
ctx: JobContext<TSchema, TEnvResult>,
|
|
124
|
+
) => Promise<void> | void
|
|
98
125
|
}
|
|
99
126
|
|
|
100
127
|
export type BackgroundDefinition =
|
|
@@ -111,6 +138,10 @@ export type JobDefinition<
|
|
|
111
138
|
|
|
112
139
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
113
140
|
export type AnyJobDefinition = QueueJobDefinition<any, any, any>
|
|
141
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
142
|
+
export type AnyBackgroundDefinition =
|
|
143
|
+
| QueueJobDefinition<any, any, any>
|
|
144
|
+
| CronDefinition<any, any, any>
|
|
114
145
|
export type JobsDefs = BackgroundDefs
|
|
115
146
|
|
|
116
147
|
export type QueueJobKeys<TDefs extends BackgroundDefs> = {
|
|
@@ -129,18 +160,38 @@ export function validateBackgroundDefs(defs: BackgroundDefs): void {
|
|
|
129
160
|
if (typeof def.handler !== 'function') {
|
|
130
161
|
throw new Error(`[bunderstack] background task "${name}" has no handler`)
|
|
131
162
|
}
|
|
132
|
-
if (def.kind === '
|
|
133
|
-
|
|
134
|
-
|
|
163
|
+
if (def.kind === 'job' && name.startsWith(CRON_PREFIX)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`[bunderstack] job "${name}": the "${CRON_PREFIX}" prefix is reserved for cron tasks`,
|
|
166
|
+
)
|
|
135
167
|
}
|
|
136
168
|
if (
|
|
137
169
|
def.retries !== undefined &&
|
|
138
170
|
(def.retries < 0 || !Number.isInteger(def.retries))
|
|
139
171
|
) {
|
|
140
172
|
throw new Error(
|
|
141
|
-
`[bunderstack]
|
|
173
|
+
`[bunderstack] background task "${name}": retries must be a non-negative integer`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
if (def.timeout !== undefined && def.timeout <= 0) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`[bunderstack] background task "${name}": timeout must be positive`,
|
|
142
179
|
)
|
|
143
180
|
}
|
|
181
|
+
if (def.kind === 'cron') {
|
|
182
|
+
parseCron(def.schedule)
|
|
183
|
+
if ((def as { concurrency?: number }).concurrency !== undefined) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`[bunderstack] cron "${name}": concurrency is not supported for cron tasks — slots are already unique`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
if (def.catchUpWindow !== undefined && def.catchUpWindow <= 0) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`[bunderstack] cron "${name}": catchUpWindow must be positive`,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
144
195
|
if (
|
|
145
196
|
def.concurrency !== undefined &&
|
|
146
197
|
(def.concurrency < 1 || !Number.isInteger(def.concurrency))
|
|
@@ -149,22 +200,27 @@ export function validateBackgroundDefs(defs: BackgroundDefs): void {
|
|
|
149
200
|
`[bunderstack] job "${name}": concurrency must be a positive integer`,
|
|
150
201
|
)
|
|
151
202
|
}
|
|
152
|
-
if (def.timeout !== undefined && def.timeout <= 0) {
|
|
153
|
-
throw new Error(`[bunderstack] job "${name}": timeout must be positive`)
|
|
154
|
-
}
|
|
155
203
|
}
|
|
156
204
|
}
|
|
157
205
|
|
|
158
206
|
/** @deprecated Use validateBackgroundDefs. */
|
|
159
207
|
export const validateJobsDefs = validateBackgroundDefs
|
|
160
208
|
|
|
161
|
-
/**
|
|
162
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Delay in ms before retry `attempt` (1-based = the attempt that just failed).
|
|
211
|
+
* Jittered by ±20% so a shared outage does not retry every job in lockstep.
|
|
212
|
+
* A caller-supplied backoff function is returned verbatim — the caller owns it.
|
|
213
|
+
*/
|
|
214
|
+
export function backoffMs(
|
|
215
|
+
def: AnyBackgroundDefinition,
|
|
216
|
+
attempt: number,
|
|
217
|
+
): number {
|
|
163
218
|
const b = def.backoff
|
|
164
219
|
if (typeof b === 'function') return b(attempt)
|
|
165
220
|
const baseMs = b?.baseMs ?? 1000
|
|
166
221
|
const factor = b?.factor ?? 2
|
|
167
|
-
|
|
222
|
+
const flat = baseMs * factor ** (attempt - 1)
|
|
223
|
+
return Math.round(flat * (0.8 + Math.random() * 0.4))
|
|
168
224
|
}
|
|
169
225
|
|
|
170
226
|
/**
|
package/src/jobs/index.ts
CHANGED
|
@@ -25,15 +25,13 @@ export type {
|
|
|
25
25
|
} from './define'
|
|
26
26
|
export { enqueueJob } from './queue'
|
|
27
27
|
export { createJobRunner } from './worker'
|
|
28
|
-
export { runCronSlot, runScheduledSlot } from './cron-runner'
|
|
29
|
-
export type { CronRunResult } from './cron-runner'
|
|
30
|
-
export { buildCronRouter } from './cron-router'
|
|
31
|
-
export { signScheduleRequest, verifyScheduleRequest } from './cron-auth'
|
|
32
28
|
export { startJobWorker } from './runtime'
|
|
33
|
-
export type { StartWorkerOptions, RunWorkerOptions, WorkerHandle } from './runtime'
|
|
34
|
-
export { startLocalCronScheduler } from './local-cron'
|
|
35
29
|
export type {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
StartWorkerOptions,
|
|
31
|
+
RunWorkerOptions,
|
|
32
|
+
WorkerHandle,
|
|
33
|
+
} from './runtime'
|
|
39
34
|
export { parseCron, cronMatches } from './cron'
|
|
35
|
+
export { slotsDue, floorSlot, CRON_PREFIX, SLOT_MS } from './slots'
|
|
36
|
+
export type { CatchUp } from './slots'
|
|
37
|
+
export type { TickResult } from './define'
|
package/src/jobs/queue.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type { EnqueueOptions, JobsDefs } from './define'
|
|
|
7
7
|
import { jobsTableFor } from '../internal-tables'
|
|
8
8
|
import { generate } from '../typeid'
|
|
9
9
|
|
|
10
|
+
import { CRON_PREFIX } from './slots'
|
|
11
|
+
|
|
10
12
|
export async function enqueueJob(
|
|
11
13
|
db: AnyDb,
|
|
12
14
|
defs: JobsDefs,
|
|
@@ -15,11 +17,13 @@ export async function enqueueJob(
|
|
|
15
17
|
opts: EnqueueOptions = {},
|
|
16
18
|
): Promise<{ id: string }> {
|
|
17
19
|
const def = defs[name]
|
|
18
|
-
if (!def
|
|
19
|
-
throw new Error(`[bunderstack] unknown
|
|
20
|
+
if (!def) {
|
|
21
|
+
throw new Error(`[bunderstack] unknown background task "${name}"`)
|
|
20
22
|
}
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
+
const isCron = def.kind === 'cron'
|
|
24
|
+
const type = isCron ? `${CRON_PREFIX}${name}` : name
|
|
25
|
+
// Cron slots carry no payload; queue jobs validate theirs at the call site.
|
|
26
|
+
const parsed = isCron ? null : def.input ? def.input.parse(input) : null
|
|
23
27
|
const t = jobsTableFor(db)
|
|
24
28
|
const now = Date.now()
|
|
25
29
|
const runAt =
|
|
@@ -35,7 +39,7 @@ export async function enqueueJob(
|
|
|
35
39
|
.insert(t)
|
|
36
40
|
.values({
|
|
37
41
|
id,
|
|
38
|
-
type
|
|
42
|
+
type,
|
|
39
43
|
payloadJson: JSON.stringify(parsed),
|
|
40
44
|
status: 'pending',
|
|
41
45
|
attempts: 0,
|
|
@@ -49,7 +53,7 @@ export async function enqueueJob(
|
|
|
49
53
|
const existing = await db
|
|
50
54
|
.select({ id: t.id })
|
|
51
55
|
.from(t)
|
|
52
|
-
.where(and(eq(t.type,
|
|
56
|
+
.where(and(eq(t.type, type), eq(t.dedupeKey, opts.dedupeKey ?? '')))
|
|
53
57
|
.limit(1)
|
|
54
58
|
if (existing[0]) return { id: String(existing[0].id) }
|
|
55
59
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/jobs/slots.ts — cron slot enumeration. Pure; no db, no clock reads.
|
|
2
|
+
import { cronMatches, type ParsedCron } from './cron'
|
|
3
|
+
|
|
4
|
+
/** Slot granularity. Every slot timestamp satisfies `slot % SLOT_MS === 0`. */
|
|
5
|
+
export const SLOT_MS = 60_000
|
|
6
|
+
|
|
7
|
+
/** Reserved job-type prefix for cron occurrences. */
|
|
8
|
+
export const CRON_PREFIX = 'cron:'
|
|
9
|
+
|
|
10
|
+
/** How far back either catch-up mode will look. */
|
|
11
|
+
export const DEFAULT_CATCH_UP_WINDOW_MS = 60 * SLOT_MS
|
|
12
|
+
|
|
13
|
+
export type CatchUp = 'latest' | 'all'
|
|
14
|
+
|
|
15
|
+
/** Aligns `ms` down to its containing slot. */
|
|
16
|
+
export function floorSlot(ms: number): number {
|
|
17
|
+
return Math.floor(ms / SLOT_MS) * SLOT_MS
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Slots matching `cron` in the half-open range `(from, to]`, oldest first.
|
|
22
|
+
*
|
|
23
|
+
* `from` is exclusive so a stored watermark is never re-emitted. Both modes are
|
|
24
|
+
* clamped to `catchUpWindowMs` — without it a watermark far in the past would
|
|
25
|
+
* make this iterate unbounded minutes.
|
|
26
|
+
*/
|
|
27
|
+
export function slotsDue(args: {
|
|
28
|
+
cron: ParsedCron
|
|
29
|
+
from: number
|
|
30
|
+
to: number
|
|
31
|
+
catchUp?: CatchUp
|
|
32
|
+
catchUpWindowMs?: number
|
|
33
|
+
}): number[] {
|
|
34
|
+
const catchUp = args.catchUp ?? 'latest'
|
|
35
|
+
const windowMs = args.catchUpWindowMs ?? DEFAULT_CATCH_UP_WINDOW_MS
|
|
36
|
+
const to = floorSlot(args.to)
|
|
37
|
+
const from = Math.max(floorSlot(args.from), to - windowMs)
|
|
38
|
+
if (to <= from) return []
|
|
39
|
+
|
|
40
|
+
if (catchUp === 'latest') {
|
|
41
|
+
for (let s = to; s > from; s -= SLOT_MS) {
|
|
42
|
+
if (cronMatches(args.cron, s)) return [s]
|
|
43
|
+
}
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const slots: number[] = []
|
|
48
|
+
for (let s = from + SLOT_MS; s <= to; s += SLOT_MS) {
|
|
49
|
+
if (cronMatches(args.cron, s)) slots.push(s)
|
|
50
|
+
}
|
|
51
|
+
return slots
|
|
52
|
+
}
|
package/src/jobs/worker.ts
CHANGED
|
@@ -1,36 +1,56 @@
|
|
|
1
1
|
// src/jobs/worker.ts — the queue worker. One `tick()` is a full cycle:
|
|
2
2
|
// recover expired leases → reap old succeeded rows → claim and run queue jobs.
|
|
3
|
-
import { and, eq, inArray, is, isNotNull, lt, lte, sql } from 'drizzle-orm'
|
|
3
|
+
import { and, eq, inArray, is, isNotNull, lt, lte, max, sql } from 'drizzle-orm'
|
|
4
4
|
import { PgDatabase } from 'drizzle-orm/pg-core'
|
|
5
5
|
|
|
6
6
|
import type { AnyDb } from '../dialect'
|
|
7
7
|
import type {
|
|
8
|
-
|
|
8
|
+
AnyBackgroundDefinition,
|
|
9
9
|
JobsDefs,
|
|
10
10
|
JobsRuntimeFacade,
|
|
11
|
+
TickResult,
|
|
11
12
|
} from './define'
|
|
12
13
|
|
|
13
14
|
import { jobsTableFor } from '../internal-tables'
|
|
15
|
+
import { parseCron } from './cron'
|
|
14
16
|
import { backoffMs, DEFAULT_RETRIES, DEFAULT_TIMEOUT_MS } from './define'
|
|
17
|
+
import { enqueueJob } from './queue'
|
|
18
|
+
import { CRON_PREFIX, floorSlot, slotsDue, SLOT_MS } from './slots'
|
|
15
19
|
|
|
16
20
|
const CLAIM_BATCH = 10
|
|
17
21
|
const SUCCEEDED_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
22
|
+
const REAP_INTERVAL_MS = 60 * 60_000
|
|
18
23
|
|
|
19
24
|
type JobRow = {
|
|
20
25
|
id: string
|
|
21
26
|
type: string
|
|
22
27
|
payloadJson: string
|
|
23
28
|
attempts: number
|
|
29
|
+
runAt: number
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
function toError(err: unknown): Error {
|
|
27
33
|
return err instanceof Error ? err : new Error(String(err))
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
function maxAttempts(def:
|
|
36
|
+
function maxAttempts(def: AnyBackgroundDefinition): number {
|
|
31
37
|
return 1 + (def.retries ?? DEFAULT_RETRIES)
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
/** Resolve a stored row type back to its definition. Cron rows carry the
|
|
41
|
+
* reserved prefix; queue rows use the definition key verbatim. */
|
|
42
|
+
function definitionFor(
|
|
43
|
+
defs: JobsDefs,
|
|
44
|
+
type: string,
|
|
45
|
+
): AnyBackgroundDefinition | undefined {
|
|
46
|
+
if (type.startsWith(CRON_PREFIX)) {
|
|
47
|
+
const def = defs[type.slice(CRON_PREFIX.length)]
|
|
48
|
+
return def?.kind === 'cron' ? def : undefined
|
|
49
|
+
}
|
|
50
|
+
const def = defs[type]
|
|
51
|
+
return def?.kind === 'job' ? def : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
/** Terminal queue rows release their dedupe key. */
|
|
35
55
|
function terminalPatch() {
|
|
36
56
|
return { dedupeKey: null }
|
|
@@ -45,21 +65,66 @@ export function createJobRunner(deps: {
|
|
|
45
65
|
const { db, defs } = deps
|
|
46
66
|
const t = jobsTableFor(db)
|
|
47
67
|
const ctx = { ...deps.ctx } as Record<string, unknown>
|
|
68
|
+
let lastReapAt = 0
|
|
69
|
+
|
|
70
|
+
/** Cron rows carry no payload — their handler input is the slot itself. */
|
|
71
|
+
function resolveInput(def: AnyBackgroundDefinition, row: JobRow): unknown {
|
|
72
|
+
if (def.kind === 'cron') {
|
|
73
|
+
return { scheduledFor: new Date(Number(row.runAt)) }
|
|
74
|
+
}
|
|
75
|
+
const raw = JSON.parse(row.payloadJson)
|
|
76
|
+
return def.input ? def.input.parse(raw) : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The watermark is the newest slot we already stored for this cron. When no
|
|
81
|
+
* rows exist — a newly declared cron, or one whose rows were reaped — anchor
|
|
82
|
+
* one slot before now so the current minute is eligible and nothing older is.
|
|
83
|
+
*/
|
|
84
|
+
async function cronWatermark(type: string, now: number): Promise<number> {
|
|
85
|
+
const rows = await db
|
|
86
|
+
.select({ latest: max(t.runAt) })
|
|
87
|
+
.from(t)
|
|
88
|
+
.where(eq(t.type, type))
|
|
89
|
+
const latest = rows[0]?.latest
|
|
90
|
+
return latest == null ? floorSlot(now) - SLOT_MS : Number(latest)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Enqueue a row per due slot. The unique(type, dedupeKey) constraint makes
|
|
94
|
+
* this safe to run concurrently in any number of processes. */
|
|
95
|
+
async function materializeCronSlots(now: number) {
|
|
96
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
97
|
+
if (def.kind !== 'cron') continue
|
|
98
|
+
const type = `${CRON_PREFIX}${name}`
|
|
99
|
+
const from = await cronWatermark(type, now)
|
|
100
|
+
const slots = slotsDue({
|
|
101
|
+
cron: parseCron(def.schedule),
|
|
102
|
+
from,
|
|
103
|
+
to: now,
|
|
104
|
+
catchUp: def.catchUp,
|
|
105
|
+
catchUpWindowMs: def.catchUpWindow,
|
|
106
|
+
})
|
|
107
|
+
for (const slot of slots) {
|
|
108
|
+
await enqueueJob(db, defs, name, null, {
|
|
109
|
+
runAt: slot,
|
|
110
|
+
dedupeKey: String(slot),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
48
116
|
async function fireOnFailed(
|
|
49
|
-
def:
|
|
50
|
-
|
|
117
|
+
def: AnyBackgroundDefinition,
|
|
118
|
+
input: unknown,
|
|
51
119
|
error: Error,
|
|
52
120
|
) {
|
|
53
121
|
if (!def.onFailed) return
|
|
54
|
-
let input: unknown
|
|
55
|
-
try {
|
|
56
|
-
const raw = JSON.parse(payloadJson)
|
|
57
|
-
input = def.input ? def.input.parse(raw) : undefined
|
|
58
|
-
} catch {
|
|
59
|
-
input = undefined // payload unusable; the hook still gets the error
|
|
60
|
-
}
|
|
61
122
|
try {
|
|
62
|
-
await def.onFailed(
|
|
123
|
+
await (def.onFailed as (i: unknown, e: Error, c: unknown) => unknown)(
|
|
124
|
+
input,
|
|
125
|
+
error,
|
|
126
|
+
ctx,
|
|
127
|
+
)
|
|
63
128
|
} catch (hookErr) {
|
|
64
129
|
console.error('[bunderstack] onFailed hook threw:', hookErr)
|
|
65
130
|
}
|
|
@@ -74,15 +139,20 @@ export function createJobRunner(deps: {
|
|
|
74
139
|
payloadJson: t.payloadJson,
|
|
75
140
|
attempts: t.attempts,
|
|
76
141
|
lastError: t.lastError,
|
|
142
|
+
runAt: t.runAt,
|
|
77
143
|
})
|
|
78
144
|
.from(t)
|
|
79
145
|
.where(
|
|
80
|
-
and(
|
|
146
|
+
and(
|
|
147
|
+
eq(t.status, 'running'),
|
|
148
|
+
isNotNull(t.lockedUntil),
|
|
149
|
+
lt(t.lockedUntil, now),
|
|
150
|
+
),
|
|
81
151
|
)
|
|
82
152
|
for (const row of expired) {
|
|
83
|
-
const def = defs
|
|
153
|
+
const def = definitionFor(defs, row.type)
|
|
84
154
|
const error = new Error('lease expired (worker crashed or timed out)')
|
|
85
|
-
if (!def
|
|
155
|
+
if (!def) {
|
|
86
156
|
await db
|
|
87
157
|
.update(t)
|
|
88
158
|
.set({
|
|
@@ -106,7 +176,7 @@ export function createJobRunner(deps: {
|
|
|
106
176
|
...terminalPatch(),
|
|
107
177
|
})
|
|
108
178
|
.where(eq(t.id, row.id))
|
|
109
|
-
await fireOnFailed(def, row
|
|
179
|
+
await fireOnFailed(def, resolveInput(def, row), error)
|
|
110
180
|
} else {
|
|
111
181
|
await db
|
|
112
182
|
.update(t)
|
|
@@ -148,8 +218,11 @@ export function createJobRunner(deps: {
|
|
|
148
218
|
// PG: lock the selected rows so concurrent replicas skip them. SQLite's
|
|
149
219
|
// single-writer model makes the one-statement UPDATE atomic on its own.
|
|
150
220
|
const sub = is(db, PgDatabase)
|
|
151
|
-
? (
|
|
152
|
-
|
|
221
|
+
? (
|
|
222
|
+
pendingIds as unknown as {
|
|
223
|
+
for: (m: string, o: object) => typeof pendingIds
|
|
224
|
+
}
|
|
225
|
+
).for('update', { skipLocked: true })
|
|
153
226
|
: pendingIds
|
|
154
227
|
const rows: JobRow[] = await db
|
|
155
228
|
.update(t)
|
|
@@ -164,6 +237,7 @@ export function createJobRunner(deps: {
|
|
|
164
237
|
type: t.type,
|
|
165
238
|
payloadJson: t.payloadJson,
|
|
166
239
|
attempts: t.attempts,
|
|
240
|
+
runAt: t.runAt,
|
|
167
241
|
})
|
|
168
242
|
return rows
|
|
169
243
|
}
|
|
@@ -171,15 +245,19 @@ export function createJobRunner(deps: {
|
|
|
171
245
|
// `now` is the tick's injected clock: retry runAt math uses it so tests can
|
|
172
246
|
// drive backoff deterministically. finishedAt uses the real clock (a handler
|
|
173
247
|
// may run long past the tick's start).
|
|
174
|
-
async function runJob(
|
|
248
|
+
async function runJob(
|
|
249
|
+
row: JobRow,
|
|
250
|
+
def: AnyBackgroundDefinition,
|
|
251
|
+
now: number,
|
|
252
|
+
leaseUntil: number,
|
|
253
|
+
): Promise<'ran' | 'failed' | 'lost'> {
|
|
175
254
|
let input: unknown
|
|
176
255
|
try {
|
|
177
|
-
|
|
178
|
-
input = def.input ? def.input.parse(raw) : undefined
|
|
256
|
+
input = resolveInput(def, row)
|
|
179
257
|
} catch (err) {
|
|
180
258
|
// Stored payload no longer parses (schema drift): retrying can't help.
|
|
181
259
|
const e = toError(err)
|
|
182
|
-
await db
|
|
260
|
+
const updated = await db
|
|
183
261
|
.update(t)
|
|
184
262
|
.set({
|
|
185
263
|
status: 'failed',
|
|
@@ -188,13 +266,15 @@ export function createJobRunner(deps: {
|
|
|
188
266
|
lastError: e.message,
|
|
189
267
|
...terminalPatch(),
|
|
190
268
|
})
|
|
191
|
-
.where(eq(t.id, row.id))
|
|
192
|
-
|
|
193
|
-
return
|
|
269
|
+
.where(and(eq(t.id, row.id), eq(t.lockedUntil, leaseUntil)))
|
|
270
|
+
.returning({ id: t.id })
|
|
271
|
+
if (!updated[0]) return 'lost'
|
|
272
|
+
await fireOnFailed(def, undefined, e)
|
|
273
|
+
return 'failed'
|
|
194
274
|
}
|
|
195
275
|
try {
|
|
196
|
-
await def.handler(
|
|
197
|
-
await db
|
|
276
|
+
await (def.handler as (i: unknown, c: unknown) => unknown)(input, ctx)
|
|
277
|
+
const updated = await db
|
|
198
278
|
.update(t)
|
|
199
279
|
.set({
|
|
200
280
|
status: 'succeeded',
|
|
@@ -202,11 +282,14 @@ export function createJobRunner(deps: {
|
|
|
202
282
|
lockedUntil: null,
|
|
203
283
|
...terminalPatch(),
|
|
204
284
|
})
|
|
205
|
-
.where(eq(t.id, row.id))
|
|
285
|
+
.where(and(eq(t.id, row.id), eq(t.lockedUntil, leaseUntil)))
|
|
286
|
+
.returning({ id: t.id })
|
|
287
|
+
if (!updated[0]) return 'lost'
|
|
288
|
+
return 'ran'
|
|
206
289
|
} catch (err) {
|
|
207
290
|
const e = toError(err)
|
|
208
291
|
if (Number(row.attempts) < maxAttempts(def)) {
|
|
209
|
-
await db
|
|
292
|
+
const updated = await db
|
|
210
293
|
.update(t)
|
|
211
294
|
.set({
|
|
212
295
|
status: 'pending',
|
|
@@ -214,9 +297,11 @@ export function createJobRunner(deps: {
|
|
|
214
297
|
runAt: now + backoffMs(def, Number(row.attempts)),
|
|
215
298
|
lastError: e.message,
|
|
216
299
|
})
|
|
217
|
-
.where(eq(t.id, row.id))
|
|
300
|
+
.where(and(eq(t.id, row.id), eq(t.lockedUntil, leaseUntil)))
|
|
301
|
+
.returning({ id: t.id })
|
|
302
|
+
if (!updated[0]) return 'lost'
|
|
218
303
|
} else {
|
|
219
|
-
await db
|
|
304
|
+
const updated = await db
|
|
220
305
|
.update(t)
|
|
221
306
|
.set({
|
|
222
307
|
status: 'failed',
|
|
@@ -225,19 +310,22 @@ export function createJobRunner(deps: {
|
|
|
225
310
|
lastError: e.message,
|
|
226
311
|
...terminalPatch(),
|
|
227
312
|
})
|
|
228
|
-
.where(eq(t.id, row.id))
|
|
229
|
-
|
|
313
|
+
.where(and(eq(t.id, row.id), eq(t.lockedUntil, leaseUntil)))
|
|
314
|
+
.returning({ id: t.id })
|
|
315
|
+
if (!updated[0]) return 'lost'
|
|
316
|
+
await fireOnFailed(def, input, e)
|
|
230
317
|
}
|
|
318
|
+
return 'failed'
|
|
231
319
|
}
|
|
232
320
|
}
|
|
233
321
|
|
|
234
|
-
async function runClaimable(now: number) {
|
|
235
|
-
const work: Promise<
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
322
|
+
async function runClaimable(now: number): Promise<TickResult> {
|
|
323
|
+
const work: Promise<'ran' | 'failed' | 'lost'>[] = []
|
|
324
|
+
let totalClaimed = 0
|
|
325
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
326
|
+
const type = def.kind === 'cron' ? `${CRON_PREFIX}${name}` : name
|
|
239
327
|
let limit = CLAIM_BATCH
|
|
240
|
-
if (def.concurrency !== undefined) {
|
|
328
|
+
if (def.kind === 'job' && def.concurrency !== undefined) {
|
|
241
329
|
const runningRows = await db
|
|
242
330
|
.select({ id: t.id })
|
|
243
331
|
.from(t)
|
|
@@ -248,16 +336,28 @@ export function createJobRunner(deps: {
|
|
|
248
336
|
}
|
|
249
337
|
const leaseUntil = now + (def.timeout ?? DEFAULT_TIMEOUT_MS)
|
|
250
338
|
const claimed = await claim(type, limit, now, leaseUntil)
|
|
251
|
-
|
|
339
|
+
totalClaimed += claimed.length
|
|
340
|
+
for (const row of claimed) work.push(runJob(row, def, now, leaseUntil))
|
|
341
|
+
}
|
|
342
|
+
const outcomes = await Promise.all(work)
|
|
343
|
+
let ran = 0
|
|
344
|
+
let failed = 0
|
|
345
|
+
for (const outcome of outcomes) {
|
|
346
|
+
if (outcome === 'ran') ran++
|
|
347
|
+
else if (outcome === 'failed') failed++
|
|
252
348
|
}
|
|
253
|
-
|
|
349
|
+
return { claimed: totalClaimed, ran, failed }
|
|
254
350
|
}
|
|
255
351
|
|
|
256
352
|
return {
|
|
257
|
-
async tick(now: number = Date.now()) {
|
|
353
|
+
async tick(now: number = Date.now()): Promise<TickResult> {
|
|
354
|
+
await materializeCronSlots(now)
|
|
258
355
|
await recoverExpiredLeases(now)
|
|
259
|
-
|
|
260
|
-
|
|
356
|
+
if (now - lastReapAt >= REAP_INTERVAL_MS) {
|
|
357
|
+
lastReapAt = now
|
|
358
|
+
await reapSucceeded(now)
|
|
359
|
+
}
|
|
360
|
+
return runClaimable(now)
|
|
261
361
|
},
|
|
262
362
|
setJobsFacade(f: JobsRuntimeFacade) {
|
|
263
363
|
ctx.jobs = f
|
package/src/list-query.ts
CHANGED
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
} from 'drizzle-orm'
|
|
17
17
|
import { PgTable } from 'drizzle-orm/pg-core'
|
|
18
18
|
|
|
19
|
-
import type { AnyDb } from './dialect'
|
|
20
19
|
import type { ResolvedTableAccess, SortOrder } from './access'
|
|
20
|
+
import type { AnyDb } from './dialect'
|
|
21
21
|
|
|
22
22
|
import { ErrorCode, ListQueryError } from './errors'
|
|
23
23
|
|
|
@@ -282,9 +282,7 @@ export function encodeCursor(payload: CursorPayload): string {
|
|
|
282
282
|
|
|
283
283
|
export function decodeCursor(cursor: string): CursorPayload {
|
|
284
284
|
try {
|
|
285
|
-
const parsed = JSON.parse(
|
|
286
|
-
Buffer.from(cursor, 'base64url').toString('utf8'),
|
|
287
|
-
)
|
|
285
|
+
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
|
|
288
286
|
if (!isCursorPayload(parsed)) {
|
|
289
287
|
throw new Error('invalid cursor shape')
|
|
290
288
|
}
|