import { type Fetch, type Params, TaskStore } from "./base.js"; /** * SQLiteStore — the SQLite dialect of the shared cairnq-protocol SQL. * * Everything protocol-shaped lives in TaskStore; this file is only what SQLite * does differently: better-sqlite3's synchronous driver, BEGIN IMMEDIATE * transactions, a read-only probe in front of the write lock, and time supplied * by the SDK (`:now_ms`) rather than by the database. * * The driver being synchronous suits SQLite's single writer: claim is one short * transaction, the handler runs outside any transaction, and * progress/heartbeat/succeed/fail are each their own short write. * * Cross-process contention is absorbed by retrying in JavaScript, not by * busy_timeout. The two cost the same wait but not the same blocking: a nonzero * busy_timeout waits *inside* the synchronous driver, so a caller that loses the * write lock stalls this process's event loop for up to the whole timeout — the * P99 of an HTTP server that submits tasks. Executing a statement takes * microseconds; waiting for a lock takes milliseconds to seconds, and only the * second part needs to happen off the thread. So busy_timeout goes to 0 (fail * immediately) and the wait becomes an awaited backoff, which the event loop runs * through. The budget is the same either way — `busyTimeoutMs`. * * The open path keeps a real busy_timeout: it is synchronous by nature (WAL * switch, migrations) and happens once, under the caller's `connect()`. */ export declare class SQLiteStore extends TaskStore { private readonly path; private db; private stmts; private readonly statements; /** This store's entry in `fileLocks` — see there for why it is per-database. */ private readonly lockKey; /** How long a single operation may keep retrying a lost write lock. */ private readonly busyBudgetMs; /** When this connection may next revisit its planner statistics. */ private nextStatsRefreshAt; /** Which statements are writes — see isWriteStatement. */ private readonly writes; /** Writes waiting to be group-committed — see flush(). */ private pending; /** Whether a flusher is already queued to drain `pending`. */ private flushing; constructor(path: string, opts?: { busyTimeoutMs?: number; }); connect(): Promise; close(): Promise; private ensure; private applyMigrations; private readProtocolVersion; protocolVersion(): Promise; /** * Adapt the dialect-neutral parameters to what this statement binds. * * SQLite statements carry no DB clock, so every absolute `*_ms` is derived here * from one `now`, and booleans cross as 0/1. The result is narrowed to the * names the SQL actually uses, which is what makes it safe for a caller to pass * one superset of parameters for both dialects. * * Each derivation writes a name Postgres does not use (`lease_until_ms` from * `lease_ms`, and so on), so a statement binds one or the other, never both — * which is why the derived values can be computed unconditionally and left for * the narrowing step to discard. */ private bind; private runNow; /** Queue an operation behind every other operation on this database. */ private enqueue; /** * Serialize an operation against this database, waiting out a lost write lock on * a jittered backoff. Replaces busy_timeout's synchronous wait (see the class * comment); on exhausting the budget the original SQLITE_BUSY surfaces, which is * what a nonzero busy_timeout would have thrown too. * * Each attempt re-queues rather than backing off while holding its turn: the * contention left to retry is cross-process, and under WAL a *reader* never sees * SQLITE_BUSY at all — so sleeping in place would stall this process's reads * (including the worker's own poll) on a lock they were never waiting for. * * Retrying is safe because an attempt is one statement, or one transaction that * has already rolled back: nothing partially applied survives it. `fn` may * therefore run more than once and must not carry effects of its own — the * callers in TaskStore build their ids and payloads before opening one. */ private withLock; /** * Revisit this connection's planner statistics, at most once per * STATS_REFRESH_INTERVAL_MS. * * A connection lives for days, and the statements were prepared against whatever * the table looked like when it opened — a worker started against an empty * database plans as if it were still empty however large the backlog grows. The * prepared statements do pick the refreshed plans up: ANALYZE bumps the schema * cookie, so SQLite silently re-prepares them on next use. That is what makes * this worth doing rather than a restart-only concern. * * Queued rather than run under `withLock`: statistics are best-effort, so losing * the write lock to another process should cost nothing — skip and let the next * interval try, instead of spending an operation's whole retry budget on them. */ private maybeRefreshStatistics; /** * Group commit: one transaction for every write already waiting on the lock. * * A write costs microseconds to execute and a transaction costs a WAL commit, so * N concurrent writes spend nearly all their time on N commits they could have * shared. Measured at 200 finalizes: 80µs each one-transaction-apiece against * 10µs each in one transaction (`bench/sweep` sweep B). * * Nothing waits to form a batch — a flusher takes whatever arrived while the * previous one held the lock, so this trades no latency for the throughput. What * it does trade is atomicity: two callers' writes now land together or not at * all. Under at-least-once that is not observable (a lost batch is a * redelivery), and it is why every member is resolved only after COMMIT. */ private flush; /** * Make sure some flusher is draining `pending`, without ever running two. * * The flusher loops instead of re-arming itself per batch. A caller that awaits * its writes one at a time resumes and issues the next one *before* the flusher * gets its turn back, so re-arming would cost that write an extra trip through * the lock queue — measured as ~2x on sequential writes, which is most of them. * Looping picks it up in the same session for free. * * The exit is safe because the last `pending` check and clearing the flag happen * in one synchronous step: a write that arrives before it keeps the loop going, * and one that arrives after sees the flag down and starts a new flusher. */ private scheduleFlush; protected fetch(name: string, params: Params): Promise; protected tx(fn: (fetch: Fetch) => Promise): Promise; protected hasClaimableWork(params: Params): Promise; }