bunderstack 0.15.0 → 0.15.1

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": "bunderstack",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Batteries-included backend framework for Bun: CRUD APIs, auth, file storage, realtime, tRPC, email, and validated env from a single Drizzle schema and config object.",
5
5
  "keywords": [
6
6
  "backend",
@@ -22,11 +22,13 @@ export async function runScheduledSlot(args: {
22
22
  run: (scheduledFor: Date) => Promise<void> | void
23
23
  leaseMs?: number
24
24
  heartbeatIntervalMs?: number
25
+ heartbeatCleanupTimeoutMs?: number
25
26
  }): Promise<CronRunResult> {
26
27
  const { db, taskId, schedule, slot, now, run } = args
27
28
  const leaseMs = args.leaseMs ?? LEASE_MS
28
29
  const heartbeatIntervalMs =
29
30
  args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
31
+ const heartbeatCleanupTimeoutMs = args.heartbeatCleanupTimeoutMs ?? 1_000
30
32
 
31
33
  if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
32
34
  throw new Error('[bunderstack] cron slot does not match its schedule')
@@ -45,9 +47,12 @@ export async function runScheduledSlot(args: {
45
47
  startedAt: now,
46
48
  })
47
49
  .onConflictDoNothing({ target: [t.taskId, t.scheduledAt] })
48
- .returning({ taskId: t.taskId })
50
+ .returning({ taskId: t.taskId, attempts: t.attempts })
49
51
 
50
- if (!inserted[0]) {
52
+ let ownershipAttempt: number
53
+ if (inserted[0]) {
54
+ ownershipAttempt = Number(inserted[0].attempts)
55
+ } else {
51
56
  const existing = await db
52
57
  .select({ status: t.status, lockedUntil: t.lockedUntil })
53
58
  .from(t)
@@ -74,42 +79,75 @@ export async function runScheduledSlot(args: {
74
79
  or(eq(t.status, 'failed'), lt(t.lockedUntil, now)),
75
80
  ),
76
81
  )
77
- .returning({ taskId: t.taskId })
78
- if (!reclaimed[0]) return { status: 'running' }
82
+ .returning({ taskId: t.taskId, attempts: t.attempts })
83
+ const reclaimedRow = reclaimed[0]
84
+ if (!reclaimedRow) return { status: 'running' }
85
+ ownershipAttempt = Number(reclaimedRow.attempts)
79
86
  }
80
87
 
81
88
  let heartbeatTimer: Timer | undefined
82
- const stopHeartbeat = () => {
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
83
126
  if (heartbeatTimer) {
84
- clearInterval(heartbeatTimer)
127
+ clearTimeout(heartbeatTimer)
85
128
  heartbeatTimer = undefined
86
129
  }
87
- }
130
+ const inFlight = heartbeatInFlight
131
+ if (!inFlight) return
88
132
 
89
- heartbeatTimer = setInterval(async () => {
133
+ let cleanupTimer: Timer | undefined
90
134
  try {
91
- const renewUntil = Date.now() + leaseMs
92
- await db
93
- .update(t)
94
- .set({ lockedUntil: renewUntil })
95
- .where(
96
- and(
97
- eq(t.taskId, taskId),
98
- eq(t.scheduledAt, slot),
99
- eq(t.status, 'running'),
100
- eq(t.startedAt, now),
101
- ),
102
- )
103
- } catch {
104
- // Best effort renewal
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)
105
143
  }
106
- }, heartbeatIntervalMs)
144
+ }
145
+
146
+ scheduleHeartbeat()
107
147
 
108
148
  try {
109
149
  await run(new Date(slot))
110
150
 
111
- stopHeartbeat()
112
-
113
151
  const updated = await db
114
152
  .update(t)
115
153
  .set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
@@ -119,6 +157,7 @@ export async function runScheduledSlot(args: {
119
157
  eq(t.scheduledAt, slot),
120
158
  eq(t.status, 'running'),
121
159
  eq(t.startedAt, now),
160
+ eq(t.attempts, ownershipAttempt),
122
161
  ),
123
162
  )
124
163
  .returning({ taskId: t.taskId })
@@ -131,8 +170,6 @@ export async function runScheduledSlot(args: {
131
170
 
132
171
  return { status: 'succeeded' }
133
172
  } catch (error) {
134
- stopHeartbeat()
135
-
136
173
  const message = error instanceof Error ? error.message : String(error)
137
174
  await db
138
175
  .update(t)
@@ -148,11 +185,12 @@ export async function runScheduledSlot(args: {
148
185
  eq(t.scheduledAt, slot),
149
186
  eq(t.status, 'running'),
150
187
  eq(t.startedAt, now),
188
+ eq(t.attempts, ownershipAttempt),
151
189
  ),
152
190
  )
153
191
  throw error
154
192
  } finally {
155
- stopHeartbeat()
193
+ await stopHeartbeat()
156
194
  }
157
195
  }
158
196
 
@@ -165,6 +203,7 @@ export async function runCronSlot(args: {
165
203
  now: number
166
204
  leaseMs?: number
167
205
  heartbeatIntervalMs?: number
206
+ heartbeatCleanupTimeoutMs?: number
168
207
  }): Promise<CronRunResult> {
169
208
  const definition = args.defs[args.name]
170
209
  if (!definition || definition.kind !== 'cron') {
@@ -178,6 +217,7 @@ export async function runCronSlot(args: {
178
217
  now: args.now,
179
218
  leaseMs: args.leaseMs,
180
219
  heartbeatIntervalMs: args.heartbeatIntervalMs,
220
+ heartbeatCleanupTimeoutMs: args.heartbeatCleanupTimeoutMs,
181
221
  run: (scheduledFor) =>
182
222
  definition.handler({ scheduledFor }, args.ctx as never),
183
223
  })