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 +1 -1
- package/src/jobs/cron-runner.ts +68 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.15.
|
|
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",
|
package/src/jobs/cron-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
clearTimeout(heartbeatTimer)
|
|
85
128
|
heartbeatTimer = undefined
|
|
86
129
|
}
|
|
87
|
-
|
|
130
|
+
const inFlight = heartbeatInFlight
|
|
131
|
+
if (!inFlight) return
|
|
88
132
|
|
|
89
|
-
|
|
133
|
+
let cleanupTimer: Timer | undefined
|
|
90
134
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
}
|
|
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
|
})
|