@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.
@@ -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
- const row = await this.db
93
- .selectFrom("__donkeylabs_jobs__")
94
- .selectAll()
95
- .where("id", "=", jobId)
96
- .executeTakeFirst();
102
+ if (this.checkStopped()) return null;
97
103
 
98
- if (!row) return null;
99
- return this.rowToJob(row);
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
- await this.db
143
- .updateTable("__donkeylabs_jobs__")
144
- .set(updateData)
145
- .where("id", "=", jobId)
146
- .execute();
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
- const rows = await this.db
169
- .selectFrom("__donkeylabs_jobs__")
170
- .selectAll()
171
- .where("status", "=", "pending")
172
- .orderBy("created_at", "asc")
173
- .limit(limit)
174
- .execute();
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
- return rows.map((r) => this.rowToJob(r));
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
- const rows = await this.db
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
- return rows.map((r) => this.rowToJob(r));
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
- let query = this.db
193
- .selectFrom("__donkeylabs_jobs__")
194
- .selectAll()
195
- .where("name", "=", name);
236
+ if (this.checkStopped()) return [];
196
237
 
197
- if (status) {
198
- query = query.where("status", "=", status);
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
- const rows = await query.orderBy("created_at", "desc").execute();
202
- return rows.map((r) => this.rowToJob(r));
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
- const rows = await this.db
207
- .selectFrom("__donkeylabs_jobs__")
208
- .selectAll()
209
- .where("external", "=", 1)
210
- .where("status", "=", "running")
211
- .execute();
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
- return rows.map((r) => this.rowToJob(r));
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
- const rows = await this.db
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
- return rows.map((r) => this.rowToJob(r));
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
- const { status, name, limit = 100, offset = 0 } = options;
303
+ if (this.checkStopped()) return [];
236
304
 
237
- let query = this.db.selectFrom("__donkeylabs_jobs__").selectAll();
305
+ try {
306
+ const { status, name, limit = 100, offset = 0 } = options;
238
307
 
239
- if (status) {
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
- const rows = await query
247
- .orderBy("created_at", "desc")
248
- .limit(limit)
249
- .offset(offset)
250
- .execute();
310
+ if (status) {
311
+ query = query.where("status", "=", status);
312
+ }
313
+ if (name) {
314
+ query = query.where("name", "=", name);
315
+ }
251
316
 
252
- return rows.map((r) => this.rowToJob(r));
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
- console.error("[Jobs] Tick error:", err);
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
- // Update job with socket info
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
- // This might be ok, or might indicate an issue
861
- console.warn(`[Jobs] External job ${job.id} exited with code 0 but no completion message`);
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 later
1010
- await this.adapter.update(job.id, {
1011
- status: "pending",
1012
- attempts,
1013
- error,
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
+ }