Simpl-Resultat/src/services/db.ts
le king fu 08946e6d08 fix(db): keep the lock-free path for the transaction handle only
The global inTransaction flag let every execute/select bypass the FIFO
lock while a withTransaction callback ran, wherever it came from. The
callback now receives its own handle, which runs directly, refuses
close() and throws once the callback settled; any other call, getDb()
from a callback included, queues behind the transaction.

connectToProfile, initializeNewProfileDb and closeDb each run as one
exclusive section (close the old pool, prepare, load), and every open
or close bumps a connection generation: an operation queued for a
closed connection throws "connection closed by profile switch". An
operation waiting more than 15 s for the lock writes one Journal
warning naming the holder and since when.

findOrCreateSecurity now requires its executor: its getDb() fallback
would deadlock if called from a transaction callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:28:36 -04:00

365 lines
14 KiB
TypeScript

import Database from "@tauri-apps/plugin-sql";
import { invoke } from "@tauri-apps/api/core";
import { logError, logInfo, logWarn } from "./logService";
let rawInstance: Database | null = null;
let serializedInstance: Database | null = null;
// ---------------------------------------------------------------------------
// DB access serialization (#228 follow-up — fix "database is locked"; #408).
//
// tauri-plugin-sql exposes NO transaction primitive to the JS side and loads
// SQLite through a default multi-connection sqlx pool (`Pool::connect` =>
// max_connections = 10). Every db.execute / db.select is an independent
// `pool.acquire()`, so a `BEGIN` issued on one pooled connection and its
// matching `COMMIT` issued on another would strand an open write transaction
// on an idle connection — that connection then holds SQLite's write lock until
// the app restarts, and every later write fails with "database is locked".
//
// Fix: funnel EVERY db operation through a single FIFO async lock. Plain
// reads/writes take the lock per-statement; `withTransaction` holds it across
// the whole BEGIN..COMMIT. With no two operations ever overlapping in time,
// the pool keeps handing back its single idle connection, so a transaction's
// statements all run on the same connection and can never strand. Serializing
// all DB access is a non-issue for a local single-user app where queries are
// tiny and infrequent — and it makes the "atomic" saves genuinely atomic.
//
// Rules (#408):
// - The lock is strict and NOT reentrant. Inside a `withTransaction` callback,
// use ONLY the handle the callback receives: it runs directly on the
// connection the transaction holds. Calling `getDb()` from a callback (or
// from anything it calls) queues behind the transaction that waits for it:
// a deadlock, reported in the Journal after BLOCKED_ALERT_MS (see below).
// - The transaction handle is revoked when the callback settles: a call on it
// afterwards (an orphan promise) throws `TransactionHandleRevokedError`
// instead of running outside the lock. `close()` on it always throws.
// - Opening, closing and switching a profile each run as ONE exclusive section
// (`connectToProfile`, `initializeNewProfileDb`, `closeDb`), so they wait for
// the operations already queued and for a running transaction, and nothing
// runs between closing the old pool and opening the new one.
// - Every open or close bumps a connection generation. A handle checks its
// generation when its operation leaves the queue: an operation queued for a
// connection that was closed meanwhile throws `DbConnectionClosedError`
// rather than running on the next profile's database or on a closed pool.
// ---------------------------------------------------------------------------
/** An operation queued for a connection closed before it could run. */
export class DbConnectionClosedError extends Error {
constructor() {
super("connection closed by profile switch");
this.name = "DbConnectionClosedError";
}
}
/** A transaction handle used after its `withTransaction` callback settled. */
export class TransactionHandleRevokedError extends Error {
constructor() {
super("transaction handle used after its withTransaction callback ended");
this.name = "TransactionHandleRevokedError";
}
}
/** `close()` called on a transaction handle: only the profile functions close. */
export class TransactionHandleCloseError extends Error {
constructor() {
super("close() is not allowed on a transaction handle; use closeDb()");
this.name = "TransactionHandleCloseError";
}
}
/** What holds the lock, named in the blocking alert. */
type LockKind =
| "query"
| "transaction"
| "profile opening"
| "profile initialization"
| "connection close";
/**
* An operation still waiting for the lock after this delay writes ONE warning
* to the Journal, naming what holds the lock and since when. The typical cause
* is a `getDb()` call from inside a `withTransaction` callback (deadlock). A
* profile catch-up (`prepare_profile_db`) that takes longer also triggers it,
* naming a "profile opening": expected, nothing can run during a switch.
* `logWarn` only writes to memory and sessionStorage, never to the DB, so it
* is safe to call while the lock is held.
*/
export const BLOCKED_ALERT_MS = 15_000;
let lockTail: Promise<unknown> = Promise.resolve();
let lockHolder: { kind: LockKind; since: number } | null = null;
let generation = 0;
/** Run `fn` after every previously-queued DB operation has settled (FIFO). */
function runExclusive<T>(kind: LockKind, fn: () => Promise<T>): Promise<T> {
const queuedAt = Date.now();
let started = false;
const alert = setTimeout(() => {
if (started) return;
const holder = lockHolder;
const waited = Math.round((Date.now() - queuedAt) / 1000);
logWarn(
holder
? `DB lock: a ${kind} has waited ${waited} s, the lock is held by a ${holder.kind} since ${new Date(holder.since).toISOString()}` +
(holder.kind === "transaction"
? " (a getDb() call inside a withTransaction callback deadlocks: use the callback's handle)"
: "")
: `DB lock: a ${kind} has waited ${waited} s behind the queue`
);
}, BLOCKED_ALERT_MS);
const run = async (): Promise<T> => {
started = true;
clearTimeout(alert);
lockHolder = { kind, since: Date.now() };
try {
return await fn();
} finally {
lockHolder = null;
}
};
const result = lockTail.then(run, run);
// Keep the chain alive regardless of fn's outcome; only the tail swallows.
lockTail = result.then(
() => undefined,
() => undefined
);
return result;
}
/** Throw when the connection a handle was made for is gone. */
function assertGeneration(handleGeneration: number): void {
if (handleGeneration !== generation) {
throw new DbConnectionClosedError();
}
}
/**
* Wrap the raw Database so `execute`/`select` are serialized through the lock
* and checked against the connection generation once they leave the queue.
* The proxy keeps the `Database` type, so call sites (including
* `db.select<T>(...)`) are unchanged.
*/
function serialize(db: Database, handleGeneration: number): Database {
return new Proxy(db, {
get(target, prop, receiver) {
if (prop === "execute") {
return (query: string, bindValues?: unknown[]) =>
runExclusive("query", async () => {
assertGeneration(handleGeneration);
return target.execute(query, bindValues);
});
}
if (prop === "select") {
return (query: string, bindValues?: unknown[]) =>
runExclusive("query", async () => {
assertGeneration(handleGeneration);
return target.select(query, bindValues);
});
}
if (prop === "close") {
return () => closeDb();
}
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
}
/**
* The handle a `withTransaction` callback receives: runs directly on the
* connection (the transaction already holds the lock), refuses `close()`, and
* throws once `revoke()` ran at the end of the callback.
*/
function transactionHandle(
db: Database,
handleGeneration: number
): { handle: Database; revoke: () => void } {
let active = true;
const check = () => {
if (!active) throw new TransactionHandleRevokedError();
assertGeneration(handleGeneration);
};
const handle = new Proxy(db, {
get(target, prop, receiver) {
if (prop === "execute") {
return async (query: string, bindValues?: unknown[]) => {
check();
return target.execute(query, bindValues);
};
}
if (prop === "select") {
return async (query: string, bindValues?: unknown[]) => {
check();
return target.select(query, bindValues);
};
}
if (prop === "close") {
return async () => {
throw new TransactionHandleCloseError();
};
}
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
return {
handle,
revoke: () => {
active = false;
},
};
}
export async function getDb(): Promise<Database> {
if (!serializedInstance) {
throw new Error("No database connection. Call connectToProfile() first.");
}
return serializedInstance;
}
/**
* Run `fn` as one serialized transaction. The global DB lock is held for the
* ENTIRE callback so no other db.execute/select can interleave and strand the
* transaction on a different pooled connection. `fn` receives the transaction
* handle and is responsible for issuing BEGIN/COMMIT (and ROLLBACK on error)
* on it — `withTransaction` only guarantees exclusive, non-interleaved access
* for the whole BEGIN..COMMIT. Never call `getDb()` from `fn` (deadlock).
*/
export async function withTransaction<T>(
fn: (db: Database) => Promise<T>
): Promise<T> {
if (!rawInstance) {
throw new Error("No database connection. Call connectToProfile() first.");
}
const db = rawInstance;
const handleGeneration = generation;
return runExclusive("transaction", async () => {
assertGeneration(handleGeneration);
const { handle, revoke } = transactionHandle(db, handleGeneration);
try {
return await fn(handle);
} finally {
revoke();
}
});
}
// Internal helpers for the profile sections below. They NEVER take the lock:
// only call them from inside a `runExclusive` section.
async function closeUnlocked(): Promise<void> {
if (rawInstance) {
const closing = rawInstance;
rawInstance = null;
serializedInstance = null;
generation += 1;
await closing.close();
}
}
async function loadUnlocked(dbFilename: string): Promise<Database> {
const db = await Database.load(`sqlite:${dbFilename}`);
generation += 1;
rawInstance = db;
serializedInstance = serialize(db, generation);
return db;
}
/** What `prepare_profile_db` did to one profile database (#390). */
export interface ProfilePreparation {
catch_up: {
floor: number | null;
recorded: number[];
applied: number[];
fk_repaired: Array<[string, number]>;
fk_violations: Array<[string, number]>;
backup_file: string | null;
} | null;
catch_up_error: string | null;
repaired_flags: number;
elapsed_ms: number;
}
/**
* Journal lines for one preparation: only what changed. Names files, never
* folders: an error line can be attached to a feedback report, and the
* folder holds the OS user name.
*/
export function describePreparation(
dbFilename: string,
report: ProfilePreparation | null | undefined
): { info: string[]; errors: string[] } {
const info: string[] = [];
const errors: string[] = [];
if (!report || typeof report !== "object") return { info, errors };
const catchUp = report.catch_up;
if (catchUp && catchUp.applied.length > 0) {
const first = catchUp.applied[0];
const last = catchUp.applied[catchUp.applied.length - 1];
info.push(
`Profile ${dbFilename}: database updated with versions ${first} to ${last} in ${report.elapsed_ms} ms` +
(catchUp.backup_file ? `, copy of the previous state kept as ${catchUp.backup_file}` : "")
);
} else if (catchUp && catchUp.recorded.length > 0) {
info.push(
`Profile ${dbFilename}: migration history recorded up to version ${catchUp.recorded[catchUp.recorded.length - 1]}`
);
}
for (const [table, rows] of catchUp?.fk_repaired ?? []) {
info.push(`Profile ${dbFilename}: ${rows} row(s) of ${table} detached from a missing parent`);
}
for (const [table, rows] of catchUp?.fk_violations ?? []) {
info.push(`Profile ${dbFilename}: ${rows} row(s) of ${table} point to a missing row`);
}
if (report.catch_up_error) {
errors.push(`Profile ${dbFilename}: database update stopped: ${report.catch_up_error}`);
}
if (report.repaired_flags > 0) {
info.push(`Profile ${dbFilename}: ${report.repaired_flags} manual categorization flag(s) repaired`);
}
return { info, errors };
}
export function connectToProfile(dbFilename: string): Promise<void> {
return runExclusive("profile opening", async () => {
// The old pool is fully closed before the preparation touches the file.
await closeUnlocked();
// Repair migration checksums before loading (fixes "migration was modified" error)
try {
const repaired = await invoke<boolean>("repair_migrations", { dbFilename });
if (repaired) {
console.warn("Migration checksums repaired for", dbFilename);
}
} catch (e) {
console.error("Migration repair failed:", e);
}
// Catch a secondary profile up on the migrations tauri-plugin-sql never
// runs for it, and repair legacy values in every profile (#390). Fails
// open, like the repair above: the profile still opens.
try {
const report = await invoke<ProfilePreparation>("prepare_profile_db", { dbFilename });
const lines = describePreparation(dbFilename, report);
lines.info.forEach((line) => logInfo(line));
lines.errors.forEach((line) => logError(line));
} catch (e) {
logError(
`Profile ${dbFilename}: database preparation failed: ${e instanceof Error ? e.message : String(e)}`
);
}
await loadUnlocked(dbFilename);
});
}
export function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> {
return runExclusive("profile initialization", async () => {
await closeUnlocked();
const db = await loadUnlocked(dbFilename);
for (const sql of sqlStatements) {
await db.execute(sql);
}
});
}
export function closeDb(): Promise<void> {
return runExclusive("connection close", () => closeUnlocked());
}