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/cron-runner.ts
DELETED
|
@@ -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
|
-
}
|
package/src/jobs/local-cron.ts
DELETED
|
@@ -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
|
-
}
|