workmatic 1.0.7 → 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/README.md +60 -0
- package/dist/cli.cjs +903 -104
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +903 -104
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +256 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +78 -1
- package/dist/index.d.ts +78 -1
- package/dist/index.js +255 -54
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -222,6 +222,29 @@ function createClient(options) {
|
|
|
222
222
|
import fastq from "fastq";
|
|
223
223
|
import { sql as sql2 } from "kysely";
|
|
224
224
|
var DEFAULT_WORKER_TIMEOUT_MS = 6e4;
|
|
225
|
+
function parseClaimedRows(result) {
|
|
226
|
+
const rows = result.rows;
|
|
227
|
+
if (!rows || !Array.isArray(rows)) {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
return rows.map((row) => ({
|
|
231
|
+
id: row.id,
|
|
232
|
+
public_id: row.public_id,
|
|
233
|
+
queue: row.queue,
|
|
234
|
+
payload: row.payload,
|
|
235
|
+
attempts: row.attempts,
|
|
236
|
+
max_attempts: row.max_attempts,
|
|
237
|
+
priority: row.priority,
|
|
238
|
+
created_at: row.created_at,
|
|
239
|
+
last_error: row.last_error
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
function requireProcessor(processor) {
|
|
243
|
+
if (!processor) {
|
|
244
|
+
throw new Error("No processor set");
|
|
245
|
+
}
|
|
246
|
+
return processor;
|
|
247
|
+
}
|
|
225
248
|
function createWorker(options) {
|
|
226
249
|
const {
|
|
227
250
|
db,
|
|
@@ -306,21 +329,7 @@ function createWorker(options) {
|
|
|
306
329
|
)
|
|
307
330
|
RETURNING id, public_id, queue, payload, attempts, max_attempts, priority, created_at, last_error
|
|
308
331
|
`.execute(trx);
|
|
309
|
-
|
|
310
|
-
if (!rows || !Array.isArray(rows)) {
|
|
311
|
-
return [];
|
|
312
|
-
}
|
|
313
|
-
return rows.map((row) => ({
|
|
314
|
-
id: row.id,
|
|
315
|
-
public_id: row.public_id,
|
|
316
|
-
queue: row.queue,
|
|
317
|
-
payload: row.payload,
|
|
318
|
-
attempts: row.attempts,
|
|
319
|
-
max_attempts: row.max_attempts,
|
|
320
|
-
priority: row.priority,
|
|
321
|
-
created_at: row.created_at,
|
|
322
|
-
last_error: row.last_error
|
|
323
|
-
}));
|
|
332
|
+
return parseClaimedRows(result);
|
|
324
333
|
});
|
|
325
334
|
}
|
|
326
335
|
async function markDone(jobId) {
|
|
@@ -368,9 +377,7 @@ function createWorker(options) {
|
|
|
368
377
|
}
|
|
369
378
|
}
|
|
370
379
|
async function processJob(claimedJob) {
|
|
371
|
-
|
|
372
|
-
throw new Error("No processor set");
|
|
373
|
-
}
|
|
380
|
+
const fn = requireProcessor(processor);
|
|
374
381
|
const payload = parsePayload(claimedJob.payload);
|
|
375
382
|
const job = {
|
|
376
383
|
id: claimedJob.public_id,
|
|
@@ -385,9 +392,9 @@ function createWorker(options) {
|
|
|
385
392
|
};
|
|
386
393
|
try {
|
|
387
394
|
if (timeoutMs) {
|
|
388
|
-
await withTimeout(
|
|
395
|
+
await withTimeout(fn(job), timeoutMs, job.id);
|
|
389
396
|
} else {
|
|
390
|
-
await
|
|
397
|
+
await fn(job);
|
|
391
398
|
}
|
|
392
399
|
await markDone(claimedJob.id);
|
|
393
400
|
} catch (error) {
|
|
@@ -545,12 +552,211 @@ function createWorker(options) {
|
|
|
545
552
|
return worker;
|
|
546
553
|
}
|
|
547
554
|
|
|
555
|
+
// src/orchestrator.ts
|
|
556
|
+
import { sql as sql3 } from "kysely";
|
|
557
|
+
var DEFAULT_TRANSFER_STATUSES = ["ready", "dead"];
|
|
558
|
+
var DEFAULT_TRANSFER_LIMIT = 1e4;
|
|
559
|
+
function rowsUpdated(result) {
|
|
560
|
+
const row = result[0];
|
|
561
|
+
return Number(row?.numUpdatedRows ?? 0);
|
|
562
|
+
}
|
|
563
|
+
function normalizeStatuses(status) {
|
|
564
|
+
if (!status) return [...DEFAULT_TRANSFER_STATUSES];
|
|
565
|
+
return Array.isArray(status) ? status : [status];
|
|
566
|
+
}
|
|
567
|
+
function createOrchestrator(options) {
|
|
568
|
+
const { db } = options;
|
|
569
|
+
if (!db) {
|
|
570
|
+
throw new Error("Database instance is required");
|
|
571
|
+
}
|
|
572
|
+
const registry = /* @__PURE__ */ new Map();
|
|
573
|
+
function ensureEntry(queue) {
|
|
574
|
+
let entry = registry.get(queue);
|
|
575
|
+
if (!entry) {
|
|
576
|
+
entry = { client: createClient({ db, queue }) };
|
|
577
|
+
registry.set(queue, entry);
|
|
578
|
+
}
|
|
579
|
+
return entry;
|
|
580
|
+
}
|
|
581
|
+
async function setQueuePaused(queue, paused) {
|
|
582
|
+
const timestamp = now();
|
|
583
|
+
const value = paused ? 1 : 0;
|
|
584
|
+
await sql3`
|
|
585
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
586
|
+
VALUES (${queue}, ${value}, ${timestamp})
|
|
587
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
588
|
+
paused = ${value},
|
|
589
|
+
updated_at = ${timestamp}
|
|
590
|
+
`.execute(db);
|
|
591
|
+
}
|
|
592
|
+
async function selectJobIds(from, status, limit) {
|
|
593
|
+
const rows = await db.selectFrom("workmatic_jobs").select("id").where("queue", "=", from).where("status", "=", status).orderBy("priority", "asc").orderBy("id", "asc").limit(limit).execute();
|
|
594
|
+
return rows.map((r) => r.id);
|
|
595
|
+
}
|
|
596
|
+
async function transferReady(from, to, limit) {
|
|
597
|
+
const ids = await selectJobIds(from, "ready", limit);
|
|
598
|
+
if (ids.length === 0) return 0;
|
|
599
|
+
const timestamp = now();
|
|
600
|
+
const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
|
|
601
|
+
return rowsUpdated(result);
|
|
602
|
+
}
|
|
603
|
+
async function transferDead(from, to, limit, resetForRetry) {
|
|
604
|
+
const ids = await selectJobIds(from, "dead", limit);
|
|
605
|
+
if (ids.length === 0) return 0;
|
|
606
|
+
const timestamp = now();
|
|
607
|
+
if (resetForRetry) {
|
|
608
|
+
const result2 = await db.updateTable("workmatic_jobs").set({
|
|
609
|
+
queue: to,
|
|
610
|
+
status: "ready",
|
|
611
|
+
attempts: 0,
|
|
612
|
+
lease_until: 0,
|
|
613
|
+
last_error: null,
|
|
614
|
+
updated_at: timestamp,
|
|
615
|
+
run_at: timestamp
|
|
616
|
+
}).where("id", "in", ids).execute();
|
|
617
|
+
return rowsUpdated(result2);
|
|
618
|
+
}
|
|
619
|
+
const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
|
|
620
|
+
return rowsUpdated(result);
|
|
621
|
+
}
|
|
622
|
+
async function transferOtherStatus(from, to, status, limit) {
|
|
623
|
+
const ids = await selectJobIds(from, status, limit);
|
|
624
|
+
if (ids.length === 0) return 0;
|
|
625
|
+
const timestamp = now();
|
|
626
|
+
const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
|
|
627
|
+
return rowsUpdated(result);
|
|
628
|
+
}
|
|
629
|
+
const orchestrator = {
|
|
630
|
+
register(queue, opts = {}) {
|
|
631
|
+
const client = createClient({ db, queue });
|
|
632
|
+
let worker;
|
|
633
|
+
if (opts.worker) {
|
|
634
|
+
worker = createWorker({ db, queue, ...opts.worker });
|
|
635
|
+
}
|
|
636
|
+
registry.set(queue, { client, worker });
|
|
637
|
+
return client;
|
|
638
|
+
},
|
|
639
|
+
client(queue) {
|
|
640
|
+
return ensureEntry(queue).client;
|
|
641
|
+
},
|
|
642
|
+
worker(queue) {
|
|
643
|
+
const entry = registry.get(queue);
|
|
644
|
+
if (!entry?.worker) {
|
|
645
|
+
throw new Error(`No worker registered for queue "${queue}"`);
|
|
646
|
+
}
|
|
647
|
+
return entry.worker;
|
|
648
|
+
},
|
|
649
|
+
workers() {
|
|
650
|
+
return [...registry.values()].map((e) => e.worker).filter((w) => w !== void 0);
|
|
651
|
+
},
|
|
652
|
+
async queues() {
|
|
653
|
+
const rows = await db.selectFrom("workmatic_jobs").select("queue").distinct().execute();
|
|
654
|
+
const names = new Set(rows.map((r) => r.queue));
|
|
655
|
+
for (const name of registry.keys()) {
|
|
656
|
+
names.add(name);
|
|
657
|
+
}
|
|
658
|
+
return [...names].sort();
|
|
659
|
+
},
|
|
660
|
+
process(queue, fn) {
|
|
661
|
+
this.worker(queue).process(fn);
|
|
662
|
+
},
|
|
663
|
+
startAll() {
|
|
664
|
+
for (const entry of registry.values()) {
|
|
665
|
+
entry.worker?.start();
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
async stopAll() {
|
|
669
|
+
const stops = [...registry.values()].map((e) => e.worker?.stop()).filter((p) => p !== void 0);
|
|
670
|
+
await Promise.all(stops);
|
|
671
|
+
},
|
|
672
|
+
async pause(queue) {
|
|
673
|
+
const entry = registry.get(queue);
|
|
674
|
+
entry?.worker?.pause();
|
|
675
|
+
await setQueuePaused(queue, true);
|
|
676
|
+
},
|
|
677
|
+
async resume(queue) {
|
|
678
|
+
const entry = registry.get(queue);
|
|
679
|
+
entry?.worker?.resume();
|
|
680
|
+
await setQueuePaused(queue, false);
|
|
681
|
+
},
|
|
682
|
+
async isPaused(queue) {
|
|
683
|
+
const setting = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", queue).executeTakeFirst();
|
|
684
|
+
return setting?.paused === 1;
|
|
685
|
+
},
|
|
686
|
+
async stats(queueName) {
|
|
687
|
+
const names = queueName ? [queueName] : await this.queues();
|
|
688
|
+
const result = {};
|
|
689
|
+
for (const name of names) {
|
|
690
|
+
result[name] = await ensureEntry(name).client.stats();
|
|
691
|
+
}
|
|
692
|
+
return result;
|
|
693
|
+
},
|
|
694
|
+
async transfer(opts) {
|
|
695
|
+
const { from, to, resetForRetry = false } = opts;
|
|
696
|
+
if (from === to) {
|
|
697
|
+
return { moved: 0 };
|
|
698
|
+
}
|
|
699
|
+
const statuses = normalizeStatuses(opts.status);
|
|
700
|
+
let remaining = opts.limit ?? DEFAULT_TRANSFER_LIMIT;
|
|
701
|
+
let moved = 0;
|
|
702
|
+
if (statuses.includes("ready") && remaining > 0) {
|
|
703
|
+
const n = await transferReady(from, to, remaining);
|
|
704
|
+
moved += n;
|
|
705
|
+
remaining -= n;
|
|
706
|
+
}
|
|
707
|
+
if (statuses.includes("dead") && remaining > 0) {
|
|
708
|
+
const n = await transferDead(from, to, remaining, resetForRetry);
|
|
709
|
+
moved += n;
|
|
710
|
+
remaining -= n;
|
|
711
|
+
}
|
|
712
|
+
for (const status of statuses) {
|
|
713
|
+
if (status === "ready" || status === "dead" || remaining <= 0) continue;
|
|
714
|
+
const n = await transferOtherStatus(from, to, status, remaining);
|
|
715
|
+
moved += n;
|
|
716
|
+
remaining -= n;
|
|
717
|
+
}
|
|
718
|
+
return { moved };
|
|
719
|
+
},
|
|
720
|
+
async moveJob(publicId, toQueue, opts = {}) {
|
|
721
|
+
const allowed = normalizeStatuses(opts.status);
|
|
722
|
+
const row = await db.selectFrom("workmatic_jobs").select(["id", "queue", "status"]).where("public_id", "=", publicId).executeTakeFirst();
|
|
723
|
+
if (!row) {
|
|
724
|
+
throw new Error(`Job not found: ${publicId}`);
|
|
725
|
+
}
|
|
726
|
+
if (row.queue === toQueue) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (!allowed.includes(row.status)) {
|
|
730
|
+
throw new Error(
|
|
731
|
+
`Job ${publicId} has status "${row.status}" and cannot be moved (allowed: ${allowed.join(", ")})`
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
const timestamp = now();
|
|
735
|
+
const resetForRetry = opts.resetForRetry ?? false;
|
|
736
|
+
if (row.status === "dead" && resetForRetry) {
|
|
737
|
+
await db.updateTable("workmatic_jobs").set({
|
|
738
|
+
queue: toQueue,
|
|
739
|
+
status: "ready",
|
|
740
|
+
attempts: 0,
|
|
741
|
+
lease_until: 0,
|
|
742
|
+
last_error: null,
|
|
743
|
+
updated_at: timestamp,
|
|
744
|
+
run_at: timestamp
|
|
745
|
+
}).where("public_id", "=", publicId).execute();
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
await db.updateTable("workmatic_jobs").set({ queue: toQueue, updated_at: timestamp }).where("public_id", "=", publicId).execute();
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
return orchestrator;
|
|
752
|
+
}
|
|
753
|
+
|
|
548
754
|
// src/dashboard.ts
|
|
549
755
|
import { createServer } from "http";
|
|
550
756
|
import { fileURLToPath } from "url";
|
|
551
757
|
import { dirname, join } from "path";
|
|
552
758
|
import { readFile } from "fs/promises";
|
|
553
|
-
import { sql as
|
|
759
|
+
import { sql as sql4 } from "kysely";
|
|
554
760
|
var __filename2 = fileURLToPath(import.meta.url);
|
|
555
761
|
var __dirname2 = dirname(__filename2);
|
|
556
762
|
var CONTENT_TYPES = {
|
|
@@ -559,6 +765,13 @@ var CONTENT_TYPES = {
|
|
|
559
765
|
".js": "application/javascript; charset=utf-8",
|
|
560
766
|
".json": "application/json; charset=utf-8"
|
|
561
767
|
};
|
|
768
|
+
function requestUrl(url) {
|
|
769
|
+
return url || "";
|
|
770
|
+
}
|
|
771
|
+
function staticContentTypeFor(filePath) {
|
|
772
|
+
const ext = filePath.substring(filePath.lastIndexOf(".")) || ".html";
|
|
773
|
+
return CONTENT_TYPES[ext] || "application/octet-stream";
|
|
774
|
+
}
|
|
562
775
|
function createRequestHandler(db, workerMap, basePath = "") {
|
|
563
776
|
function sendJson(res, data, status = 200) {
|
|
564
777
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
@@ -581,7 +794,7 @@ function createRequestHandler(db, workerMap, basePath = "") {
|
|
|
581
794
|
return path;
|
|
582
795
|
}
|
|
583
796
|
async function handleGetJobs(req, res) {
|
|
584
|
-
const query = parseQuery(req.url
|
|
797
|
+
const query = parseQuery(requestUrl(req.url));
|
|
585
798
|
const queueFilter = query.get("queue");
|
|
586
799
|
const statusFilter = query.get("status");
|
|
587
800
|
const limit = Math.min(parseInt(query.get("limit") || "50", 10), 100);
|
|
@@ -622,11 +835,11 @@ function createRequestHandler(db, workerMap, basePath = "") {
|
|
|
622
835
|
sendJson(res, { jobs: apiJobs, limit, offset });
|
|
623
836
|
}
|
|
624
837
|
async function handleGetStats(req, res) {
|
|
625
|
-
const query = parseQuery(req.url
|
|
838
|
+
const query = parseQuery(requestUrl(req.url));
|
|
626
839
|
const queueFilter = query.get("queue");
|
|
627
840
|
let statsQuery = db.selectFrom("workmatic_jobs").select([
|
|
628
841
|
"status",
|
|
629
|
-
|
|
842
|
+
sql4`count(*)`.as("count")
|
|
630
843
|
]).groupBy("status");
|
|
631
844
|
if (queueFilter) {
|
|
632
845
|
statsQuery = statsQuery.where("queue", "=", queueFilter);
|
|
@@ -708,8 +921,7 @@ function createRequestHandler(db, workerMap, basePath = "") {
|
|
|
708
921
|
async function serveStatic(res, filePath) {
|
|
709
922
|
const dashboardDir = join(__dirname2, "..", "dashboard");
|
|
710
923
|
const fullPath = join(dashboardDir, filePath);
|
|
711
|
-
const
|
|
712
|
-
const contentType = CONTENT_TYPES[ext] || "application/octet-stream";
|
|
924
|
+
const contentType = staticContentTypeFor(filePath);
|
|
713
925
|
try {
|
|
714
926
|
const content = await readFile(fullPath, "utf-8");
|
|
715
927
|
res.writeHead(200, { "Content-Type": contentType });
|
|
@@ -719,21 +931,21 @@ function createRequestHandler(db, workerMap, basePath = "") {
|
|
|
719
931
|
}
|
|
720
932
|
}
|
|
721
933
|
return async function handleRequest(req, res, next) {
|
|
722
|
-
const path = getPath(req.url || "/");
|
|
723
|
-
const fullUrl = req.url || "/";
|
|
724
|
-
if (basePath && !fullUrl.startsWith(basePath)) {
|
|
725
|
-
if (next) next();
|
|
726
|
-
return false;
|
|
727
|
-
}
|
|
728
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
729
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
730
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
731
|
-
if (req.method === "OPTIONS") {
|
|
732
|
-
res.writeHead(204);
|
|
733
|
-
res.end();
|
|
734
|
-
return true;
|
|
735
|
-
}
|
|
736
934
|
try {
|
|
935
|
+
const path = getPath(req.url || "/");
|
|
936
|
+
const fullUrl = req.url || "/";
|
|
937
|
+
if (basePath && !fullUrl.startsWith(basePath)) {
|
|
938
|
+
if (next) next();
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
942
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
943
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
944
|
+
if (req.method === "OPTIONS") {
|
|
945
|
+
res.writeHead(204);
|
|
946
|
+
res.end();
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
737
949
|
if (path === "/api/jobs" && req.method === "GET") {
|
|
738
950
|
await handleGetJobs(req, res);
|
|
739
951
|
return true;
|
|
@@ -793,11 +1005,7 @@ function createDashboard(options) {
|
|
|
793
1005
|
}
|
|
794
1006
|
const handleRequest = createRequestHandler(db, workerMap);
|
|
795
1007
|
const server = createServer((req, res) => {
|
|
796
|
-
handleRequest(req, res)
|
|
797
|
-
console.error("[workmatic] Unhandled error:", error);
|
|
798
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
799
|
-
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
800
|
-
});
|
|
1008
|
+
void handleRequest(req, res);
|
|
801
1009
|
});
|
|
802
1010
|
server.listen(port);
|
|
803
1011
|
return {
|
|
@@ -829,15 +1037,7 @@ function createDashboardMiddleware(options) {
|
|
|
829
1037
|
}
|
|
830
1038
|
const handleRequest = createRequestHandler(db, workerMap, basePath);
|
|
831
1039
|
return (req, res, next) => {
|
|
832
|
-
handleRequest(req, res, next)
|
|
833
|
-
console.error("[workmatic] Unhandled error:", error);
|
|
834
|
-
if (next) {
|
|
835
|
-
next();
|
|
836
|
-
} else {
|
|
837
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
838
|
-
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
839
|
-
}
|
|
840
|
-
});
|
|
1040
|
+
void handleRequest(req, res, next);
|
|
841
1041
|
};
|
|
842
1042
|
}
|
|
843
1043
|
export {
|
|
@@ -846,6 +1046,7 @@ export {
|
|
|
846
1046
|
createDashboard,
|
|
847
1047
|
createDashboardMiddleware,
|
|
848
1048
|
createDatabase,
|
|
1049
|
+
createOrchestrator,
|
|
849
1050
|
createWorker,
|
|
850
1051
|
defaultBackoff,
|
|
851
1052
|
getUnderlyingDb,
|