159 lines
5.6 KiB
TypeScript
159 lines
5.6 KiB
TypeScript
import type { Db } from "@wrnexus/db";
|
|
import type { Job } from "./index.ts";
|
|
import type { QueueJobRecord, QueueStore } from "./durable.ts";
|
|
|
|
const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
const check = (value: string) => {
|
|
if (!SAFE.test(value)) throw new TypeError(`Invalid queue table: ${value}`);
|
|
return value;
|
|
};
|
|
|
|
export async function installDatabaseQueueSchema(db: Db, table = "wrnexus_jobs") {
|
|
const target = check(table);
|
|
await db.exec(`CREATE TABLE IF NOT EXISTS ${target} (
|
|
id VARCHAR(191) PRIMARY KEY, name VARCHAR(191) NOT NULL, payload TEXT NOT NULL,
|
|
run_at BIGINT NOT NULL, priority INTEGER NOT NULL DEFAULT 0,
|
|
idempotency_key VARCHAR(191), lease_owner VARCHAR(191), lease_until BIGINT
|
|
)`);
|
|
await db.exec(`CREATE TABLE IF NOT EXISTS ${target}_history (
|
|
id VARCHAR(191) PRIMARY KEY, name VARCHAR(191) NOT NULL, state VARCHAR(32) NOT NULL,
|
|
payload TEXT NOT NULL, error TEXT, finished_at BIGINT NOT NULL
|
|
)`);
|
|
for (const statement of [
|
|
`CREATE INDEX ${target}_due ON ${target} (run_at, priority)`,
|
|
`CREATE UNIQUE INDEX ${target}_idempotency ON ${target} (name, idempotency_key)`,
|
|
]) {
|
|
try {
|
|
await db.exec(statement);
|
|
} catch (error) {
|
|
if (!/exist|duplicate|already/i.test(String(error))) throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function databaseQueueStore(db: Db, table = "wrnexus_jobs"): QueueStore {
|
|
const target = check(table);
|
|
type Row = { payload: string };
|
|
const decode = (row: Row) => JSON.parse(row.payload) as Job;
|
|
return {
|
|
async put(job) {
|
|
if (db.driver.dialect === "mysql") {
|
|
await db.exec(
|
|
`INSERT INTO ${target} (id,name,payload,run_at,priority,idempotency_key,lease_owner,lease_until)
|
|
VALUES (?,?,?,?,?,?,NULL,NULL) ON DUPLICATE KEY UPDATE name=VALUES(name),payload=VALUES(payload),run_at=VALUES(run_at),priority=VALUES(priority),lease_owner=NULL,lease_until=NULL`,
|
|
[
|
|
job.id,
|
|
job.name,
|
|
JSON.stringify(job),
|
|
job.runAt,
|
|
job.priority,
|
|
job.idempotencyKey ?? null,
|
|
],
|
|
);
|
|
} else {
|
|
await db.exec(
|
|
`INSERT INTO ${target} (id,name,payload,run_at,priority,idempotency_key,lease_owner,lease_until)
|
|
VALUES (?,?,?,?,?,?,NULL,NULL) ON CONFLICT(id) DO UPDATE SET name=excluded.name,payload=excluded.payload,run_at=excluded.run_at,priority=excluded.priority,lease_owner=NULL,lease_until=NULL`,
|
|
[
|
|
job.id,
|
|
job.name,
|
|
JSON.stringify(job),
|
|
job.runAt,
|
|
job.priority,
|
|
job.idempotencyKey ?? null,
|
|
],
|
|
);
|
|
}
|
|
},
|
|
async get(id) {
|
|
const row = await db.one<Row>(`SELECT payload FROM ${target} WHERE id = ?`, [id]);
|
|
return row ? decode(row) : null;
|
|
},
|
|
async remove(id) {
|
|
await db.exec(`DELETE FROM ${target} WHERE id = ?`, [id]);
|
|
},
|
|
async due(now, limit) {
|
|
return (
|
|
await db.all<Row>(
|
|
`SELECT payload FROM ${target} WHERE run_at <= ? AND (lease_until IS NULL OR lease_until < ?) ORDER BY priority DESC,run_at LIMIT ?`,
|
|
[now, now, limit],
|
|
)
|
|
).map(decode);
|
|
},
|
|
async list(name) {
|
|
return (
|
|
await db.all<Row>(
|
|
`SELECT payload FROM ${target}${name ? " WHERE name = ?" : ""} ORDER BY run_at`,
|
|
name ? [name] : [],
|
|
)
|
|
).map(decode);
|
|
},
|
|
async findByIdempotencyKey(name, key) {
|
|
const row = await db.one<Row>(
|
|
`SELECT payload FROM ${target} WHERE name = ? AND idempotency_key = ?`,
|
|
[name, key],
|
|
);
|
|
return row ? decode(row) : null;
|
|
},
|
|
async size() {
|
|
return Number(
|
|
(await db.one<{ total: number | string }>(`SELECT COUNT(*) AS total FROM ${target}`))
|
|
?.total ?? 0,
|
|
);
|
|
},
|
|
async claim(id, worker, leaseUntil, now = Date.now()) {
|
|
return (
|
|
(
|
|
await db.exec(
|
|
`UPDATE ${target} SET lease_owner=?,lease_until=? WHERE id=? AND (lease_until IS NULL OR lease_until < ?)`,
|
|
[worker, leaseUntil, id, now],
|
|
)
|
|
).changes > 0
|
|
);
|
|
},
|
|
async release(id, worker) {
|
|
await db.exec(
|
|
`UPDATE ${target} SET lease_owner=NULL,lease_until=NULL WHERE id=? AND lease_owner=?`,
|
|
[id, worker],
|
|
);
|
|
},
|
|
async archive(record) {
|
|
const values = [
|
|
record.job.id,
|
|
record.job.name,
|
|
record.state,
|
|
JSON.stringify(record.job),
|
|
record.error ?? null,
|
|
record.finishedAt,
|
|
];
|
|
if (db.driver.dialect === "mysql")
|
|
await db.exec(
|
|
`INSERT INTO ${target}_history (id,name,state,payload,error,finished_at) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE state=VALUES(state),payload=VALUES(payload),error=VALUES(error),finished_at=VALUES(finished_at)`,
|
|
values,
|
|
);
|
|
else
|
|
await db.exec(
|
|
`INSERT INTO ${target}_history (id,name,state,payload,error,finished_at) VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET state=excluded.state,payload=excluded.payload,error=excluded.error,finished_at=excluded.finished_at`,
|
|
values,
|
|
);
|
|
},
|
|
async history(id) {
|
|
const rows = await db.all<{
|
|
state: QueueJobRecord["state"];
|
|
payload: string;
|
|
error: string | null;
|
|
finished_at: number;
|
|
}>(
|
|
`SELECT state,payload,error,finished_at FROM ${target}_history${id ? " WHERE id = ?" : ""} ORDER BY finished_at DESC`,
|
|
id ? [id] : [],
|
|
);
|
|
return rows.map((row) => ({
|
|
job: JSON.parse(row.payload),
|
|
state: row.state,
|
|
error: row.error ?? undefined,
|
|
finishedAt: Number(row.finished_at),
|
|
}));
|
|
},
|
|
};
|
|
}
|