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.
@@ -1,224 +0,0 @@
1
- import { and, eq, lt, or, sql } from 'drizzle-orm'
2
-
3
- import type { AnyDb } from '../dialect'
4
- import type { BackgroundDefs } from './define'
5
-
6
- import { cronRunsTableFor } from '../internal-tables'
7
- import { cronMatches, parseCron } from './cron'
8
-
9
- const LEASE_MS = 60_000
10
-
11
- export type CronRunResult =
12
- | { status: 'succeeded' }
13
- | { status: 'duplicate' }
14
- | { status: 'running' }
15
-
16
- export async function runScheduledSlot(args: {
17
- db: AnyDb
18
- taskId: string
19
- schedule: string
20
- slot: number
21
- now: number
22
- run: (scheduledFor: Date) => Promise<void> | void
23
- leaseMs?: number
24
- heartbeatIntervalMs?: number
25
- heartbeatCleanupTimeoutMs?: number
26
- }): Promise<CronRunResult> {
27
- const { db, taskId, schedule, slot, now, run } = args
28
- const leaseMs = args.leaseMs ?? LEASE_MS
29
- const heartbeatIntervalMs =
30
- args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
31
- const heartbeatCleanupTimeoutMs = args.heartbeatCleanupTimeoutMs ?? 1_000
32
-
33
- if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
34
- throw new Error('[bunderstack] cron slot does not match its schedule')
35
- }
36
-
37
- const t = cronRunsTableFor(db)
38
- const leaseUntil = now + leaseMs
39
- const inserted = await db
40
- .insert(t)
41
- .values({
42
- taskId,
43
- scheduledAt: slot,
44
- status: 'running',
45
- attempts: 1,
46
- lockedUntil: leaseUntil,
47
- startedAt: now,
48
- })
49
- .onConflictDoNothing({ target: [t.taskId, t.scheduledAt] })
50
- .returning({ taskId: t.taskId, attempts: t.attempts })
51
-
52
- let ownershipAttempt: number
53
- if (inserted[0]) {
54
- ownershipAttempt = Number(inserted[0].attempts)
55
- } else {
56
- const existing = await db
57
- .select({ status: t.status, lockedUntil: t.lockedUntil })
58
- .from(t)
59
- .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
60
- .limit(1)
61
- const row = existing[0]
62
- if (!row || row.status === 'succeeded') return { status: 'duplicate' }
63
- if (row.status === 'running' && Number(row.lockedUntil) >= now) {
64
- return { status: 'running' }
65
- }
66
- const reclaimed = await db
67
- .update(t)
68
- .set({
69
- status: 'running',
70
- lockedUntil: leaseUntil,
71
- startedAt: now,
72
- attempts: sql`${t.attempts} + 1`,
73
- lastError: null,
74
- })
75
- .where(
76
- and(
77
- eq(t.taskId, taskId),
78
- eq(t.scheduledAt, slot),
79
- or(eq(t.status, 'failed'), lt(t.lockedUntil, now)),
80
- ),
81
- )
82
- .returning({ taskId: t.taskId, attempts: t.attempts })
83
- const reclaimedRow = reclaimed[0]
84
- if (!reclaimedRow) return { status: 'running' }
85
- ownershipAttempt = Number(reclaimedRow.attempts)
86
- }
87
-
88
- let heartbeatTimer: Timer | undefined
89
- let heartbeatInFlight: Promise<void> | undefined
90
- let heartbeatStopped = false
91
-
92
- const scheduleHeartbeat = () => {
93
- heartbeatTimer = setTimeout(() => {
94
- heartbeatTimer = undefined
95
- if (heartbeatStopped) return
96
-
97
- heartbeatInFlight = (async () => {
98
- try {
99
- const renewUntil = Date.now() + leaseMs
100
- await db
101
- .update(t)
102
- .set({ lockedUntil: renewUntil })
103
- .where(
104
- and(
105
- eq(t.taskId, taskId),
106
- eq(t.scheduledAt, slot),
107
- eq(t.status, 'running'),
108
- eq(t.startedAt, now),
109
- eq(t.attempts, ownershipAttempt),
110
- ),
111
- )
112
- } catch {
113
- // Best effort renewal
114
- }
115
- })()
116
-
117
- void heartbeatInFlight.finally(() => {
118
- heartbeatInFlight = undefined
119
- if (!heartbeatStopped) scheduleHeartbeat()
120
- })
121
- }, heartbeatIntervalMs)
122
- }
123
-
124
- const stopHeartbeat = async () => {
125
- heartbeatStopped = true
126
- if (heartbeatTimer) {
127
- clearTimeout(heartbeatTimer)
128
- heartbeatTimer = undefined
129
- }
130
- const inFlight = heartbeatInFlight
131
- if (!inFlight) return
132
-
133
- let cleanupTimer: Timer | undefined
134
- try {
135
- await Promise.race([
136
- inFlight,
137
- new Promise<void>((resolve) => {
138
- cleanupTimer = setTimeout(resolve, heartbeatCleanupTimeoutMs)
139
- }),
140
- ])
141
- } finally {
142
- if (cleanupTimer) clearTimeout(cleanupTimer)
143
- }
144
- }
145
-
146
- scheduleHeartbeat()
147
-
148
- try {
149
- await run(new Date(slot))
150
-
151
- const updated = await db
152
- .update(t)
153
- .set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
154
- .where(
155
- and(
156
- eq(t.taskId, taskId),
157
- eq(t.scheduledAt, slot),
158
- eq(t.status, 'running'),
159
- eq(t.startedAt, now),
160
- eq(t.attempts, ownershipAttempt),
161
- ),
162
- )
163
- .returning({ taskId: t.taskId })
164
-
165
- if (!updated[0]) {
166
- throw new Error(
167
- '[bunderstack] cron lease ownership was lost during execution',
168
- )
169
- }
170
-
171
- return { status: 'succeeded' }
172
- } catch (error) {
173
- const message = error instanceof Error ? error.message : String(error)
174
- await db
175
- .update(t)
176
- .set({
177
- status: 'failed',
178
- lockedUntil: null,
179
- lastError: message,
180
- finishedAt: Date.now(),
181
- })
182
- .where(
183
- and(
184
- eq(t.taskId, taskId),
185
- eq(t.scheduledAt, slot),
186
- eq(t.status, 'running'),
187
- eq(t.startedAt, now),
188
- eq(t.attempts, ownershipAttempt),
189
- ),
190
- )
191
- throw error
192
- } finally {
193
- await stopHeartbeat()
194
- }
195
- }
196
-
197
- export async function runCronSlot(args: {
198
- db: AnyDb
199
- defs: BackgroundDefs
200
- ctx: Record<string, unknown>
201
- name: string
202
- slot: number
203
- now: number
204
- leaseMs?: number
205
- heartbeatIntervalMs?: number
206
- heartbeatCleanupTimeoutMs?: number
207
- }): Promise<CronRunResult> {
208
- const definition = args.defs[args.name]
209
- if (!definition || definition.kind !== 'cron') {
210
- throw new Error(`[bunderstack] unknown cron "${args.name}"`)
211
- }
212
- return runScheduledSlot({
213
- db: args.db,
214
- taskId: `cron:${args.name}`,
215
- schedule: definition.schedule,
216
- slot: args.slot,
217
- now: args.now,
218
- leaseMs: args.leaseMs,
219
- heartbeatIntervalMs: args.heartbeatIntervalMs,
220
- heartbeatCleanupTimeoutMs: args.heartbeatCleanupTimeoutMs,
221
- run: (scheduledFor) =>
222
- definition.handler({ scheduledFor }, args.ctx as never),
223
- })
224
- }
@@ -1,78 +0,0 @@
1
- import { cronMatches, parseCron } from './cron'
2
-
3
- type Timer = ReturnType<typeof setTimeout> | number
4
-
5
- export type LocalCronDefinition = {
6
- name: string
7
- schedule: string
8
- }
9
-
10
- export type LocalCronSchedulerOptions = {
11
- cron: readonly LocalCronDefinition[]
12
- runSlot: (name: string, scheduledFor: number) => Promise<void>
13
- now?: () => number
14
- setTimer?: (callback: () => void, delayMs: number) => Timer
15
- clearTimer?: (timer: Timer) => void
16
- onError?: (error: Error) => void
17
- }
18
-
19
- export type LocalCronScheduler = {
20
- tick: () => Promise<void>
21
- close: () => Promise<void>
22
- }
23
-
24
- /**
25
- * Runs declared cron handlers locally. Production schedules should be delivered
26
- * by the hosting platform through the signed cron endpoint instead.
27
- */
28
- export function startLocalCronScheduler(
29
- options: LocalCronSchedulerOptions,
30
- ): LocalCronScheduler {
31
- const cron = options.cron.map((definition) => ({
32
- ...definition,
33
- expression: parseCron(definition.schedule),
34
- }))
35
- const now = options.now ?? Date.now
36
- const setTimer = options.setTimer ?? setTimeout
37
- const clearTimer = options.clearTimer ?? clearTimeout
38
- let timer: Timer | undefined
39
- let closed = false
40
-
41
- const scheduleNextTick = () => {
42
- if (closed || timer) return
43
-
44
- const delay = 60_000 - (now() % 60_000)
45
- timer = setTimer(() => {
46
- timer = undefined
47
- void tick().catch((error: unknown) => {
48
- options.onError?.(
49
- error instanceof Error ? error : new Error(String(error)),
50
- )
51
- })
52
- }, delay)
53
- }
54
-
55
- const tick = async () => {
56
- if (closed) return
57
-
58
- const scheduledFor = Math.floor(now() / 60_000) * 60_000
59
- for (const definition of cron) {
60
- if (cronMatches(definition.expression, scheduledFor)) {
61
- await options.runSlot(definition.name, scheduledFor)
62
- }
63
- }
64
-
65
- scheduleNextTick()
66
- }
67
-
68
- return {
69
- tick,
70
- async close() {
71
- closed = true
72
- if (timer) {
73
- clearTimer(timer)
74
- timer = undefined
75
- }
76
- },
77
- }
78
- }