@bitclaw/jobs 1.1.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/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/cron.d.ts +11 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +86 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/queue.d.ts +63 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +522 -0
- package/dist/rate-limiter.d.ts +11 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +27 -0
- package/dist/scheduler.d.ts +26 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +176 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +103 -0
- package/dist/types.d.ts +193 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +5 -0
- package/dist/worker.d.ts +21 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +111 -0
- package/package.json +67 -0
package/dist/queue.js
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
// packages/jobs/src/queue.ts
|
|
2
|
+
// JobQueue — SQLite-backed job queue with typed payloads and dependency support
|
|
3
|
+
import { Database } from 'bun:sqlite';
|
|
4
|
+
import { mkdirSync } from 'node:fs';
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
import { applyPragmas, initializeSchema } from './schema';
|
|
7
|
+
import { nowISO } from './utils';
|
|
8
|
+
import { JobWorker } from './worker';
|
|
9
|
+
function toJob(row) {
|
|
10
|
+
return {
|
|
11
|
+
id: row.id,
|
|
12
|
+
type: row.type,
|
|
13
|
+
data: JSON.parse(row.data),
|
|
14
|
+
status: row.status,
|
|
15
|
+
priority: row.priority,
|
|
16
|
+
progress: row.progress,
|
|
17
|
+
maxRetries: row.max_retries,
|
|
18
|
+
retryCount: row.retry_count,
|
|
19
|
+
runAt: row.run_at,
|
|
20
|
+
createdAt: row.created_at,
|
|
21
|
+
updatedAt: row.updated_at,
|
|
22
|
+
startedAt: row.started_at,
|
|
23
|
+
completedAt: row.completed_at,
|
|
24
|
+
error: row.error,
|
|
25
|
+
batchId: row.batch_id,
|
|
26
|
+
requestLog: row.request_log,
|
|
27
|
+
responseLog: row.response_log
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function toBatch(row) {
|
|
31
|
+
return {
|
|
32
|
+
id: row.id,
|
|
33
|
+
name: row.name,
|
|
34
|
+
totalJobs: row.total_jobs,
|
|
35
|
+
pendingJobs: row.pending_jobs,
|
|
36
|
+
failedJobs: row.failed_jobs,
|
|
37
|
+
failedJobIds: JSON.parse(row.failed_job_ids),
|
|
38
|
+
options: row.options ? JSON.parse(row.options) : null,
|
|
39
|
+
cancelledAt: row.cancelled_at,
|
|
40
|
+
createdAt: row.created_at,
|
|
41
|
+
finishedAt: row.finished_at
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function toFailedJob(row) {
|
|
45
|
+
return {
|
|
46
|
+
id: row.id,
|
|
47
|
+
originalJobId: row.original_job_id,
|
|
48
|
+
type: row.type,
|
|
49
|
+
data: JSON.parse(row.data),
|
|
50
|
+
error: row.error,
|
|
51
|
+
retryCount: row.retry_count,
|
|
52
|
+
maxRetries: row.max_retries,
|
|
53
|
+
createdAt: row.created_at,
|
|
54
|
+
failedAt: row.failed_at,
|
|
55
|
+
requestLog: row.request_log,
|
|
56
|
+
responseLog: row.response_log
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export class JobQueue {
|
|
60
|
+
db;
|
|
61
|
+
insertJobStmt;
|
|
62
|
+
insertDepStmt;
|
|
63
|
+
selectJobStmt;
|
|
64
|
+
selectPendingStmt;
|
|
65
|
+
markProcessingStmt;
|
|
66
|
+
markDoneStmt;
|
|
67
|
+
markFailedStmt;
|
|
68
|
+
updateProgressStmt;
|
|
69
|
+
selectStatsStmt;
|
|
70
|
+
countFailedStmt;
|
|
71
|
+
insertFailedJobStmt;
|
|
72
|
+
deleteJobStmt;
|
|
73
|
+
selectDependentsStmt;
|
|
74
|
+
countUnmetDepsStmt;
|
|
75
|
+
unblockJobStmt;
|
|
76
|
+
lastInsertRowIdStmt;
|
|
77
|
+
// Batch statements
|
|
78
|
+
insertBatchStmt;
|
|
79
|
+
selectBatchStmt;
|
|
80
|
+
decrementBatchPendingStmt;
|
|
81
|
+
incrementBatchFailedStmt;
|
|
82
|
+
finishBatchStmt;
|
|
83
|
+
cancelBatchStmt;
|
|
84
|
+
cancelBatchJobsStmt;
|
|
85
|
+
constructor(dbPath) {
|
|
86
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
87
|
+
this.db = new Database(dbPath, { create: true });
|
|
88
|
+
applyPragmas(this.db);
|
|
89
|
+
initializeSchema(this.db);
|
|
90
|
+
this.insertJobStmt = this.db.query(`
|
|
91
|
+
INSERT INTO jobs (type, data, status, priority, max_retries, run_at, batch_id)
|
|
92
|
+
VALUES ($type, $data, $status, $priority, $maxRetries, $runAt, $batchId)
|
|
93
|
+
`);
|
|
94
|
+
this.insertDepStmt = this.db.query(`
|
|
95
|
+
INSERT INTO job_dependencies (job_id, depends_on_id) VALUES ($jobId, $depsOnId)
|
|
96
|
+
`);
|
|
97
|
+
this.selectJobStmt = this.db.query('SELECT * FROM jobs WHERE id = $id');
|
|
98
|
+
this.selectPendingStmt = this.db.query(`
|
|
99
|
+
SELECT * FROM jobs
|
|
100
|
+
WHERE status = 'pending' AND type = $type AND run_at <= $now
|
|
101
|
+
ORDER BY priority DESC, created_at ASC
|
|
102
|
+
LIMIT 1
|
|
103
|
+
`);
|
|
104
|
+
this.markProcessingStmt = this.db.query(`
|
|
105
|
+
UPDATE jobs SET status = 'processing', started_at = $now, updated_at = $now
|
|
106
|
+
WHERE id = $id
|
|
107
|
+
`);
|
|
108
|
+
this.markDoneStmt = this.db.query(`
|
|
109
|
+
UPDATE jobs SET status = 'done', completed_at = $now, updated_at = $now, progress = 100
|
|
110
|
+
WHERE id = $id
|
|
111
|
+
`);
|
|
112
|
+
this.markFailedStmt = this.db.query(`
|
|
113
|
+
UPDATE jobs
|
|
114
|
+
SET status = 'pending',
|
|
115
|
+
retry_count = retry_count + 1,
|
|
116
|
+
error = $error,
|
|
117
|
+
updated_at = $now
|
|
118
|
+
WHERE id = $id
|
|
119
|
+
`);
|
|
120
|
+
this.updateProgressStmt = this.db.query(`
|
|
121
|
+
UPDATE jobs SET progress = $progress, updated_at = $now WHERE id = $id
|
|
122
|
+
`);
|
|
123
|
+
this.selectStatsStmt = this.db.query('SELECT status, COUNT(*) as count FROM jobs GROUP BY status');
|
|
124
|
+
this.countFailedStmt = this.db.query('SELECT COUNT(*) as count FROM failed_jobs');
|
|
125
|
+
this.insertFailedJobStmt = this.db.query(`
|
|
126
|
+
INSERT INTO failed_jobs (original_job_id, type, data, error, retry_count, max_retries, created_at, request_log, response_log)
|
|
127
|
+
VALUES ($originalJobId, $type, $data, $error, $retryCount, $maxRetries, $createdAt, $requestLog, $responseLog)
|
|
128
|
+
`);
|
|
129
|
+
this.deleteJobStmt = this.db.query('DELETE FROM jobs WHERE id = $id');
|
|
130
|
+
this.selectDependentsStmt = this.db.query(`
|
|
131
|
+
SELECT job_id FROM job_dependencies WHERE depends_on_id = $depsOnId
|
|
132
|
+
`);
|
|
133
|
+
this.countUnmetDepsStmt = this.db.query(`
|
|
134
|
+
SELECT COUNT(*) as count FROM job_dependencies jd
|
|
135
|
+
JOIN jobs j ON jd.depends_on_id = j.id
|
|
136
|
+
WHERE jd.job_id = $jobId AND j.status != 'done'
|
|
137
|
+
`);
|
|
138
|
+
this.unblockJobStmt = this.db.query(`
|
|
139
|
+
UPDATE jobs SET status = 'pending', updated_at = $now
|
|
140
|
+
WHERE id = $id AND status = 'blocked'
|
|
141
|
+
`);
|
|
142
|
+
this.lastInsertRowIdStmt = this.db.query('SELECT last_insert_rowid() as id');
|
|
143
|
+
// Batch statements
|
|
144
|
+
this.insertBatchStmt = this.db.query(`
|
|
145
|
+
INSERT INTO job_batches (id, name, options, created_at)
|
|
146
|
+
VALUES ($id, $name, $options, $createdAt)
|
|
147
|
+
`);
|
|
148
|
+
this.selectBatchStmt = this.db.query('SELECT * FROM job_batches WHERE id = $id');
|
|
149
|
+
this.decrementBatchPendingStmt = this.db.query(`
|
|
150
|
+
UPDATE job_batches SET pending_jobs = pending_jobs - 1
|
|
151
|
+
WHERE id = $id
|
|
152
|
+
`);
|
|
153
|
+
this.incrementBatchFailedStmt = this.db.query(`
|
|
154
|
+
UPDATE job_batches
|
|
155
|
+
SET failed_jobs = failed_jobs + 1,
|
|
156
|
+
failed_job_ids = json_insert(failed_job_ids, '$[#]', $jobId)
|
|
157
|
+
WHERE id = $id
|
|
158
|
+
`);
|
|
159
|
+
this.finishBatchStmt = this.db.query(`
|
|
160
|
+
UPDATE job_batches SET finished_at = $now WHERE id = $id
|
|
161
|
+
`);
|
|
162
|
+
this.cancelBatchStmt = this.db.query(`
|
|
163
|
+
UPDATE job_batches SET cancelled_at = $now WHERE id = $id
|
|
164
|
+
`);
|
|
165
|
+
this.cancelBatchJobsStmt = this.db.query(`
|
|
166
|
+
UPDATE jobs SET status = 'cancelled', updated_at = $now
|
|
167
|
+
WHERE batch_id = $batchId AND status IN ('pending', 'blocked')
|
|
168
|
+
`);
|
|
169
|
+
}
|
|
170
|
+
add(type, data, options) {
|
|
171
|
+
return this.insertJob(type, data, null, options);
|
|
172
|
+
}
|
|
173
|
+
getJob(id) {
|
|
174
|
+
const row = this.selectJobStmt.get({ $id: id });
|
|
175
|
+
return row ? toJob(row) : null;
|
|
176
|
+
}
|
|
177
|
+
getStats() {
|
|
178
|
+
const rows = this.selectStatsStmt.all();
|
|
179
|
+
const deadRow = this.countFailedStmt.get();
|
|
180
|
+
const stats = {
|
|
181
|
+
pending: 0,
|
|
182
|
+
blocked: 0,
|
|
183
|
+
processing: 0,
|
|
184
|
+
done: 0,
|
|
185
|
+
failed: 0,
|
|
186
|
+
cancelled: 0,
|
|
187
|
+
dead: deadRow.count
|
|
188
|
+
};
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
const key = row.status;
|
|
191
|
+
if (key in stats) {
|
|
192
|
+
stats[key] = row.count;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return stats;
|
|
196
|
+
}
|
|
197
|
+
getFailedJobs(options) {
|
|
198
|
+
const limit = options?.limit ?? 100;
|
|
199
|
+
const offset = options?.offset ?? 0;
|
|
200
|
+
let rows;
|
|
201
|
+
let total;
|
|
202
|
+
if (options?.type) {
|
|
203
|
+
rows = this.db
|
|
204
|
+
.query('SELECT * FROM failed_jobs WHERE type = $type ORDER BY failed_at DESC LIMIT $limit OFFSET $offset')
|
|
205
|
+
.all({
|
|
206
|
+
$type: options.type,
|
|
207
|
+
$limit: limit,
|
|
208
|
+
$offset: offset
|
|
209
|
+
});
|
|
210
|
+
total = this.db
|
|
211
|
+
.query('SELECT COUNT(*) as count FROM failed_jobs WHERE type = $type')
|
|
212
|
+
.get({ $type: options.type }).count;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
rows = this.db
|
|
216
|
+
.query('SELECT * FROM failed_jobs ORDER BY failed_at DESC LIMIT $limit OFFSET $offset')
|
|
217
|
+
.all({ $limit: limit, $offset: offset });
|
|
218
|
+
total = this.countFailedStmt.get().count;
|
|
219
|
+
}
|
|
220
|
+
return { items: rows.map(toFailedJob), total };
|
|
221
|
+
}
|
|
222
|
+
listJobs(options) {
|
|
223
|
+
const limit = options?.limit ?? 50;
|
|
224
|
+
const offset = options?.offset ?? 0;
|
|
225
|
+
const conditions = [];
|
|
226
|
+
const filterParams = {};
|
|
227
|
+
if (options?.status) {
|
|
228
|
+
conditions.push('status = $status');
|
|
229
|
+
filterParams.$status = options.status;
|
|
230
|
+
}
|
|
231
|
+
if (options?.type) {
|
|
232
|
+
conditions.push('type = $type');
|
|
233
|
+
filterParams.$type = options.type;
|
|
234
|
+
}
|
|
235
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
236
|
+
const rows = this.db
|
|
237
|
+
.query(`SELECT * FROM jobs ${where} ORDER BY created_at DESC LIMIT $limit OFFSET $offset`)
|
|
238
|
+
.all({ ...filterParams, $limit: limit, $offset: offset });
|
|
239
|
+
const total = this.db
|
|
240
|
+
.query(`SELECT COUNT(*) as count FROM jobs ${where}`)
|
|
241
|
+
.get(filterParams).count;
|
|
242
|
+
return { items: rows.map(toJob), total };
|
|
243
|
+
}
|
|
244
|
+
cancelJob(id) {
|
|
245
|
+
const result = this.db
|
|
246
|
+
.query("UPDATE jobs SET status = 'cancelled', updated_at = $now WHERE id = $id AND status IN ('pending', 'blocked')")
|
|
247
|
+
.run({ $id: id, $now: nowISO() });
|
|
248
|
+
return result.changes > 0;
|
|
249
|
+
}
|
|
250
|
+
forceRetryJob(id) {
|
|
251
|
+
const result = this.db
|
|
252
|
+
.query("UPDATE jobs SET status = 'pending', retry_count = 0, error = NULL, run_at = $now, started_at = NULL, updated_at = $now WHERE id = $id AND status IN ('processing', 'cancelled')")
|
|
253
|
+
.run({ $id: id, $now: nowISO() });
|
|
254
|
+
return result.changes > 0;
|
|
255
|
+
}
|
|
256
|
+
setJobHttpLog(id, requestLog, responseLog) {
|
|
257
|
+
this.db
|
|
258
|
+
.query('UPDATE jobs SET request_log = $req, response_log = $res, updated_at = $now WHERE id = $id')
|
|
259
|
+
.run({ $id: id, $req: requestLog, $res: responseLog, $now: nowISO() });
|
|
260
|
+
}
|
|
261
|
+
getJobTypes() {
|
|
262
|
+
const rows = this.db
|
|
263
|
+
.query('SELECT DISTINCT type FROM jobs ORDER BY type')
|
|
264
|
+
.all();
|
|
265
|
+
return rows.map(r => r.type);
|
|
266
|
+
}
|
|
267
|
+
retryFailedJob(failedJobId) {
|
|
268
|
+
const row = this.db
|
|
269
|
+
.query('SELECT * FROM failed_jobs WHERE id = $id')
|
|
270
|
+
.get({ $id: failedJobId });
|
|
271
|
+
if (!row) {
|
|
272
|
+
throw new Error(`Failed job ${failedJobId} not found`);
|
|
273
|
+
}
|
|
274
|
+
const now = nowISO();
|
|
275
|
+
this.insertJobStmt.run({
|
|
276
|
+
$type: row.type,
|
|
277
|
+
$data: row.data,
|
|
278
|
+
$status: 'pending',
|
|
279
|
+
$priority: 0,
|
|
280
|
+
$maxRetries: row.max_retries,
|
|
281
|
+
$runAt: now,
|
|
282
|
+
$batchId: null
|
|
283
|
+
});
|
|
284
|
+
const newJobId = this.lastInsertRowIdStmt.get();
|
|
285
|
+
this.db
|
|
286
|
+
.query('DELETE FROM failed_jobs WHERE id = $id')
|
|
287
|
+
.run({ $id: failedJobId });
|
|
288
|
+
return newJobId.id;
|
|
289
|
+
}
|
|
290
|
+
purgeFailedJobs(olderThanMs) {
|
|
291
|
+
const cutoff = new Date(Date.now() - olderThanMs).toISOString();
|
|
292
|
+
const result = this.db
|
|
293
|
+
.query('DELETE FROM failed_jobs WHERE failed_at < $cutoff')
|
|
294
|
+
.run({ $cutoff: cutoff });
|
|
295
|
+
return result.changes;
|
|
296
|
+
}
|
|
297
|
+
purge(options) {
|
|
298
|
+
const cutoff = new Date(Date.now() - options.olderThanMs).toISOString();
|
|
299
|
+
const result = this.db
|
|
300
|
+
.query('DELETE FROM jobs WHERE status = $status AND updated_at < $cutoff')
|
|
301
|
+
.run({ $status: options.status, $cutoff: cutoff });
|
|
302
|
+
return result.changes;
|
|
303
|
+
}
|
|
304
|
+
pollAndClaim(type) {
|
|
305
|
+
const now = nowISO();
|
|
306
|
+
const claimTx = this.db.transaction(() => {
|
|
307
|
+
const row = this.selectPendingStmt.get({
|
|
308
|
+
$type: type,
|
|
309
|
+
$now: now
|
|
310
|
+
});
|
|
311
|
+
if (!row)
|
|
312
|
+
return null;
|
|
313
|
+
this.markProcessingStmt.run({ $id: row.id, $now: now });
|
|
314
|
+
return row;
|
|
315
|
+
});
|
|
316
|
+
const row = claimTx.immediate();
|
|
317
|
+
return row ? toJob(row) : null;
|
|
318
|
+
}
|
|
319
|
+
markJobDone(id) {
|
|
320
|
+
this.db.transaction(() => {
|
|
321
|
+
const now = nowISO();
|
|
322
|
+
const row = this.selectJobStmt.get({ $id: id });
|
|
323
|
+
this.markDoneStmt.run({ $id: id, $now: now });
|
|
324
|
+
this.unblockDependents(id);
|
|
325
|
+
if (row?.batch_id) {
|
|
326
|
+
this.handleBatchJobComplete(row.batch_id);
|
|
327
|
+
}
|
|
328
|
+
})();
|
|
329
|
+
}
|
|
330
|
+
markJobDead(id, error) {
|
|
331
|
+
this.db.transaction(() => {
|
|
332
|
+
const row = this.selectJobStmt.get({ $id: id });
|
|
333
|
+
if (!row)
|
|
334
|
+
return;
|
|
335
|
+
this.insertFailedJobStmt.run({
|
|
336
|
+
$originalJobId: row.id,
|
|
337
|
+
$type: row.type,
|
|
338
|
+
$data: row.data,
|
|
339
|
+
$error: error,
|
|
340
|
+
$retryCount: row.retry_count,
|
|
341
|
+
$maxRetries: row.max_retries,
|
|
342
|
+
$createdAt: row.created_at,
|
|
343
|
+
$requestLog: row.request_log,
|
|
344
|
+
$responseLog: row.response_log
|
|
345
|
+
});
|
|
346
|
+
this.deleteJobStmt.run({ $id: id });
|
|
347
|
+
if (row.batch_id) {
|
|
348
|
+
this.incrementBatchFailedStmt.run({
|
|
349
|
+
$id: row.batch_id,
|
|
350
|
+
$jobId: row.id
|
|
351
|
+
});
|
|
352
|
+
this.handleBatchJobComplete(row.batch_id);
|
|
353
|
+
}
|
|
354
|
+
})();
|
|
355
|
+
}
|
|
356
|
+
markJobFailed(id, error) {
|
|
357
|
+
this.db.transaction(() => {
|
|
358
|
+
const now = nowISO();
|
|
359
|
+
const row = this.selectJobStmt.get({ $id: id });
|
|
360
|
+
if (!row)
|
|
361
|
+
return;
|
|
362
|
+
if (row.retry_count + 1 >= row.max_retries) {
|
|
363
|
+
this.insertFailedJobStmt.run({
|
|
364
|
+
$originalJobId: row.id,
|
|
365
|
+
$type: row.type,
|
|
366
|
+
$data: row.data,
|
|
367
|
+
$error: error,
|
|
368
|
+
$retryCount: row.retry_count + 1,
|
|
369
|
+
$maxRetries: row.max_retries,
|
|
370
|
+
$createdAt: row.created_at,
|
|
371
|
+
$requestLog: row.request_log,
|
|
372
|
+
$responseLog: row.response_log
|
|
373
|
+
});
|
|
374
|
+
this.deleteJobStmt.run({ $id: id });
|
|
375
|
+
// Job is permanently dead — decrement batch counter and track failure
|
|
376
|
+
if (row.batch_id) {
|
|
377
|
+
this.incrementBatchFailedStmt.run({
|
|
378
|
+
$id: row.batch_id,
|
|
379
|
+
$jobId: row.id
|
|
380
|
+
});
|
|
381
|
+
this.handleBatchJobComplete(row.batch_id);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
this.markFailedStmt.run({ $id: id, $error: error, $now: now });
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
}
|
|
389
|
+
updateProgress(id, progress) {
|
|
390
|
+
this.updateProgressStmt.run({
|
|
391
|
+
$id: id,
|
|
392
|
+
$progress: progress,
|
|
393
|
+
$now: nowISO()
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
// --- Batch API ---
|
|
397
|
+
createBatch(name, options) {
|
|
398
|
+
const id = crypto.randomUUID();
|
|
399
|
+
this.insertBatchStmt.run({
|
|
400
|
+
$id: id,
|
|
401
|
+
$name: name,
|
|
402
|
+
$options: options ? JSON.stringify(options) : null,
|
|
403
|
+
$createdAt: nowISO()
|
|
404
|
+
});
|
|
405
|
+
return id;
|
|
406
|
+
}
|
|
407
|
+
addToBatch(batchId, type, data, options) {
|
|
408
|
+
const jobId = this.insertJob(type, data, batchId, options);
|
|
409
|
+
this.db
|
|
410
|
+
.query('UPDATE job_batches SET total_jobs = total_jobs + 1, pending_jobs = pending_jobs + 1 WHERE id = $id')
|
|
411
|
+
.run({ $id: batchId });
|
|
412
|
+
return jobId;
|
|
413
|
+
}
|
|
414
|
+
getBatch(batchId) {
|
|
415
|
+
const row = this.selectBatchStmt.get({
|
|
416
|
+
$id: batchId
|
|
417
|
+
});
|
|
418
|
+
return row ? toBatch(row) : null;
|
|
419
|
+
}
|
|
420
|
+
cancelBatch(batchId) {
|
|
421
|
+
const now = nowISO();
|
|
422
|
+
this.cancelBatchStmt.run({ $id: batchId, $now: now });
|
|
423
|
+
this.cancelBatchJobsStmt.run({ $batchId: batchId, $now: now });
|
|
424
|
+
}
|
|
425
|
+
createWorker(options) {
|
|
426
|
+
return new JobWorker(this, options);
|
|
427
|
+
}
|
|
428
|
+
insertJob(type, data, batchId, options) {
|
|
429
|
+
const now = nowISO();
|
|
430
|
+
const runAt = options?.runAt ? options.runAt.toISOString() : now;
|
|
431
|
+
const hasDeps = options?.dependsOn && options.dependsOn.length > 0;
|
|
432
|
+
const status = hasDeps ? 'blocked' : 'pending';
|
|
433
|
+
this.insertJobStmt.run({
|
|
434
|
+
$type: type,
|
|
435
|
+
$data: JSON.stringify(data),
|
|
436
|
+
$status: status,
|
|
437
|
+
$priority: options?.priority ?? 0,
|
|
438
|
+
$maxRetries: options?.maxRetries ?? 3,
|
|
439
|
+
$runAt: runAt,
|
|
440
|
+
$batchId: batchId
|
|
441
|
+
});
|
|
442
|
+
const jobId = this.lastInsertRowIdStmt.get();
|
|
443
|
+
if (hasDeps) {
|
|
444
|
+
for (const depId of options.dependsOn) {
|
|
445
|
+
const dep = this.selectJobStmt.get({ $id: depId });
|
|
446
|
+
if (!dep) {
|
|
447
|
+
throw new Error(`Dependency job ${depId} does not exist`);
|
|
448
|
+
}
|
|
449
|
+
this.insertDepStmt.run({
|
|
450
|
+
$jobId: jobId.id,
|
|
451
|
+
$depsOnId: depId
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return jobId.id;
|
|
456
|
+
}
|
|
457
|
+
close() {
|
|
458
|
+
try {
|
|
459
|
+
if (this.db.filename !== ':memory:' && this.db.filename !== '') {
|
|
460
|
+
this.db.run('PRAGMA wal_checkpoint(PASSIVE)');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// ignore checkpoint errors on close
|
|
465
|
+
}
|
|
466
|
+
this.db.close();
|
|
467
|
+
}
|
|
468
|
+
unblockDependents(completedJobId) {
|
|
469
|
+
const now = nowISO();
|
|
470
|
+
const dependents = this.selectDependentsStmt.all({
|
|
471
|
+
$depsOnId: completedJobId
|
|
472
|
+
});
|
|
473
|
+
for (const dep of dependents) {
|
|
474
|
+
const unmetCount = this.countUnmetDepsStmt.get({
|
|
475
|
+
$jobId: dep.job_id
|
|
476
|
+
});
|
|
477
|
+
if (unmetCount.count === 0) {
|
|
478
|
+
this.unblockJobStmt.run({ $id: dep.job_id, $now: now });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
handleBatchJobComplete(batchId) {
|
|
483
|
+
this.decrementBatchPendingStmt.run({ $id: batchId });
|
|
484
|
+
const batch = this.selectBatchStmt.get({
|
|
485
|
+
$id: batchId
|
|
486
|
+
});
|
|
487
|
+
if (!batch || batch.pending_jobs > 0)
|
|
488
|
+
return;
|
|
489
|
+
// Batch is complete
|
|
490
|
+
const now = nowISO();
|
|
491
|
+
this.finishBatchStmt.run({ $id: batchId, $now: now });
|
|
492
|
+
const options = batch.options
|
|
493
|
+
? JSON.parse(batch.options)
|
|
494
|
+
: null;
|
|
495
|
+
if (!options)
|
|
496
|
+
return;
|
|
497
|
+
// Enqueue "then" callback job only if zero failures
|
|
498
|
+
if (batch.failed_jobs === 0 && options.thenType) {
|
|
499
|
+
this.insertJobStmt.run({
|
|
500
|
+
$type: options.thenType,
|
|
501
|
+
$data: JSON.stringify(options.thenData ?? {}),
|
|
502
|
+
$status: 'pending',
|
|
503
|
+
$priority: 0,
|
|
504
|
+
$maxRetries: 3,
|
|
505
|
+
$runAt: now,
|
|
506
|
+
$batchId: null
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
// Enqueue "finally" callback job regardless of failures
|
|
510
|
+
if (options.finallyType) {
|
|
511
|
+
this.insertJobStmt.run({
|
|
512
|
+
$type: options.finallyType,
|
|
513
|
+
$data: JSON.stringify(options.finallyData ?? {}),
|
|
514
|
+
$status: 'pending',
|
|
515
|
+
$priority: 0,
|
|
516
|
+
$maxRetries: 3,
|
|
517
|
+
$runAt: now,
|
|
518
|
+
$batchId: null
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class SlidingWindowRateLimiter {
|
|
2
|
+
private timestamps;
|
|
3
|
+
private maxCount;
|
|
4
|
+
private windowMs;
|
|
5
|
+
constructor(maxCount: number, windowMs: number);
|
|
6
|
+
canProceed(): boolean;
|
|
7
|
+
record(): void;
|
|
8
|
+
reset(): void;
|
|
9
|
+
private prune;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=rate-limiter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../src/rate-limiter.ts"],"names":[],"mappings":"AAGA,qBAAa,wBAAwB;IACnC,OAAO,CAAC,UAAU,CAAgB;IAClC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAS;gBAEb,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;IAK9C,UAAU,IAAI,OAAO;IAKrB,MAAM,IAAI,IAAI;IAId,KAAK,IAAI,IAAI;IAIb,OAAO,CAAC,KAAK;CAMd"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// packages/jobs/src/rate-limiter.ts
|
|
2
|
+
// In-memory sliding window rate limiter for per-type job throttling
|
|
3
|
+
export class SlidingWindowRateLimiter {
|
|
4
|
+
timestamps = [];
|
|
5
|
+
maxCount;
|
|
6
|
+
windowMs;
|
|
7
|
+
constructor(maxCount, windowMs) {
|
|
8
|
+
this.maxCount = maxCount;
|
|
9
|
+
this.windowMs = windowMs;
|
|
10
|
+
}
|
|
11
|
+
canProceed() {
|
|
12
|
+
this.prune();
|
|
13
|
+
return this.timestamps.length < this.maxCount;
|
|
14
|
+
}
|
|
15
|
+
record() {
|
|
16
|
+
this.timestamps.push(Date.now());
|
|
17
|
+
}
|
|
18
|
+
reset() {
|
|
19
|
+
this.timestamps = [];
|
|
20
|
+
}
|
|
21
|
+
prune() {
|
|
22
|
+
const cutoff = Date.now() - this.windowMs;
|
|
23
|
+
while (this.timestamps.length > 0 && this.timestamps[0] < cutoff) {
|
|
24
|
+
this.timestamps.shift();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { JobQueue } from './queue';
|
|
2
|
+
import type { AddScheduleOptions, JobMap, Schedule } from './types';
|
|
3
|
+
export declare class Scheduler<TMap extends JobMap = Record<string, unknown>> {
|
|
4
|
+
private readonly queue;
|
|
5
|
+
private timer;
|
|
6
|
+
private readonly upsertStmt;
|
|
7
|
+
private readonly selectByNameStmt;
|
|
8
|
+
private readonly selectAllStmt;
|
|
9
|
+
private readonly selectDueStmt;
|
|
10
|
+
private readonly updateLastRunStmt;
|
|
11
|
+
private readonly pauseStmt;
|
|
12
|
+
private readonly resumeStmt;
|
|
13
|
+
private readonly removeStmt;
|
|
14
|
+
constructor(queue: JobQueue<TMap>);
|
|
15
|
+
register(name: string, type: string, cron: string, options?: AddScheduleOptions): Schedule;
|
|
16
|
+
cleanup(registeredNames: string[]): number;
|
|
17
|
+
pauseSchedule(name: string): void;
|
|
18
|
+
resumeSchedule(name: string): void;
|
|
19
|
+
getSchedules(): Schedule[];
|
|
20
|
+
getSchedule(name: string): Schedule | null;
|
|
21
|
+
removeSchedule(name: string): boolean;
|
|
22
|
+
tick(): number;
|
|
23
|
+
start(intervalMs?: number): void;
|
|
24
|
+
stop(): void;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=scheduler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scheduler.d.ts","sourceRoot":"","sources":["../src/scheduler.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACxC,OAAO,KAAK,EACV,kBAAkB,EAClB,MAAM,EACN,QAAQ,EAET,MAAM,SAAS,CAAC;AAqBjB,qBAAa,SAAS,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAClE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;IACvC,OAAO,CAAC,KAAK,CAA+C;IAE5D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAC5B,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC;IAClC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC;IAC/B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC;IAC/B,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC;IAC3B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAC5B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;gBAEhB,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC;IAwCjC,QAAQ,CACN,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,kBAAkB,GAC3B,QAAQ;IAoBX,OAAO,CAAC,eAAe,EAAE,MAAM,EAAE,GAAG,MAAM;IAoB1C,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIjC,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIlC,YAAY,IAAI,QAAQ,EAAE;IAK1B,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI;IAO1C,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAKrC,IAAI,IAAI,MAAM;IA0Dd,KAAK,CAAC,UAAU,SAAS,GAAG,IAAI;IAKhC,IAAI,IAAI,IAAI;CAMb"}
|