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.
@@ -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<void>
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 cross-replica. */
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 === 'cron') {
133
- parseCron(def.schedule)
134
- continue
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] job "${name}": retries must be a non-negative integer`,
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
- /** Delay in ms before retry `attempt` (1-based = the attempt that just failed). */
162
- export function backoffMs(def: AnyJobDefinition, attempt: number): number {
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
- return baseMs * factor ** (attempt - 1)
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
- LocalCronScheduler,
37
- LocalCronSchedulerOptions,
38
- } from './local-cron'
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 || def.kind !== 'job') {
19
- throw new Error(`[bunderstack] unknown queue job "${name}"`)
20
+ if (!def) {
21
+ throw new Error(`[bunderstack] unknown background task "${name}"`)
20
22
  }
21
- // Fail fast: a bad payload should throw at the call site, not in the worker.
22
- const parsed = def.input ? def.input.parse(input) : null
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: name,
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, name), eq(t.dedupeKey, opts.dedupeKey ?? '')))
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
+ }
@@ -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
- AnyJobDefinition,
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: AnyJobDefinition): number {
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: AnyJobDefinition,
50
- payloadJson: string,
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(input, error, ctx as never)
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(eq(t.status, 'running'), isNotNull(t.lockedUntil), lt(t.lockedUntil, now)),
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[row.type]
153
+ const def = definitionFor(defs, row.type)
84
154
  const error = new Error('lease expired (worker crashed or timed out)')
85
- if (!def || def.kind !== 'job') {
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.payloadJson, error)
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
- ? (pendingIds as unknown as { for: (m: string, o: object) => typeof pendingIds })
152
- .for('update', { skipLocked: true })
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(row: JobRow, def: AnyJobDefinition, now: number) {
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
- const raw = JSON.parse(row.payloadJson)
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
- await fireOnFailed(def, row.payloadJson, e)
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(input, ctx as never)
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
- await fireOnFailed(def, row.payloadJson, e)
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<void>[] = []
236
- for (const [type, candidate] of Object.entries(defs)) {
237
- if (candidate.kind !== 'job') continue
238
- const def = candidate
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
- for (const row of claimed) work.push(runJob(row, def, now))
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
- await Promise.all(work)
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
- await reapSucceeded(now)
260
- await runClaimable(now)
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
  }