UNPKG

@coderule/qulite

Version:

SQLite queue for outbox/synchronization: UPSERT, dedup, leases, transactions.

473 lines (470 loc) 15.4 kB
// 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 */}, @priority, @run_after, @now, @now, 0, @max_attempts, @dedupe_key, @root_id, @rel_path, @kind, @to_path, @size, @mtime_ns, @sha256, @data ) 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 ( @type, ${0 /* Pending */}, @priority, @run_after, @now, @now, 0, @max_attempts, @dedupe_key, @data ) 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 <= @now 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 <= @now AND attempts < max_attempts AND type = @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_owner, lease_expires_at = @lease_expires_at, attempts = attempts + 1, updated_at = @now WHERE id = @id AND status = ${0 /* Pending */} AND run_after <= @now 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 = @now, updated_at = @now WHERE id = @id AND status = ${1 /* Processing */} AND lease_owner = @lease_owner `); this.stmtFailByOwner = this.db.prepare(` UPDATE qulite_jobs SET status = ${3 /* Failed */}, lease_owner = NULL, lease_expires_at = NULL, failed_at = @now, last_error = COALESCE(@error, last_error), updated_at = @now WHERE id = @id AND lease_owner = @lease_owner `); this.stmtNackByOwner = this.db.prepare(` UPDATE qulite_jobs SET status = ${0 /* Pending */}, lease_owner = NULL, lease_expires_at = NULL, run_after = @run_after, updated_at = @now WHERE id = @id AND lease_owner = @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 = @now, last_error = COALESCE(last_error, 'lease timed out and max attempts reached'), updated_at = @now WHERE status = ${1 /* Processing */} AND lease_expires_at IS NOT NULL AND lease_expires_at <= @now 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 = @now WHERE status = ${1 /* Processing */} AND lease_expires_at IS NOT NULL AND lease_expires_at <= @now AND attempts < max_attempts `); this.stmtCountByStatus = this.db.prepare(` SELECT COUNT(*) as c FROM qulite_jobs WHERE status = @status `); this.stmtDeleteDoneOlder = this.db.prepare(` DELETE FROM qulite_jobs WHERE status = ${2 /* Done */} AND done_at IS NOT NULL AND done_at < @threshold `); this.stmtDeleteFailedOlder = this.db.prepare(` DELETE FROM qulite_jobs WHERE status = ${3 /* Failed */} AND failed_at IS NOT NULL AND failed_at < @threshold `); } // ============ 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