@donkeylabs/server 2.0.19 → 2.0.21
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/docs/caching-strategies.md +677 -0
- package/docs/dev-experience.md +656 -0
- package/docs/hot-reload-limitations.md +166 -0
- package/docs/load-testing.md +974 -0
- package/docs/plugin-registry-design.md +1064 -0
- package/docs/production.md +1229 -0
- package/docs/workflows.md +90 -3
- package/package.json +1 -1
- package/src/admin/routes.ts +153 -0
- package/src/core/cron.ts +90 -9
- package/src/core/index.ts +31 -0
- package/src/core/job-adapter-kysely.ts +176 -73
- package/src/core/job-adapter-sqlite.ts +10 -0
- package/src/core/jobs.ts +112 -17
- package/src/core/migrations/workflows/002_add_metadata_column.ts +28 -0
- package/src/core/process-adapter-kysely.ts +62 -21
- package/src/core/storage-adapter-local.test.ts +199 -0
- package/src/core/storage.test.ts +197 -0
- package/src/core/workflow-adapter-kysely.ts +66 -19
- package/src/core/workflow-executor.ts +239 -0
- package/src/core/workflow-proxy.ts +238 -0
- package/src/core/workflow-socket.ts +449 -0
- package/src/core/workflow-state-machine.ts +593 -0
- package/src/core/workflows.test.ts +758 -0
- package/src/core/workflows.ts +705 -595
- package/src/core.ts +17 -6
- package/src/index.ts +14 -0
- package/src/testing/database.test.ts +263 -0
- package/src/testing/database.ts +173 -0
- package/src/testing/e2e.test.ts +189 -0
- package/src/testing/e2e.ts +272 -0
- package/src/testing/index.ts +18 -0
|
@@ -46,6 +46,7 @@ export class KyselyJobAdapter implements JobAdapter {
|
|
|
46
46
|
private db: Kysely<Database>;
|
|
47
47
|
private cleanupTimer?: ReturnType<typeof setInterval>;
|
|
48
48
|
private cleanupDays: number;
|
|
49
|
+
private stopped = false;
|
|
49
50
|
|
|
50
51
|
constructor(db: Kysely<any>, config: KyselyJobAdapterConfig = {}) {
|
|
51
52
|
this.db = db as Kysely<Database>;
|
|
@@ -58,7 +59,16 @@ export class KyselyJobAdapter implements JobAdapter {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/** Check if adapter is stopped (for safe database access) */
|
|
63
|
+
private checkStopped(): boolean {
|
|
64
|
+
return this.stopped;
|
|
65
|
+
}
|
|
66
|
+
|
|
61
67
|
async create(job: Omit<Job, "id">): Promise<Job> {
|
|
68
|
+
if (this.checkStopped()) {
|
|
69
|
+
throw new Error("JobAdapter has been stopped");
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
63
73
|
|
|
64
74
|
await this.db
|
|
@@ -89,17 +99,27 @@ export class KyselyJobAdapter implements JobAdapter {
|
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
async get(jobId: string): Promise<Job | null> {
|
|
92
|
-
|
|
93
|
-
.selectFrom("__donkeylabs_jobs__")
|
|
94
|
-
.selectAll()
|
|
95
|
-
.where("id", "=", jobId)
|
|
96
|
-
.executeTakeFirst();
|
|
102
|
+
if (this.checkStopped()) return null;
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
try {
|
|
105
|
+
const row = await this.db
|
|
106
|
+
.selectFrom("__donkeylabs_jobs__")
|
|
107
|
+
.selectAll()
|
|
108
|
+
.where("id", "=", jobId)
|
|
109
|
+
.executeTakeFirst();
|
|
110
|
+
|
|
111
|
+
if (!row) return null;
|
|
112
|
+
return this.rowToJob(row);
|
|
113
|
+
} catch (err: any) {
|
|
114
|
+
// Silently ignore errors if adapter was stopped during query
|
|
115
|
+
if (this.stopped && err?.message?.includes("destroyed")) return null;
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
async update(jobId: string, updates: Partial<Job>): Promise<void> {
|
|
121
|
+
if (this.checkStopped()) return;
|
|
122
|
+
|
|
103
123
|
const updateData: Partial<JobsTable> = {};
|
|
104
124
|
|
|
105
125
|
if (updates.status !== undefined) {
|
|
@@ -139,14 +159,22 @@ export class KyselyJobAdapter implements JobAdapter {
|
|
|
139
159
|
|
|
140
160
|
if (Object.keys(updateData).length === 0) return;
|
|
141
161
|
|
|
142
|
-
|
|
143
|
-
.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
162
|
+
try {
|
|
163
|
+
await this.db
|
|
164
|
+
.updateTable("__donkeylabs_jobs__")
|
|
165
|
+
.set(updateData)
|
|
166
|
+
.where("id", "=", jobId)
|
|
167
|
+
.execute();
|
|
168
|
+
} catch (err: any) {
|
|
169
|
+
// Silently ignore errors if adapter was stopped during query
|
|
170
|
+
if (this.stopped && err?.message?.includes("destroyed")) return;
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
147
173
|
}
|
|
148
174
|
|
|
149
175
|
async delete(jobId: string): Promise<boolean> {
|
|
176
|
+
if (this.checkStopped()) return false;
|
|
177
|
+
|
|
150
178
|
// Check if exists first since BunSqliteDialect doesn't report numDeletedRows properly
|
|
151
179
|
const exists = await this.db
|
|
152
180
|
.selectFrom("__donkeylabs_jobs__")
|
|
@@ -165,91 +193,139 @@ export class KyselyJobAdapter implements JobAdapter {
|
|
|
165
193
|
}
|
|
166
194
|
|
|
167
195
|
async getPending(limit: number = 100): Promise<Job[]> {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
196
|
+
if (this.checkStopped()) return [];
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const rows = await this.db
|
|
200
|
+
.selectFrom("__donkeylabs_jobs__")
|
|
201
|
+
.selectAll()
|
|
202
|
+
.where("status", "=", "pending")
|
|
203
|
+
.orderBy("created_at", "asc")
|
|
204
|
+
.limit(limit)
|
|
205
|
+
.execute();
|
|
175
206
|
|
|
176
|
-
|
|
207
|
+
return rows.map((r) => this.rowToJob(r));
|
|
208
|
+
} catch (err: any) {
|
|
209
|
+
// Silently ignore errors if adapter was stopped during query
|
|
210
|
+
if (this.stopped && err?.message?.includes("destroyed")) return [];
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
177
213
|
}
|
|
178
214
|
|
|
179
215
|
async getScheduledReady(now: Date): Promise<Job[]> {
|
|
180
|
-
|
|
181
|
-
.selectFrom("__donkeylabs_jobs__")
|
|
182
|
-
.selectAll()
|
|
183
|
-
.where("status", "=", "scheduled")
|
|
184
|
-
.where("run_at", "<=", now.toISOString())
|
|
185
|
-
.orderBy("run_at", "asc")
|
|
186
|
-
.execute();
|
|
216
|
+
if (this.checkStopped()) return [];
|
|
187
217
|
|
|
188
|
-
|
|
218
|
+
try {
|
|
219
|
+
const rows = await this.db
|
|
220
|
+
.selectFrom("__donkeylabs_jobs__")
|
|
221
|
+
.selectAll()
|
|
222
|
+
.where("status", "=", "scheduled")
|
|
223
|
+
.where("run_at", "<=", now.toISOString())
|
|
224
|
+
.orderBy("run_at", "asc")
|
|
225
|
+
.execute();
|
|
226
|
+
|
|
227
|
+
return rows.map((r) => this.rowToJob(r));
|
|
228
|
+
} catch (err: any) {
|
|
229
|
+
// Silently ignore errors if adapter was stopped during query
|
|
230
|
+
if (this.stopped && err?.message?.includes("destroyed")) return [];
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
189
233
|
}
|
|
190
234
|
|
|
191
235
|
async getByName(name: string, status?: JobStatus): Promise<Job[]> {
|
|
192
|
-
|
|
193
|
-
.selectFrom("__donkeylabs_jobs__")
|
|
194
|
-
.selectAll()
|
|
195
|
-
.where("name", "=", name);
|
|
236
|
+
if (this.checkStopped()) return [];
|
|
196
237
|
|
|
197
|
-
|
|
198
|
-
query =
|
|
199
|
-
|
|
238
|
+
try {
|
|
239
|
+
let query = this.db
|
|
240
|
+
.selectFrom("__donkeylabs_jobs__")
|
|
241
|
+
.selectAll()
|
|
242
|
+
.where("name", "=", name);
|
|
243
|
+
|
|
244
|
+
if (status) {
|
|
245
|
+
query = query.where("status", "=", status);
|
|
246
|
+
}
|
|
200
247
|
|
|
201
|
-
|
|
202
|
-
|
|
248
|
+
const rows = await query.orderBy("created_at", "desc").execute();
|
|
249
|
+
return rows.map((r) => this.rowToJob(r));
|
|
250
|
+
} catch (err: any) {
|
|
251
|
+
// Silently ignore errors if adapter was stopped during query
|
|
252
|
+
if (this.stopped && err?.message?.includes("destroyed")) return [];
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
203
255
|
}
|
|
204
256
|
|
|
205
257
|
async getRunningExternal(): Promise<Job[]> {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
258
|
+
if (this.checkStopped()) return [];
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const rows = await this.db
|
|
262
|
+
.selectFrom("__donkeylabs_jobs__")
|
|
263
|
+
.selectAll()
|
|
264
|
+
.where("external", "=", 1)
|
|
265
|
+
.where("status", "=", "running")
|
|
266
|
+
.execute();
|
|
212
267
|
|
|
213
|
-
|
|
268
|
+
return rows.map((r) => this.rowToJob(r));
|
|
269
|
+
} catch (err: any) {
|
|
270
|
+
// Silently ignore errors if adapter was stopped during query
|
|
271
|
+
if (this.stopped && err?.message?.includes("destroyed")) return [];
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
214
274
|
}
|
|
215
275
|
|
|
216
276
|
async getOrphanedExternal(): Promise<Job[]> {
|
|
217
|
-
|
|
218
|
-
.selectFrom("__donkeylabs_jobs__")
|
|
219
|
-
.selectAll()
|
|
220
|
-
.where("external", "=", 1)
|
|
221
|
-
.where("status", "=", "running")
|
|
222
|
-
.where((eb) =>
|
|
223
|
-
eb.or([
|
|
224
|
-
eb("process_state", "=", "running"),
|
|
225
|
-
eb("process_state", "=", "orphaned"),
|
|
226
|
-
eb("process_state", "=", "spawning"),
|
|
227
|
-
])
|
|
228
|
-
)
|
|
229
|
-
.execute();
|
|
277
|
+
if (this.checkStopped()) return [];
|
|
230
278
|
|
|
231
|
-
|
|
279
|
+
try {
|
|
280
|
+
const rows = await this.db
|
|
281
|
+
.selectFrom("__donkeylabs_jobs__")
|
|
282
|
+
.selectAll()
|
|
283
|
+
.where("external", "=", 1)
|
|
284
|
+
.where("status", "=", "running")
|
|
285
|
+
.where((eb) =>
|
|
286
|
+
eb.or([
|
|
287
|
+
eb("process_state", "=", "running"),
|
|
288
|
+
eb("process_state", "=", "orphaned"),
|
|
289
|
+
eb("process_state", "=", "spawning"),
|
|
290
|
+
])
|
|
291
|
+
)
|
|
292
|
+
.execute();
|
|
293
|
+
|
|
294
|
+
return rows.map((r) => this.rowToJob(r));
|
|
295
|
+
} catch (err: any) {
|
|
296
|
+
// Silently ignore errors if adapter was stopped during query
|
|
297
|
+
if (this.stopped && err?.message?.includes("destroyed")) return [];
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
232
300
|
}
|
|
233
301
|
|
|
234
302
|
async getAll(options: GetAllJobsOptions = {}): Promise<Job[]> {
|
|
235
|
-
|
|
303
|
+
if (this.checkStopped()) return [];
|
|
236
304
|
|
|
237
|
-
|
|
305
|
+
try {
|
|
306
|
+
const { status, name, limit = 100, offset = 0 } = options;
|
|
238
307
|
|
|
239
|
-
|
|
240
|
-
query = query.where("status", "=", status);
|
|
241
|
-
}
|
|
242
|
-
if (name) {
|
|
243
|
-
query = query.where("name", "=", name);
|
|
244
|
-
}
|
|
308
|
+
let query = this.db.selectFrom("__donkeylabs_jobs__").selectAll();
|
|
245
309
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
310
|
+
if (status) {
|
|
311
|
+
query = query.where("status", "=", status);
|
|
312
|
+
}
|
|
313
|
+
if (name) {
|
|
314
|
+
query = query.where("name", "=", name);
|
|
315
|
+
}
|
|
251
316
|
|
|
252
|
-
|
|
317
|
+
const rows = await query
|
|
318
|
+
.orderBy("created_at", "desc")
|
|
319
|
+
.limit(limit)
|
|
320
|
+
.offset(offset)
|
|
321
|
+
.execute();
|
|
322
|
+
|
|
323
|
+
return rows.map((r) => this.rowToJob(r));
|
|
324
|
+
} catch (err: any) {
|
|
325
|
+
// Silently ignore errors if adapter was stopped during query
|
|
326
|
+
if (this.stopped && err?.message?.includes("destroyed")) return [];
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
253
329
|
}
|
|
254
330
|
|
|
255
331
|
private rowToJob(row: JobsTable): Job {
|
|
@@ -278,7 +354,7 @@ export class KyselyJobAdapter implements JobAdapter {
|
|
|
278
354
|
|
|
279
355
|
/** Clean up old completed/failed jobs */
|
|
280
356
|
private async cleanup(): Promise<void> {
|
|
281
|
-
if (this.cleanupDays <= 0) return;
|
|
357
|
+
if (this.cleanupDays <= 0 || this.checkStopped()) return;
|
|
282
358
|
|
|
283
359
|
try {
|
|
284
360
|
const cutoff = new Date();
|
|
@@ -305,9 +381,36 @@ export class KyselyJobAdapter implements JobAdapter {
|
|
|
305
381
|
|
|
306
382
|
/** Stop the adapter and cleanup timer */
|
|
307
383
|
stop(): void {
|
|
384
|
+
this.stopped = true;
|
|
308
385
|
if (this.cleanupTimer) {
|
|
309
386
|
clearInterval(this.cleanupTimer);
|
|
310
387
|
this.cleanupTimer = undefined;
|
|
311
388
|
}
|
|
312
389
|
}
|
|
390
|
+
|
|
391
|
+
/** Atomically claim a pending job (returns true if successfully claimed) */
|
|
392
|
+
async claim(jobId: string): Promise<boolean> {
|
|
393
|
+
if (this.checkStopped()) return false;
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
// Use WHERE status = 'pending' for atomicity - only one process can claim
|
|
397
|
+
const result = await this.db
|
|
398
|
+
.updateTable("__donkeylabs_jobs__")
|
|
399
|
+
.set({
|
|
400
|
+
status: "running",
|
|
401
|
+
started_at: new Date().toISOString(),
|
|
402
|
+
})
|
|
403
|
+
.where("id", "=", jobId)
|
|
404
|
+
.where("status", "=", "pending")
|
|
405
|
+
.execute();
|
|
406
|
+
|
|
407
|
+
// Kysely returns numUpdatedRows for updates
|
|
408
|
+
const numUpdated = Number(result[0]?.numUpdatedRows ?? 0);
|
|
409
|
+
return numUpdated > 0;
|
|
410
|
+
} catch (err: any) {
|
|
411
|
+
// Silently ignore errors if adapter was stopped during query
|
|
412
|
+
if (this.stopped && err?.message?.includes("destroyed")) return false;
|
|
413
|
+
throw err;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
313
416
|
}
|
|
@@ -305,4 +305,14 @@ export class SqliteJobAdapter implements JobAdapter {
|
|
|
305
305
|
this.cleanupTimer = undefined;
|
|
306
306
|
}
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
/** Atomically claim a pending job (returns true if successfully claimed) */
|
|
310
|
+
async claim(jobId: string): Promise<boolean> {
|
|
311
|
+
// Use WHERE status = 'pending' for atomicity - only one process can claim
|
|
312
|
+
const result = this.db.run(
|
|
313
|
+
`UPDATE jobs SET status = 'running', started_at = ? WHERE id = ? AND status = 'pending'`,
|
|
314
|
+
[new Date().toISOString(), jobId]
|
|
315
|
+
);
|
|
316
|
+
return result.changes > 0;
|
|
317
|
+
}
|
|
308
318
|
}
|
package/src/core/jobs.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface JobAdapter {
|
|
|
87
87
|
getOrphanedExternal(): Promise<Job[]>;
|
|
88
88
|
/** Get all jobs with optional filtering (for admin dashboard) */
|
|
89
89
|
getAll(options?: GetAllJobsOptions): Promise<Job[]>;
|
|
90
|
+
/** Atomically claim a pending job (returns true if successfully claimed) */
|
|
91
|
+
claim(jobId: string): Promise<boolean>;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
export interface JobsConfig {
|
|
@@ -104,6 +106,12 @@ export interface JobsConfig {
|
|
|
104
106
|
persist?: boolean;
|
|
105
107
|
/** SQLite database path (default: .donkeylabs/jobs.db) */
|
|
106
108
|
dbPath?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Retry backoff configuration.
|
|
111
|
+
* Set to false to disable backoff (immediate retry).
|
|
112
|
+
* Default: exponential backoff starting at 1000ms, max 300000ms (5 min)
|
|
113
|
+
*/
|
|
114
|
+
retryBackoff?: false | { baseMs?: number; maxMs?: number };
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
export interface Jobs {
|
|
@@ -225,6 +233,14 @@ export class MemoryJobAdapter implements JobAdapter {
|
|
|
225
233
|
// Apply pagination
|
|
226
234
|
return results.slice(offset, offset + limit);
|
|
227
235
|
}
|
|
236
|
+
|
|
237
|
+
async claim(jobId: string): Promise<boolean> {
|
|
238
|
+
const job = this.jobs.get(jobId);
|
|
239
|
+
if (!job || job.status !== "pending") return false;
|
|
240
|
+
job.status = "running";
|
|
241
|
+
job.startedAt = new Date();
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
228
244
|
}
|
|
229
245
|
|
|
230
246
|
class JobsImpl implements Jobs {
|
|
@@ -241,6 +257,8 @@ class JobsImpl implements Jobs {
|
|
|
241
257
|
private defaultMaxAttempts: number;
|
|
242
258
|
private usePersistence: boolean;
|
|
243
259
|
private dbPath?: string;
|
|
260
|
+
private tickRunning = false; // Reentrancy guard for tick()
|
|
261
|
+
private retryBackoff: false | { baseMs: number; maxMs: number };
|
|
244
262
|
|
|
245
263
|
// External jobs support
|
|
246
264
|
private externalConfigs = new Map<string, ExternalJobConfig>();
|
|
@@ -256,6 +274,18 @@ class JobsImpl implements Jobs {
|
|
|
256
274
|
this.externalConfig = config.external ?? {};
|
|
257
275
|
this.usePersistence = config.persist ?? true; // Default to SQLite persistence
|
|
258
276
|
this.dbPath = config.dbPath;
|
|
277
|
+
// Configure retry backoff
|
|
278
|
+
if (config.retryBackoff === false) {
|
|
279
|
+
this.retryBackoff = false;
|
|
280
|
+
} else if (config.retryBackoff) {
|
|
281
|
+
this.retryBackoff = {
|
|
282
|
+
baseMs: config.retryBackoff.baseMs ?? 1000,
|
|
283
|
+
maxMs: config.retryBackoff.maxMs ?? 300000,
|
|
284
|
+
};
|
|
285
|
+
} else {
|
|
286
|
+
// Default: exponential backoff
|
|
287
|
+
this.retryBackoff = { baseMs: 1000, maxMs: 300000 };
|
|
288
|
+
}
|
|
259
289
|
|
|
260
290
|
// Use provided adapter, or create SQLite adapter if persistence enabled
|
|
261
291
|
if (config.adapter) {
|
|
@@ -402,6 +432,12 @@ class JobsImpl implements Jobs {
|
|
|
402
432
|
this.heartbeatTimer = null;
|
|
403
433
|
}
|
|
404
434
|
|
|
435
|
+
// Wait for any in-progress tick to complete
|
|
436
|
+
const tickWaitStart = Date.now();
|
|
437
|
+
while (this.tickRunning && Date.now() - tickWaitStart < 5000) {
|
|
438
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
439
|
+
}
|
|
440
|
+
|
|
405
441
|
// Cleanup external job processes
|
|
406
442
|
for (const [jobId, procInfo] of this.externalProcesses) {
|
|
407
443
|
if (procInfo.timeout) {
|
|
@@ -426,6 +462,12 @@ class JobsImpl implements Jobs {
|
|
|
426
462
|
this.sqliteAdapter.stop();
|
|
427
463
|
}
|
|
428
464
|
|
|
465
|
+
// Stop adapter (cleanup timers and prevent further DB access)
|
|
466
|
+
// This handles KyselyJobAdapter and other adapters with stop() method
|
|
467
|
+
if (this.adapter && typeof (this.adapter as any).stop === "function") {
|
|
468
|
+
(this.adapter as any).stop();
|
|
469
|
+
}
|
|
470
|
+
|
|
429
471
|
// Wait for active in-process jobs to complete (with timeout)
|
|
430
472
|
const maxWait = 30000; // 30 seconds
|
|
431
473
|
const startTime = Date.now();
|
|
@@ -721,30 +763,59 @@ class JobsImpl implements Jobs {
|
|
|
721
763
|
private async tick(): Promise<void> {
|
|
722
764
|
if (!this.running) return;
|
|
723
765
|
|
|
766
|
+
// Reentrancy guard - prevent concurrent tick execution
|
|
767
|
+
if (this.tickRunning) return;
|
|
768
|
+
this.tickRunning = true;
|
|
769
|
+
|
|
724
770
|
try {
|
|
771
|
+
// Check running state before each async operation to exit quickly on stop()
|
|
772
|
+
if (!this.running) return;
|
|
773
|
+
|
|
725
774
|
// Process scheduled jobs that are ready
|
|
726
775
|
const now = new Date();
|
|
727
776
|
const scheduledReady = await this.adapter.getScheduledReady(now);
|
|
777
|
+
|
|
778
|
+
if (!this.running) return;
|
|
779
|
+
|
|
728
780
|
for (const job of scheduledReady) {
|
|
781
|
+
if (!this.running) return;
|
|
729
782
|
await this.adapter.update(job.id, { status: "pending" });
|
|
730
783
|
}
|
|
731
784
|
|
|
785
|
+
if (!this.running) return;
|
|
786
|
+
|
|
732
787
|
// Process pending jobs
|
|
733
788
|
const availableSlots = this.concurrency - this.activeJobs;
|
|
734
789
|
if (availableSlots <= 0) return;
|
|
735
790
|
|
|
736
791
|
const pending = await this.adapter.getPending(availableSlots);
|
|
792
|
+
|
|
793
|
+
if (!this.running) return;
|
|
794
|
+
|
|
737
795
|
for (const job of pending) {
|
|
796
|
+
if (!this.running) break;
|
|
738
797
|
if (this.activeJobs >= this.concurrency) break;
|
|
739
798
|
|
|
799
|
+
// Atomic claim - prevent double execution
|
|
800
|
+
const claimed = await this.adapter.claim(job.id);
|
|
801
|
+
if (!claimed) continue; // Another process claimed it
|
|
802
|
+
|
|
740
803
|
if (job.external) {
|
|
741
804
|
this.processExternalJob(job);
|
|
742
805
|
} else {
|
|
743
806
|
this.processJob(job);
|
|
744
807
|
}
|
|
745
808
|
}
|
|
746
|
-
} catch (err) {
|
|
747
|
-
|
|
809
|
+
} catch (err: any) {
|
|
810
|
+
// Suppress "driver destroyed" errors which happen during test cleanup
|
|
811
|
+
// when the database is garbage collected before the tick completes
|
|
812
|
+
const isDriverDestroyed = err?.message?.includes("driver has already been destroyed");
|
|
813
|
+
// Only log if we're still running and it's not a driver destroyed error
|
|
814
|
+
if (this.running && !isDriverDestroyed) {
|
|
815
|
+
console.error("[Jobs] Tick error:", err);
|
|
816
|
+
}
|
|
817
|
+
} finally {
|
|
818
|
+
this.tickRunning = false; // Release guard
|
|
748
819
|
}
|
|
749
820
|
}
|
|
750
821
|
|
|
@@ -770,10 +841,9 @@ class JobsImpl implements Jobs {
|
|
|
770
841
|
|
|
771
842
|
const { socketPath, tcpPort } = await this.socketServer!.createSocket(job.id);
|
|
772
843
|
|
|
773
|
-
//
|
|
844
|
+
// Job is already claimed (status=running, startedAt set)
|
|
845
|
+
// Update with socket info and other external job fields
|
|
774
846
|
await this.adapter.update(job.id, {
|
|
775
|
-
status: "running",
|
|
776
|
-
startedAt,
|
|
777
847
|
attempts: job.attempts + 1,
|
|
778
848
|
socketPath,
|
|
779
849
|
tcpPort,
|
|
@@ -856,9 +926,20 @@ class JobsImpl implements Jobs {
|
|
|
856
926
|
const currentJob = await this.adapter.get(job.id);
|
|
857
927
|
if (currentJob?.status === "running") {
|
|
858
928
|
if (code === 0) {
|
|
859
|
-
// Process exited cleanly but didn't send completion message
|
|
860
|
-
|
|
861
|
-
|
|
929
|
+
// Process exited cleanly but didn't send completion message - mark as completed
|
|
930
|
+
console.warn(`[Jobs] External job ${job.id} exited with code 0 but no completion message, marking completed`);
|
|
931
|
+
await this.adapter.update(job.id, {
|
|
932
|
+
status: "completed",
|
|
933
|
+
completedAt: new Date(),
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
if (this.events) {
|
|
937
|
+
await this.events.emit("job.completed", {
|
|
938
|
+
jobId: job.id,
|
|
939
|
+
name: job.name,
|
|
940
|
+
result: undefined,
|
|
941
|
+
});
|
|
942
|
+
}
|
|
862
943
|
} else {
|
|
863
944
|
// Process failed
|
|
864
945
|
await this.adapter.update(job.id, {
|
|
@@ -972,12 +1053,11 @@ class JobsImpl implements Jobs {
|
|
|
972
1053
|
}
|
|
973
1054
|
|
|
974
1055
|
this.activeJobs++;
|
|
975
|
-
const startedAt = new Date();
|
|
976
1056
|
|
|
977
1057
|
try {
|
|
1058
|
+
// Job is already claimed (status=running, startedAt set)
|
|
1059
|
+
// Just update attempts
|
|
978
1060
|
await this.adapter.update(job.id, {
|
|
979
|
-
status: "running",
|
|
980
|
-
startedAt,
|
|
981
1061
|
attempts: job.attempts + 1,
|
|
982
1062
|
});
|
|
983
1063
|
|
|
@@ -1006,12 +1086,27 @@ class JobsImpl implements Jobs {
|
|
|
1006
1086
|
const attempts = job.attempts + 1;
|
|
1007
1087
|
|
|
1008
1088
|
if (attempts < job.maxAttempts) {
|
|
1009
|
-
// Retry
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1089
|
+
// Retry with optional exponential backoff
|
|
1090
|
+
if (this.retryBackoff === false) {
|
|
1091
|
+
// No backoff - immediate retry
|
|
1092
|
+
await this.adapter.update(job.id, {
|
|
1093
|
+
status: "pending",
|
|
1094
|
+
attempts,
|
|
1095
|
+
error,
|
|
1096
|
+
});
|
|
1097
|
+
} else {
|
|
1098
|
+
// Exponential backoff: delay = min(baseMs * 2^(attempts-1), maxMs)
|
|
1099
|
+
const { baseMs, maxMs } = this.retryBackoff;
|
|
1100
|
+
const backoffMs = Math.min(baseMs * Math.pow(2, attempts - 1), maxMs);
|
|
1101
|
+
const runAt = new Date(Date.now() + backoffMs);
|
|
1102
|
+
|
|
1103
|
+
await this.adapter.update(job.id, {
|
|
1104
|
+
status: "scheduled",
|
|
1105
|
+
runAt,
|
|
1106
|
+
attempts,
|
|
1107
|
+
error,
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1015
1110
|
} else {
|
|
1016
1111
|
// Max attempts reached, mark as failed
|
|
1017
1112
|
await this.adapter.update(job.id, {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Migration: Add metadata column to workflow instances
|
|
3
|
+
*
|
|
4
|
+
* Adds a metadata column to store custom JSON data that persists across workflow steps.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { sql, type Kysely } from "kysely";
|
|
8
|
+
|
|
9
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
10
|
+
// SQLite doesn't support IF NOT EXISTS for ALTER TABLE ADD COLUMN
|
|
11
|
+
// Check if column exists first
|
|
12
|
+
const tableInfo = await sql<{ name: string }>`
|
|
13
|
+
PRAGMA table_info(__donkeylabs_workflow_instances__)
|
|
14
|
+
`.execute(db);
|
|
15
|
+
|
|
16
|
+
const hasMetadataColumn = tableInfo.rows.some((row) => row.name === "metadata");
|
|
17
|
+
|
|
18
|
+
if (!hasMetadataColumn) {
|
|
19
|
+
await sql`
|
|
20
|
+
ALTER TABLE __donkeylabs_workflow_instances__ ADD COLUMN metadata TEXT
|
|
21
|
+
`.execute(db);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
26
|
+
// SQLite doesn't support DROP COLUMN directly
|
|
27
|
+
// In practice, we don't need to remove it - the column can stay
|
|
28
|
+
}
|