@coderule/qulite
Version:
SQLite queue for outbox/synchronization: UPSERT, dedup, leases, transactions.
473 lines (470 loc) • 15.4 kB
JavaScript
// src/types.ts
var JobStatus = /* @__PURE__ */ ((JobStatus2) => {
JobStatus2[JobStatus2["Pending"] = 0] = "Pending";
JobStatus2[JobStatus2["Processing"] = 1] = "Processing";
JobStatus2[JobStatus2["Done"] = 2] = "Done";
JobStatus2[JobStatus2["Failed"] = 3] = "Failed";
return JobStatus2;
})(JobStatus || {});
var nullLogger = {
error: () => {
},
warn: () => {
},
info: () => {
},
debug: () => {
}
};
// src/queue.ts
var Qulite = class {
constructor(db, opts = {}) {
this.db = db;
this.defaultLeaseMs = opts.defaultLeaseMs ?? 3e4;
this.defaultMaxAttempts = opts.defaultMaxAttempts ?? 25;
this.db.pragma("journal_mode = WAL");
this.db.pragma("synchronous = NORMAL");
this.db.pragma(`busy_timeout = ${opts.busyTimeoutMs ?? 5e3}`);
this.ensureSchema();
this.prepareStatements();
}
/** Initialize schema (see src/schema.sql) */
ensureSchema() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS qulite_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
status INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 0,
run_after INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 25,
lease_owner TEXT,
lease_expires_at INTEGER,
last_error TEXT,
done_at INTEGER,
failed_at INTEGER,
dedupe_key TEXT,
root_id TEXT,
rel_path TEXT,
kind TEXT,
to_path TEXT,
size INTEGER,
mtime_ns INTEGER,
sha256 TEXT,
data TEXT
)
`);
this.db.exec(
`CREATE INDEX IF NOT EXISTS idx_qulite_jobs_status_run_after ON qulite_jobs(status, run_after)`
);
this.db.exec(
`CREATE INDEX IF NOT EXISTS idx_qulite_jobs_lease ON qulite_jobs(status, lease_expires_at)`
);
this.db.exec(
`CREATE INDEX IF NOT EXISTS idx_qulite_jobs_type ON qulite_jobs(type)`
);
this.db.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS uq_qulite_jobs_dedupe ON qulite_jobs(dedupe_key)`
);
this.db.exec(
`CREATE INDEX IF NOT EXISTS idx_qulite_jobs_root_rel ON qulite_jobs(root_id, rel_path)`
);
}
prepareStatements() {
this.stmtInsertFsEvent = this.db.prepare(`
INSERT INTO qulite_jobs (
type, status, priority, run_after, created_at, updated_at,
attempts, max_attempts,
dedupe_key,
root_id, rel_path, kind, to_path, size, mtime_ns, sha256, data
)
VALUES (
'fs_event', ${0 /* Pending */}, , , , ,
0, ,
,
, , , , , , ,
)
ON CONFLICT(dedupe_key) DO UPDATE SET
-- Coalescence:
-- 1) unlink overrides everything
-- 2) add + modify => stays as add
-- 3) move keeps path even with subsequent modify/add (path is more important)
kind = CASE
WHEN excluded.kind = 'unlink' THEN 'unlink'
WHEN qulite_jobs.kind = 'move' AND excluded.kind IN ('modify','add') THEN 'move'
WHEN qulite_jobs.kind = 'add' AND excluded.kind = 'modify' THEN 'add'
ELSE excluded.kind
END,
-- to_path: preserve from move or keep previous if previous was move
to_path = CASE
WHEN excluded.kind = 'move' THEN excluded.to_path
WHEN qulite_jobs.kind = 'move' AND excluded.kind IN ('modify','add') THEN qulite_jobs.to_path
ELSE qulite_jobs.to_path
END,
-- Update attributes to latest known values
sha256 = COALESCE(excluded.sha256, qulite_jobs.sha256),
mtime_ns = COALESCE(excluded.mtime_ns, qulite_jobs.mtime_ns),
size = COALESCE(excluded.size, qulite_jobs.size),
data = COALESCE(excluded.data, qulite_jobs.data),
status = ${0 /* Pending */},
attempts = 0,
updated_at = excluded.updated_at
`);
this.stmtInsertGeneric = this.db.prepare(`
INSERT INTO qulite_jobs (
type, status, priority, run_after, created_at, updated_at,
attempts, max_attempts,
dedupe_key,
data
)
VALUES (
, ${0 /* Pending */}, , , , ,
0, ,
,
)
ON CONFLICT(dedupe_key) DO UPDATE SET
data = COALESCE(excluded.data, qulite_jobs.data),
status = ${0 /* Pending */},
attempts = 0,
updated_at = excluded.updated_at
`);
this.stmtSelectPendingAny = this.db.prepare(`
SELECT id
FROM qulite_jobs
WHERE status = ${0 /* Pending */}
AND run_after <=
AND attempts < max_attempts
ORDER BY priority DESC, created_at ASC, id ASC
LIMIT 1
`);
this.stmtSelectPendingByType = this.db.prepare(`
SELECT id
FROM qulite_jobs
WHERE status = ${0 /* Pending */}
AND run_after <=
AND attempts < max_attempts
AND type =
ORDER BY priority DESC, created_at ASC, id ASC
LIMIT 1
`);
this.stmtUpdateClaimById = this.db.prepare(`
UPDATE qulite_jobs
SET status = ${1 /* Processing */},
lease_owner = ,
lease_expires_at = ,
attempts = attempts + 1,
updated_at =
WHERE id =
AND status = ${0 /* Pending */}
AND run_after <=
AND attempts < max_attempts
`);
this.stmtGetById = this.db.prepare(
`SELECT * FROM qulite_jobs WHERE id = ?`
);
this.stmtAckByOwner = this.db.prepare(`
UPDATE qulite_jobs
SET status = ${2 /* Done */},
lease_owner = NULL,
lease_expires_at = NULL,
done_at = ,
updated_at =
WHERE id =
AND status = ${1 /* Processing */}
AND lease_owner =
`);
this.stmtFailByOwner = this.db.prepare(`
UPDATE qulite_jobs
SET status = ${3 /* Failed */},
lease_owner = NULL,
lease_expires_at = NULL,
failed_at = ,
last_error = COALESCE( , last_error),
updated_at =
WHERE id =
AND lease_owner =
`);
this.stmtNackByOwner = this.db.prepare(`
UPDATE qulite_jobs
SET status = ${0 /* Pending */},
lease_owner = NULL,
lease_expires_at = NULL,
run_after = ,
updated_at =
WHERE id =
AND lease_owner =
`);
this.stmtGetAttemptsById = this.db.prepare(`
SELECT attempts, max_attempts
FROM qulite_jobs
WHERE id = ?
`);
this.stmtFailTimedOutAtLimit = this.db.prepare(`
UPDATE qulite_jobs
SET status = ${3 /* Failed */},
lease_owner = NULL,
lease_expires_at = NULL,
failed_at = ,
last_error = COALESCE(last_error, 'lease timed out and max attempts reached'),
updated_at =
WHERE status = ${1 /* Processing */}
AND lease_expires_at IS NOT NULL
AND lease_expires_at <=
AND attempts >= max_attempts
`);
this.stmtRequeueTimedOutUnderLimit = this.db.prepare(`
UPDATE qulite_jobs
SET status = ${0 /* Pending */},
lease_owner = NULL,
lease_expires_at = NULL,
updated_at =
WHERE status = ${1 /* Processing */}
AND lease_expires_at IS NOT NULL
AND lease_expires_at <=
AND attempts < max_attempts
`);
this.stmtCountByStatus = this.db.prepare(`
SELECT COUNT(*) as c FROM qulite_jobs WHERE status =
`);
this.stmtDeleteDoneOlder = this.db.prepare(`
DELETE FROM qulite_jobs
WHERE status = ${2 /* Done */}
AND done_at IS NOT NULL
AND done_at <
`);
this.stmtDeleteFailedOlder = this.db.prepare(`
DELETE FROM qulite_jobs
WHERE status = ${3 /* Failed */}
AND failed_at IS NOT NULL
AND failed_at <
`);
}
// ============ API ============
/** UPSERT file event with coalescence. */
upsertFsEvent(params) {
const now = Date.now();
const run_after = now + (params.delayMs ?? 0);
const priority = params.priority ?? 0;
const max_attempts = params.maxAttempts ?? this.defaultMaxAttempts;
const dedupe_key = `fs:${params.root_id}:${params.rel_path}`;
const res = this.stmtInsertFsEvent.run({
priority,
run_after,
now,
max_attempts,
dedupe_key,
root_id: params.root_id,
rel_path: params.rel_path,
kind: params.kind,
to_path: params.to_path ?? null,
size: params.size ?? null,
mtime_ns: params.mtime_ns ?? null,
sha256: params.sha256 ?? null,
data: params.data ? JSON.stringify(params.data) : null
});
return { id: Number(res.lastInsertRowid), changes: res.changes };
}
/** Universal UPSERT by dedupe_key (snapshot/heartbeat/etc.). */
upsertGeneric(params) {
const now = Date.now();
const run_after = now + (params.delayMs ?? 0);
const priority = params.priority ?? 0;
const max_attempts = params.maxAttempts ?? this.defaultMaxAttempts;
const res = this.stmtInsertGeneric.run({
type: params.type,
priority,
run_after,
now,
max_attempts,
dedupe_key: params.dedupe_key,
data: params.data ? JSON.stringify(params.data) : null
});
return { id: Number(res.lastInsertRowid), changes: res.changes };
}
/** Atomic claim of next job (lease). Returns full job or undefined. */
claimNext(opts = {}) {
const now = opts.now ?? Date.now();
const leaseOwner = opts.leaseOwner ?? `worker-${process.pid}-${Math.random().toString(36).slice(2)}`;
const leaseMs = opts.leaseMs ?? this.defaultLeaseMs;
const lease_expires_at = now + leaseMs;
const tx = this.db.transaction(() => {
const row = opts.type ? this.stmtSelectPendingByType.get({ now, type: opts.type }) : this.stmtSelectPendingAny.get({ now });
if (!row?.id) return void 0;
const upd = this.stmtUpdateClaimById.run({
id: row.id,
lease_owner: leaseOwner,
lease_expires_at,
now
});
if (upd.changes === 0) {
return void 0;
}
const full = this.stmtGetById.get(row.id);
return full;
});
return tx();
}
/** ACK - complete job. Guard by lease_owner. */
ack(id, leaseOwner) {
const now = Date.now();
const res = this.stmtAckByOwner.run({ id, lease_owner: leaseOwner, now });
return res.changes > 0;
}
/** FAIL - mark as Failed. Guard by lease_owner. */
fail(id, leaseOwner, error) {
const now = Date.now();
const res = this.stmtFailByOwner.run({
id,
lease_owner: leaseOwner,
now,
error: error ?? null
});
return res.changes > 0;
}
/**
* RETRY - return to Pending with delay. If attempts exhausted, transitions to Failed.
* Guard by lease_owner.
*/
retry(id, leaseOwner, delayMs = 0) {
const row = this.stmtGetAttemptsById.get(id);
if (!row) return false;
if (row.attempts >= row.max_attempts) {
return this.fail(id, leaseOwner, "max attempts reached");
}
const now = Date.now();
const res = this.stmtNackByOwner.run({
id,
lease_owner: leaseOwner,
now,
run_after: now + delayMs
});
return res.changes > 0;
}
/**
* Requeue expired leases:
* - those with attempts >= max_attempts → Failed
* - otherwise → Pending
* Returns total number of changed rows.
*/
requeueTimedOut() {
const now = Date.now();
const a = this.stmtFailTimedOutAtLimit.run({ now }).changes;
const b = this.stmtRequeueTimedOutUnderLimit.run({ now }).changes;
return a + b;
}
/** Count by status */
countByStatus(status) {
const row = this.stmtCountByStatus.get({ status });
return row.c;
}
/** Delete old completed/failed */
cleanupDone(olderThanMs) {
const threshold = Date.now() - olderThanMs;
return this.stmtDeleteDoneOlder.run({ threshold }).changes;
}
cleanupFailed(olderThanMs) {
const threshold = Date.now() - olderThanMs;
return this.stmtDeleteFailedOlder.run({ threshold }).changes;
}
};
// src/worker.ts
var Worker = class {
constructor(opts) {
this.running = false;
this.queue = opts.queue;
this.type = opts.type;
this.poll = opts.pollIntervalMs ?? 500;
this.log = opts.logger ?? nullLogger;
this.processor = opts.processor;
this.leaseOwner = `worker-${process.pid}-${Math.random().toString(36).slice(2)}`;
}
async start() {
this.running = true;
this.log.info(
`[qulite] worker started (type=${this.type ?? "*"}) owner=${this.leaseOwner}`
);
while (this.running) {
try {
const requeued = this.queue.requeueTimedOut();
if (requeued > 0) {
this.log.debug(`[qulite] requeued timed-out: ${requeued}`);
}
const job = this.queue.claimNext({
type: this.type,
leaseOwner: this.leaseOwner
});
if (!job) {
await new Promise((r) => setTimeout(r, this.poll));
continue;
}
const ctx = {
leaseOwner: this.leaseOwner,
ack: () => this.queue.ack(job.id, this.leaseOwner),
fail: (err) => this.queue.fail(job.id, this.leaseOwner, err),
retry: (delayMs) => this.queue.retry(job.id, this.leaseOwner, delayMs ?? 0)
};
let result;
try {
result = await this.processor(job, ctx);
} catch (err) {
this.log.error(
`[qulite] processor crash for job ${job.id}: ${err.message}`
);
ctx.fail(err.stack || err.message);
continue;
}
if (result.kind === "ack") ctx.ack();
else if (result.kind === "retry") ctx.retry(result.delayMs);
else ctx.fail(result.error);
} catch (err) {
this.log.error(
`[qulite] worker loop error: ${err.stack || err.message}`
);
await new Promise((r) => setTimeout(r, this.poll));
}
}
this.log.info("[qulite] worker stopped");
}
stop() {
this.running = false;
}
};
// src/fs-outbox.ts
function enqueueFsEvent(q, params) {
if (params.kind === "snapshot" || params.kind === "heartbeat") {
const dedupe_key = `${params.kind}:${params.root_id}`;
return q.upsertGeneric({
type: "fs_control",
dedupe_key,
data: params.data ?? { root_id: params.root_id },
priority: params.priority,
delayMs: params.delayMs,
maxAttempts: params.maxAttempts
});
}
return q.upsertFsEvent({
root_id: params.root_id,
rel_path: params.rel_path,
kind: params.kind,
to_path: params.to_path ?? null,
size: params.size ?? null,
mtime_ns: params.mtime_ns ?? null,
sha256: params.sha256 ?? null,
priority: params.priority,
delayMs: params.delayMs,
data: params.data,
maxAttempts: params.maxAttempts
});
}
export {
JobStatus,
Qulite,
Worker,
enqueueFsEvent,
nullLogger
};
//# sourceMappingURL=index.js.map