// ---------------------------------------------------------------------------
// Conversation-row batch delete — off-event-loop bulk delete of a
// conversation's rows from one table.
// ---------------------------------------------------------------------------
//
// Deleting a conversation that owns a large history is expensive on two tables:
// the `messages` rows (potentially an entire copied conversation history) and
// the `llm_request_logs` rows (one bulky row per model call). Done in-process
// via `bun:sqlite`, a single `DELETE FROM
WHERE conversation_id = ?` is
// one implicit transaction that holds the write lock for its full duration — on
// a multi-GB database it pegs the CPU and a concurrent in-process write (a live
// user turn persisting a message) blocks up to `busy_timeout` and can throw
// `SQLITE_BUSY`.
//
// This module deletes those rows off the event loop via {@link runAsyncSqlite}
// (a `sqlite3` CLI subprocess with its own connection; SQLite file-locking
// arbitrates with the in-process handle), in lock-friendly batches that each
// auto-commit and yield. Two properties matter for correctness:
//
// 1. **Lock-friendly batching.** Each batch deletes at most `batchSize` rows
// in one auto-committing `DELETE` (no surrounding `BEGIN`), releasing the
// write lock between batches, and a brief yield between subprocess calls
// lets a contending foreground write reliably slip in instead of losing
// every race to the delete's greedy lock re-acquisition.
//
// 2. **Cascade parity.** The in-process delete runs on the daemon connection,
// which has `PRAGMA foreign_keys = ON` (`db-connection.ts`), so deleting a
// `messages` row cascades to its `memory_segments`, `message_attachments`,
// `bookmarks`, and `channel_inbound_events` rows. A fresh `sqlite3`
// subprocess connection defaults to foreign keys OFF, so set
// `enableForeignKeys` for tables whose delete must cascade — otherwise the
// dependent rows would be orphaned. (`memory_embeddings` is not FK-linked
// to segments; the caller deletes those explicitly.)
import { setTimeout as sleep } from "node:timers/promises";
import { BULK_BATCH_TIMEOUT_MS, withBulkWriteGate } from "./bulk-write-gate.js";
import {
type AsyncSqliteBackend,
type AsyncSqliteResult,
parseChangesFromStdout,
runAsyncSqlite,
type RunAsyncSqliteOptions,
} from "./db-async-query.js";
/**
* Default batch size for the chunked delete. Each batch is one `DELETE` that
* auto-commits, so this bounds how long the subprocess holds the write lock
* before releasing it to in-process writers; the worst-case wait for a
* contending foreground write is one batch, and delete latency doesn't matter
* (nobody waits on a background GC).
*
* A deleted row costs a plain b-tree delete plus any FK cascade deletes —
* cheap and roughly proportional to the row count, with no per-row trigger
* work — so a batch this size still holds the lock only briefly while
* amortizing the per-batch subprocess spawn cost.
*/
export const DEFAULT_DELETE_BATCH_SIZE = 200;
/**
* Pause inserted between batch subprocess calls. Without it the delete releases
* the write lock on each batch's auto-commit but greedily re-acquires it
* microseconds later, so a concurrent in-process writer (a live user turn
* persisting a message) can lose every race. A brief yield lets foreground
* writes reliably slip in between batches. The extra delete latency is free —
* nothing waits on a background GC — so we trade delete speed for foreground
* fairness.
*/
export const DEFAULT_DELETE_INTER_BATCH_DELAY_MS = 25;
export interface DeleteConversationRowsOptions {
/** The conversation whose rows should be deleted. */
conversationId: string;
/** Table to delete from (e.g. `messages`, `llm_request_logs`). */
table: string;
/** Column holding the conversation id on `table`. Defaults to `conversation_id`. */
conversationColumn?: string;
/**
* Target a dedicated database file (e.g. `getLogsDbPath()` for
* `llm_request_logs`). Defaults to the main assistant DB.
*/
dbPath?: string;
/**
* Enable `PRAGMA foreign_keys=ON` so the delete cascades to FK-dependent
* rows. Required when deleting `messages`; unnecessary for tables with no
* dependents.
*/
enableForeignKeys?: boolean;
/** Override the per-batch row count (see {@link DEFAULT_DELETE_BATCH_SIZE}). */
batchSize?: number;
/** Test-only passthrough to force the in-process backend. */
forceInProcess?: boolean;
}
// Server-generated ids only ever use this charset. We interpolate the
// conversation id (never row content) into the SQL script, so reject anything
// outside it as a defense-in-depth guard against a malformed id breaking out of
// the literal.
const SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/;
// Table/column identifiers are interpolated unquoted, so constrain them to a
// plain SQL identifier shape. Call sites pass compile-time constants, but this
// keeps the interpolation safe regardless.
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
function assertSafeId(id: string): void {
if (!SAFE_ID.test(id)) {
throw new Error(
`conversation-row delete: unsafe id literal: ${JSON.stringify(id)}`,
);
}
}
function assertSafeIdentifier(name: string, kind: string): void {
if (!SAFE_IDENTIFIER.test(name)) {
throw new Error(
`conversation-row delete: unsafe ${kind} identifier: ${JSON.stringify(name)}`,
);
}
}
/**
* Build the SQL script for one delete batch: optionally enable cascading
* deletes, remove up to `batchSize` of the conversation's remaining rows, and
* print the direct-delete count via `SELECT changes()` so the caller can tell
* when the conversation is drained. `changes()` counts only the rows deleted
* from `table` (not cascade/trigger rows), so it strictly decreases the
* remaining count and is a sound loop terminator. Exported for unit testing the
* generated SQL without spawning a subprocess.
*/
export function buildBatchDeleteScript(options: {
conversationId: string;
table: string;
conversationColumn?: string;
enableForeignKeys?: boolean;
batchSize: number;
}): string {
assertSafeId(options.conversationId);
assertSafeIdentifier(options.table, "table");
const column = options.conversationColumn ?? "conversation_id";
assertSafeIdentifier(column, "column");
const limit = Math.max(1, options.batchSize);
const fkPragma = options.enableForeignKeys ? "PRAGMA foreign_keys=ON;\n" : "";
return `${fkPragma}DELETE FROM ${options.table} WHERE rowid IN (
SELECT rowid FROM ${options.table} WHERE ${column} = '${options.conversationId}' LIMIT ${limit}
);
SELECT changes();`;
}
/**
* Delete a conversation's rows from one table off the event loop, in
* lock-friendly batches. Each batch runs as its own subprocess call with a
* brief yield in between (see {@link DEFAULT_DELETE_INTER_BATCH_DELAY_MS}) so
* foreground writers reliably acquire the write lock between batches instead of
* losing every race to the delete's greedy lock re-acquisition. Loops until a
* batch deletes fewer rows than the batch size (the table is drained for this
* conversation), and resolves once every batch has committed — or returns the
* failing batch's `ok: false` result on subprocess failure, leaving any
* already-deleted rows gone (the caller's GC is best-effort and an orphan sweep
* is the backstop).
*
* Only this table's rows (and, when `enableForeignKeys` is set, their FK
* cascades) are removed; the caller is responsible for the remaining tables and
* the conversation row.
*/
export async function deleteConversationRowsInBatches(
options: DeleteConversationRowsOptions,
): Promise {
const batchSize = Math.max(1, options.batchSize ?? DEFAULT_DELETE_BATCH_SIZE);
const runOptions: RunAsyncSqliteOptions = {};
if (options.forceInProcess) {
runOptions.forceBackend = "in-process-blocking";
}
if (options.dbPath !== undefined) {
// A dedicated file (e.g. the logs DB) has its own write lock, so its
// drain must not queue behind — or hold up — main-DB bulk streams.
runOptions.dbPath = options.dbPath;
return await drainBatches(options, batchSize, runOptions);
}
return await withBulkWriteGate(
`conversation-row-delete:${options.table}:${options.conversationId}`,
() => drainBatches(options, batchSize, runOptions),
);
}
async function drainBatches(
options: DeleteConversationRowsOptions,
batchSize: number,
runOptions: RunAsyncSqliteOptions,
): Promise {
let totalElapsedMs = 0;
let backend: AsyncSqliteBackend = "in-process-blocking";
// Loop until a batch deletes fewer rows than requested — that batch drained
// the remaining rows, so there is nothing left to delete. Each DELETE
// strictly shrinks the row set, so the loop always terminates.
for (;;) {
const sql = buildBatchDeleteScript({
conversationId: options.conversationId,
table: options.table,
conversationColumn: options.conversationColumn,
enableForeignKeys: options.enableForeignKeys,
batchSize,
});
const result = await runAsyncSqlite(
sql,
`conversation-row-delete:${options.table}:${options.conversationId}`,
{ ...runOptions, timeoutMs: BULK_BATCH_TIMEOUT_MS },
);
totalElapsedMs += result.elapsedMs;
backend = result.backend;
if (!result.ok) {
return { ...result, elapsedMs: totalElapsedMs };
}
const deleted = parseChangesFromStdout(result.stdout);
if (deleted < batchSize) {
break;
}
await sleep(DEFAULT_DELETE_INTER_BATCH_DELAY_MS);
}
return { ok: true, backend, error: null, elapsedMs: totalElapsedMs };
}