quick_search/crates/quicksearch-core/src/db/repo.rs
= b8eedf2137
Some checks are pending
CI / linux (push) Waiting to run
CI / windows-cross (push) Waiting to run
CI / release (push) Blocked by required conditions
Added better visual feeback for adding file extensions to ignore patterns and a prompt to save modified settings.
Added a Forgejo CI pipeline
2026-08-04 03:27:05 -04:00

1336 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Row-level write helpers that keep the FTS5 contentless table in sync with
//! `files`/`documents`/`properties`.
//!
//! FTS5 contentless tables store postings only. Updating them requires the
//! old row values to compute which terms to remove. These helpers centralize
//! that bookkeeping so callers never have to remember the order of operations.
//!
//! States (mirrors `basic_state` / `content_state` columns):
//!
//! | value | meaning |
//! |------:|---------|
//! | 0 | pending |
//! | 1 | done |
//! | 2 | failed |
//! | 3 | not applicable (content only) |
use rusqlite::{params, Connection, OptionalExtension, Transaction};
use crate::mime::FileType;
pub const STATE_PENDING: i64 = 0;
pub const STATE_DONE: i64 = 1;
pub const STATE_FAILED: i64 = 2;
pub const STATE_NA: i64 = 3;
/// Everything needed to insert a fresh file row.
#[derive(Debug, Clone)]
pub struct NewFile<'a> {
pub name: &'a str,
pub path: &'a str,
pub parent: &'a str,
pub size: u64,
pub mtime: u64,
pub inode: Option<u64>,
pub device_id: Option<u64>,
pub mime: Option<&'a str>,
pub ftype: FileType,
pub hash: Option<&'a [u8]>,
/// Whether the content pass has work to do for this file. `false` means
/// the row is born `STATE_NA` — nothing claims its MIME, the
/// `content_extensions` filter excludes it, or it is over
/// `maximum_text_file_size`. Decided at walk time by
/// [`crate::file_handling::content_extractable`].
pub needs_content: bool,
}
/// Insert a new file row, returning its id. `basic_state` is set to DONE
/// (row existing *is* the basic-index state); `content_state` comes from
/// `needs_content` — PENDING for a file an extractor will claim, NA for one it
/// won't. Deciding here rather than on a content worker is what keeps PENDING
/// meaning "real work outstanding", which the extraction progress denominator
/// and [`pending_content_page`] both depend on.
///
/// Uses `INSERT OR IGNORE` so a UNIQUE(path) collision (which indicates the
/// caller fed the same path twice in one run) becomes a silent no-op
/// returning `None` rather than aborting the whole batch. The walker is
/// expected to dedupe visits upstream; this is a defense-in-depth backstop.
pub fn insert_file(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
let rows = tx
.prepare_cached(
"INSERT OR IGNORE INTO files (
name, path, parent, size, mtime, inode, device_id,
mime, type, basic_state, content_state, hash
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
)
.and_then(|mut stmt| {
stmt.execute(params![
f.name,
f.path,
f.parent,
f.size as i64,
f.mtime as i64,
f.inode.map(|x| x as i64),
f.device_id.map(|x| x as i64),
f.mime,
f.ftype.bits() as i64,
STATE_DONE,
initial_content_state(f),
f.hash,
])
})
.map_err(|e| format!("insert file {}: {}", f.path, e))?;
if rows == 0 {
// Existing row with the same path (e.g. duplicate visit within the run).
return Ok(None);
}
Ok(Some(tx.last_insert_rowid()))
}
/// The `content_state` a freshly written row starts in. Shared by
/// [`insert_file`] and [`update_file_basic`] so a file that reappears as an
/// update lands in the same state it would have as an insert.
fn initial_content_state(f: &NewFile<'_>) -> i64 {
if f.needs_content {
STATE_PENDING
} else {
STATE_NA
}
}
/// Update a file's metadata in place (same path, changed size/mtime/hash) and
/// reset its content state from `f.needs_content`, clearing any extracted
/// content so the text-indexing pass re-processes it.
///
/// Writes `size`, `mtime`, `hash`, `mime`, `type`, `content_state` and
/// `failure_msg` — and only those. `name`, `parent`, `inode` and `device_id`
/// are not refreshed here despite being present on the `NewFile`: the row is
/// found by path, and the first two are functions of it.
pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
// One statement, not a lookup then an update: `RETURNING` hands back the
// id of the row it just wrote, and a miss is simply no row returned.
let id: Option<i64> = tx
.prepare_cached(
"UPDATE files
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5,
content_state = ?6, failure_msg = NULL
WHERE path = ?7
RETURNING id",
)
.and_then(|mut stmt| {
stmt.query_row(
params![
f.size as i64,
f.mtime as i64,
f.hash,
f.mime,
f.ftype.bits() as i64,
initial_content_state(f),
f.path,
],
|r| r.get(0),
)
.optional()
})
.map_err(|e| format!("update file {}: {}", f.path, e))?;
let Some(id) = id else {
return Ok(None);
};
// Any prior extracted content is stale — remove it along with its FTS row.
remove_content_for_id(tx, id)?;
Ok(Some(id))
}
/// Mark a file's content indexing as complete and write the extracted text +
/// properties atomically. The plaintext is fed to the contentless FTS5
/// tokenizer (which keeps only the inverted index) and — when `store_text`
/// is `true` — separately stored zstd-compressed in `documents_text` for
/// on-demand snippet rendering. When `store_text=false` the sidecar INSERT
/// is skipped: queries still match the right files but result rows can't
/// render snippets. `properties` are stored both as a structured side-
/// table (for exact retrieval) and concatenated into the FTS `properties`
/// column (for MATCH).
pub fn set_content_done(
tx: &Transaction<'_>,
file_id: i64,
name: &str,
text: &str,
properties: &[(String, String)],
store_text: bool,
) -> Result<(), String> {
// Clear any previous extraction (in case of re-run).
remove_content_for_id(tx, file_id)?;
for (k, v) in properties {
tx.prepare_cached("INSERT INTO properties(file_id, key, value) VALUES (?1, ?2, ?3)")
.and_then(|mut stmt| stmt.execute(params![file_id, k, v]))
.map_err(|e| format!("insert property {}={}: {}", k, v, e))?;
}
let props_blob = encode_properties_for_fts(properties);
// Contentless FTS5 still accepts values on INSERT — the tokenizer needs
// them — it simply doesn't persist the raw column values.
tx.prepare_cached(
"INSERT INTO searchabletext(rowid, name, text, properties) VALUES (?1, ?2, ?3, ?4)",
)
.and_then(|mut stmt| stmt.execute(params![file_id, name, text, props_blob]))
.map_err(|e| format!("insert FTS row {}: {}", file_id, e))?;
// Skip the compressed sidecar when: the config disables snippet storage
// outright, or there's no body text (e.g. an image whose extractor
// returned only EXIF properties). The second case saves a zstd frame
// on what would otherwise be an empty blob.
if store_text && !text.is_empty() {
let compressed = zstd::encode_all(text.as_bytes(), ZSTD_LEVEL)
.map_err(|e| format!("zstd encode for file {}: {}", file_id, e))?;
tx.prepare_cached(
"INSERT INTO documents_text(file_id, text_zstd, text_len) VALUES (?1, ?2, ?3)",
)
.and_then(|mut stmt| stmt.execute(params![file_id, compressed, text.len() as i64]))
.map_err(|e| format!("insert documents_text {}: {}", file_id, e))?;
}
tx.prepare_cached("UPDATE files SET content_state = ?1, failure_msg = NULL WHERE id = ?2")
.and_then(|mut stmt| stmt.execute(params![STATE_DONE, file_id]))
.map_err(|e| format!("update content_state DONE {}: {}", file_id, e))?;
// Clear any prior failed-file record.
tx.prepare_cached("DELETE FROM failed_files WHERE file_id = ?1")
.and_then(|mut stmt| stmt.execute(params![file_id]))
.map_err(|e| format!("clear failed_files {}: {}", file_id, e))?;
Ok(())
}
/// zstd level tuned for extracted-text prose. Level 3 hits ~3-5× on English
/// prose at high throughput (hundreds of MB/s) — level 9+ would shave a few
/// percent more but at 10× the CPU cost during indexing. Wrong knob to
/// tune: readers decompress far faster than writers compress, so keep
/// write-side cost low.
const ZSTD_LEVEL: i32 = 3;
/// Mark a file's content extraction as failed. Keeps the basic row in place.
pub fn set_content_failed(tx: &Transaction<'_>, file_id: i64, reason: &str) -> Result<(), String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
tx.prepare_cached("UPDATE files SET content_state = ?1, failure_msg = ?2 WHERE id = ?3")
.and_then(|mut stmt| stmt.execute(params![STATE_FAILED, reason, file_id]))
.map_err(|e| format!("update content_state FAILED {}: {}", file_id, e))?;
tx.prepare_cached(
"INSERT OR REPLACE INTO failed_files(file_id, reason, ts) VALUES (?1, ?2, ?3)",
)
.and_then(|mut stmt| stmt.execute(params![file_id, reason, now]))
.map_err(|e| format!("insert failed_files {}: {}", file_id, e))?;
Ok(())
}
/// Mark content extraction as not applicable (e.g. binary format we don't
/// support). The file row still contributes to filename search.
pub fn set_content_na(tx: &Transaction<'_>, file_id: i64) -> Result<(), String> {
tx.prepare_cached("UPDATE files SET content_state = ?1, failure_msg = NULL WHERE id = ?2")
.and_then(|mut stmt| stmt.execute(params![STATE_NA, file_id]))
.map_err(|e| format!("update content_state NA {}: {}", file_id, e))?;
tx.prepare_cached("DELETE FROM failed_files WHERE file_id = ?1")
.and_then(|mut stmt| stmt.execute(params![file_id]))
.map_err(|e| format!("clear failed_files {}: {}", file_id, e))?;
Ok(())
}
/// Delete a file row by path, keeping FTS in sync. Returns whether a row was
/// removed.
pub fn delete_file_by_path(tx: &Transaction<'_>, path: &str) -> Result<bool, String> {
// `RETURNING` gives the id of the row that went, so the dependent tables
// can be cleared without a separate lookup first.
let id: Option<i64> = tx
.prepare_cached("DELETE FROM files WHERE path = ?1 RETURNING id")
.and_then(|mut stmt| stmt.query_row(params![path], |r| r.get(0)).optional())
.map_err(|e| format!("delete file {}: {}", path, e))?;
let Some(id) = id else { return Ok(false) };
remove_content_for_id(tx, id)?;
Ok(true)
}
/// Delete every row whose path falls in the half-open range `[lo, hi)`,
/// keeping FTS, `documents_text`, `properties` and `failed_files` in step.
/// Returns how many `files` rows went.
///
/// The bulk counterpart to [`delete_file_by_path`], for removing a whole
/// directory at once. Five statements regardless of how many files the range
/// holds — where deleting them one at a time costs ~5 *per file* — and the
/// range is a plain index seek on `UNIQUE(files.path)`, so this is
/// `SEARCH … (path>? AND path<?)` rather than a scan. Build the bounds with
/// [`crate::file_handling::ExtractCursor::for_root`], which is what makes them
/// separator-correct.
///
/// All four dependent tables are deleted explicitly rather than left to their
/// `ON DELETE CASCADE` foreign keys. `searchabletext` is an FTS5 virtual table
/// and has no foreign key at all, so relying on cascade would leave the rule
/// half-declarative and half-manual, free to drift the moment someone opens a
/// connection without `PRAGMA foreign_keys`. (`contentless_delete=1` is what
/// lets the FTS rows go by rowid alone.)
pub fn delete_subtree(tx: &Transaction<'_>, lo: &str, hi: &str) -> Result<usize, String> {
// Every dependent table is keyed by the file id, so they share one
// sub-select; `files` itself goes last, once nothing references it.
for (table, key) in [
("searchabletext", "rowid"),
("documents_text", "file_id"),
("properties", "file_id"),
("failed_files", "file_id"),
] {
let sql = format!(
"DELETE FROM {} WHERE {} IN \
(SELECT id FROM files WHERE path >= ?1 AND path < ?2)",
table, key
);
tx.prepare_cached(&sql)
.and_then(|mut stmt| stmt.execute(params![lo, hi]))
.map_err(|e| format!("delete {} under {}: {}", table, lo, e))?;
}
let removed = tx
.prepare_cached("DELETE FROM files WHERE path >= ?1 AND path < ?2")
.and_then(|mut stmt| stmt.execute(params![lo, hi]))
.map_err(|e| format!("delete files under {}: {}", lo, e))?;
Ok(removed)
}
/// Every indexed file directly inside `parent`, as `name -> mtime`.
///
/// The walk's unit of classification. Keyed by name rather than full path
/// because the parent is implied — at millions of files, not storing the
/// directory prefix once per entry is the difference this whole path exists
/// to make. Served by `idx_files_parent`, so this is one index range lookup.
pub fn dir_rows(
conn: &Connection,
parent: &str,
) -> Result<std::collections::HashMap<String, u64>, String> {
let mut stmt = conn
.prepare_cached("SELECT name, mtime FROM files WHERE parent = ?1")
.map_err(|e| format!("prepare dir rows for {}: {}", parent, e))?;
let rows = stmt
.query_map(params![parent], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
})
.map_err(|e| format!("query dir rows for {}: {}", parent, e))?;
let mut out = std::collections::HashMap::new();
for row in rows {
let (name, mtime) = row.map_err(|e| format!("read dir row under {}: {}", parent, e))?;
out.insert(name, mtime);
}
Ok(out)
}
/// One page of rows still awaiting content extraction under `cursor`'s range,
/// as `(id, name, path, mime)` ordered by id.
///
/// Keyset, not `OFFSET`: `id > cursor.last_id` means each page is an index
/// seek rather than a re-scan of everything already handed out, and — because
/// the cursor only moves forward — a row is served exactly once even though
/// the writer is concurrently flipping `content_state` behind the reader. The
/// `content_state = 0` predicate is the belt to that braces, not the
/// mechanism.
pub fn pending_content_page(
conn: &Connection,
cursor: &crate::file_handling::ExtractCursor,
max_size: i64,
limit: i64,
) -> Result<Vec<(i64, String, String, Option<String>)>, String> {
let mut stmt = conn
.prepare_cached(
"SELECT id, name, path, mime FROM files
WHERE content_state = 0 AND size <= ?1 AND id > ?2
AND path >= ?3 AND path < ?4
ORDER BY id
LIMIT ?5",
)
.map_err(|e| format!("prepare pending content query: {}", e))?;
let rows = stmt
.query_map(
params![max_size, cursor.last_id, cursor.lo, cursor.hi, limit],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
))
},
)
.map_err(|e| format!("query pending content: {}", e))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("read pending content row: {}", e))
}
/// The stored mtime for one exact path, or `None` if it isn't indexed.
///
/// For files the walk reaches by a spelling whose parent isn't the directory
/// being read — a resolved symlink target — where [`dir_rows`] would not
/// have them.
pub fn mtime_for_path(conn: &Connection, path: &str) -> Result<Option<u64>, String> {
let mut stmt = conn
.prepare_cached("SELECT mtime FROM files WHERE path = ?1")
.map_err(|e| format!("prepare mtime lookup for {}: {}", path, e))?;
stmt.query_row(params![path], |r| r.get::<_, i64>(0))
.optional()
.map(|o| o.map(|m| m.max(0) as u64))
.map_err(|e| format!("mtime lookup for {}: {}", path, e))
}
/// Distinct `parent` values within the half-open path range `[lo, hi)`,
/// streamed to `f` rather than collected.
///
/// Callers use this to find directories the walk never visited, so it must
/// not itself materialize a list proportional to the tree — the whole point
/// of the change that introduced it. `idx_files_parent` makes this an
/// index-only scan.
pub fn for_each_parent_in_range<F: FnMut(String)>(
conn: &Connection,
lo: &str,
hi: &str,
mut f: F,
) -> Result<(), String> {
let mut stmt = conn
.prepare("SELECT DISTINCT parent FROM files WHERE parent >= ?1 AND parent < ?2")
.map_err(|e| format!("prepare parent scan: {}", e))?;
let rows = stmt
.query_map(params![lo, hi], |r| r.get::<_, String>(0))
.map_err(|e| format!("parent scan: {}", e))?;
for row in rows {
f(row.map_err(|e| format!("read parent row: {}", e))?);
}
Ok(())
}
/// Paths of every file directly inside `parent`.
///
/// The companion to [`for_each_parent_in_range`]: once a parent is known to
/// be unvisited, this is what its rows are.
pub fn paths_in_dir(conn: &Connection, parent: &str) -> Result<Vec<String>, String> {
let mut stmt = conn
.prepare_cached("SELECT path FROM files WHERE parent = ?1")
.map_err(|e| format!("prepare paths in {}: {}", parent, e))?;
let rows = stmt
.query_map(params![parent], |r| r.get::<_, String>(0))
.map_err(|e| format!("query paths in {}: {}", parent, e))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("read path under {}: {}", parent, e))
}
/// Remove the FTS row, compressed text blob, and any `properties` rows for
/// a given file id. Does not touch the `files` row itself. Idempotent — a
/// missing row is fine.
pub fn remove_content_for_id(tx: &Transaction<'_>, file_id: i64) -> Result<(), String> {
// `contentless_delete=1` on the FTS5 table makes this work without
// re-supplying the old column values (it tombstones the rowid).
for (table, key) in [
("searchabletext", "rowid"),
("documents_text", "file_id"),
("properties", "file_id"),
] {
let sql = format!("DELETE FROM {} WHERE {} = ?1", table, key);
tx.prepare_cached(&sql)
.and_then(|mut stmt| stmt.execute(params![file_id]))
.map_err(|e| format!("delete {} for {}: {}", table, file_id, e))?;
}
Ok(())
}
/// Serialize properties for the FTS `properties` column. `key:value` pairs
/// separated by spaces so `MATCH 'properties:artist:beatles'` works.
fn encode_properties_for_fts(props: &[(String, String)]) -> String {
let mut buf = String::new();
for (i, (k, v)) in props.iter().enumerate() {
if i > 0 {
buf.push(' ');
}
buf.push_str(k);
buf.push(':');
buf.push_str(v);
}
buf
}
/// Free pages, as a percentage of the file, that make a [`maintain`] VACUUM
/// worth its cost. Below this the file is rewritten for nothing: a run that
/// changed little leaves little slack, and rewriting a multi-gigabyte index
/// to reclaim a few megabytes is minutes of I/O for no gain.
const VACUUM_MIN_SLACK_PERCENT: i64 = 10;
/// Flush the whole WAL into the main database and truncate the log to zero
/// bytes. `Err` means the log was *not* emptied.
///
/// `execute`/`execute_batch` discard the pragma's result row, and that row is
/// the only place SQLite reports that a checkpoint gave up — an incomplete
/// checkpoint is not an error, it is a number in a row nobody read. That is
/// how a log can grow past the index it journals without a word in the logs.
///
/// The signal is the *log* column (frames left in the WAL), not `busy`: a
/// TRUNCATE that cannot take the writer lock silently downgrades itself to
/// PASSIVE and still reports `busy = 0` with the log untouched. A real restart
/// sets `mxFrame` to 0. A database not in WAL mode reports -1, hence `<= 0`.
pub fn checkpoint_truncate(conn: &Connection) -> Result<(), String> {
let (busy, log): (i64, i64) = conn
.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.map_err(|e| format!("wal checkpoint: {}", e))?;
if log <= 0 {
Ok(())
} else {
Err(format!(
"wal checkpoint incomplete: busy={}, {} frames left in the log",
busy, log
))
}
}
/// Flush the WAL into the main DB file and close. Call on clean shutdown so
/// the next open starts with an empty log. WAL mode itself is persistent in
/// the file — deliberately left on.
pub fn checkpoint_and_close(conn: Connection) {
if let Err(e) = checkpoint_truncate(&conn) {
crate::log_warn!("{}", e);
}
drop(conn);
}
/// Land the log, reclaim the file's slack, and refresh the query planner's
/// statistics. Returns whether it vacuumed.
///
/// The sequence is checkpoint → VACUUM → `PRAGMA optimize` → checkpoint. The
/// trailing checkpoint is not a repeat of the first: VACUUM's copy-back pushes
/// every page of the rebuilt file through the log, and `optimize` writes
/// `sqlite_stat1`, so leaving without one would trade the slack just reclaimed
/// for a log the size of the index.
///
/// Run on a connection from [`crate::db::open::open_maintenance`], never on
/// the indexer's: VACUUM builds the replacement database in the temp store,
/// and under the indexer's `temp_store = MEMORY` that means building the whole
/// index in RAM.
///
/// `db_dir` is where the temporary database goes, and it must be the index's
/// own directory. `temp_store = FILE` alone resolves through `SQLITE_TMPDIR`,
/// `TMPDIR`, `/var/tmp`, then `/tmp` — and `/tmp` is a RAM-backed tmpfs on many
/// Linux systems, which would put us straight back where we started. Keeping
/// the temporary database beside the index also puts it on a volume already
/// known to hold one.
///
/// Peak transient space on that volume is roughly three times the index: the
/// original, the replacement being built beside it, and the log that VACUUM's
/// copy-back runs through. Running out is a failed VACUUM, not a damaged
/// index — the transaction rolls back.
pub fn maintain(conn: &Connection, db_dir: &str) -> Result<bool, String> {
// Best-effort: a reader that briefly pins the log is no reason to skip
// the compaction below, which does not need the log empty to start.
if let Err(e) = checkpoint_truncate(conn) {
crate::log_warn!("{}", e);
}
let page_count: i64 = conn
.query_row("PRAGMA page_count", [], |r| r.get(0))
.map_err(|e| format!("read page_count: {}", e))?;
let freelist: i64 = conn
.query_row("PRAGMA freelist_count", [], |r| r.get(0))
.map_err(|e| format!("read freelist_count: {}", e))?;
let vacuumed = freelist * 100 >= page_count * VACUUM_MIN_SLACK_PERCENT;
if vacuumed {
// `temp_store_directory` is a deprecated pragma that writes a global,
// so it is set for the VACUUM and cleared straight after rather than
// left standing for every other connection in the process.
let escaped = db_dir.replace('\'', "''");
conn.execute_batch(&format!("PRAGMA temp_store_directory = '{}';", escaped))
.map_err(|e| format!("set temp dir for vacuum: {}", e))?;
let outcome = conn
.execute_batch("VACUUM;")
.map_err(|e| format!("vacuum: {}", e));
let _ = conn.execute_batch("PRAGMA temp_store_directory = '';");
outcome?;
}
// Re-analyses only the tables whose shape has drifted far enough to matter,
// so it is close to free on a run that changed little. A run that added
// millions of rows is exactly when the planner's old statistics start
// choosing the wrong index for a search.
conn.execute_batch("PRAGMA optimize;")
.map_err(|e| format!("optimize: {}", e))?;
checkpoint_truncate(conn)?;
Ok(vacuumed)
}
/// Read the `last_full_index` marker (unix seconds of the last *successful*
/// full indexing run) from `schema_info`. Absent key — fresh DB, or a DB
/// from before this marker existed — means "never".
pub fn get_last_full_index(conn: &Connection) -> Option<u64> {
conn.query_row(
"SELECT value FROM schema_info WHERE key = 'last_full_index'",
[],
|r| r.get::<_, String>(0),
)
.optional()
.ok()
.flatten()
.and_then(|v| v.parse().ok())
}
/// Stamp `last_full_index` with `ts` (unix seconds). Called at the end of
/// every successful full indexing run; the coordinator reads it to schedule
/// periodic reindexing.
pub fn set_last_full_index(conn: &Connection, ts: u64) -> Result<(), String> {
conn.execute(
"INSERT OR REPLACE INTO schema_info(key, value) VALUES ('last_full_index', ?1)",
params![ts.to_string()],
)
.map_err(|e| format!("write last_full_index: {}", e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use super::*;
use crate::db::open_or_recreate;
fn tmp_path() -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"quicksearch-repo-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
p
}
#[test]
fn insert_update_delete_round_trip() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
{
let tx = conn.transaction().unwrap();
let id = insert_file(
&tx,
&NewFile {
name: "a.txt",
path: "/tmp/a.txt",
parent: "/tmp",
size: 42,
mtime: 1_700_000_000,
inode: Some(7),
device_id: Some(64768),
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: Some(&[1, 2, 3]),
needs_content: true,
},
)
.unwrap()
.expect("unique path");
set_content_done(
&tx,
id,
"a.txt",
"hello world",
&[("title".to_string(), "hi".to_string())],
true,
)
.unwrap();
tx.commit().unwrap();
}
// Text is findable via FTS.
let hit: i64 = conn
.query_row(
"SELECT rowid FROM searchabletext WHERE searchabletext MATCH 'hello'",
[],
|r| r.get(0),
)
.unwrap();
assert!(hit > 0);
// Delete cleans up.
{
let tx = conn.transaction().unwrap();
assert!(delete_file_by_path(&tx, "/tmp/a.txt").unwrap());
tx.commit().unwrap();
}
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
let fts_count: i64 = conn
.query_row("SELECT COUNT(*) FROM searchabletext", [], |r| r.get(0))
.unwrap();
assert_eq!(fts_count, 0);
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn insert_writes_content_state_from_needs_content() {
// The whole point of the field: a row nothing will extract is born NA,
// so "pending" downstream — the extraction denominator, the feeder's
// page query, the Baloo pending count — means real outstanding work.
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let tx = conn.transaction().unwrap();
let mut row = NewFile {
name: "claimed.txt",
path: "/tmp/claimed.txt",
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
needs_content: true,
};
let claimed = insert_file(&tx, &row).unwrap().expect("unique path");
row.name = "unclaimed.mp4";
row.path = "/tmp/unclaimed.mp4";
row.mime = Some("video/mp4");
row.needs_content = false;
let unclaimed = insert_file(&tx, &row).unwrap().expect("unique path");
let state = |id: i64| -> i64 {
tx.query_row(
"SELECT content_state FROM files WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(state(claimed), STATE_PENDING);
assert_eq!(state(unclaimed), STATE_NA);
drop(tx);
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn update_writes_content_state_from_needs_content() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let mut row = NewFile {
name: "a.txt",
path: "/tmp/a.txt",
parent: "/tmp",
size: 10,
mtime: 1,
inode: None,
device_id: None,
mime: None,
ftype: FileType::EMPTY,
hash: None,
needs_content: false,
};
let id = {
let tx = conn.transaction().unwrap();
let id = insert_file(&tx, &row).unwrap().expect("unique path");
set_content_done(&tx, id, "a.txt", "old text", &[], true).unwrap();
tx.commit().unwrap();
id
};
let content_state = |conn: &Connection| -> i64 {
conn.query_row(
"SELECT content_state FROM files WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap()
};
// Rewritten as something an extractor claims: back to pending, and the
// stale FTS row goes with it.
{
let tx = conn.transaction().unwrap();
row.size = 20;
row.mtime = 2;
row.mime = Some("text/plain");
row.ftype = FileType::TEXT;
row.needs_content = true;
let got = update_file_basic(&tx, &row).unwrap();
assert_eq!(got, Some(id));
tx.commit().unwrap();
}
let basic: i64 = conn
.query_row(
"SELECT basic_state FROM files WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(basic, STATE_DONE);
assert_eq!(content_state(&conn), STATE_PENDING);
let fts_hits: i64 = conn
.query_row(
"SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH 'old'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(fts_hits, 0);
// And rewritten as something nothing claims: NA, not pending. Without
// this the row would re-enter the content pass on every run.
{
let tx = conn.transaction().unwrap();
row.mtime = 3;
row.mime = Some("video/mp4");
row.needs_content = false;
update_file_basic(&tx, &row)
.unwrap()
.expect("row still there");
tx.commit().unwrap();
}
assert_eq!(content_state(&conn), STATE_NA);
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn insert_file_twice_on_same_path_is_idempotent() {
// Defense-in-depth: when the walker visits a canonical path twice
// (overlapping roots, symlink resolution quirks), the second INSERT
// must be a silent no-op, not a run-ending error.
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let tx = conn.transaction().unwrap();
let row = NewFile {
name: "dup.txt",
path: "/tmp/dup.txt",
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
needs_content: true,
};
let id1 = insert_file(&tx, &row).unwrap().expect("first insert");
let id2 = insert_file(&tx, &row).unwrap();
assert!(id2.is_none(), "second insert of same path must return None");
// Only one row exists.
let count: i64 = tx
.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
let (id_read,): (i64,) = tx
.query_row(
"SELECT id FROM files WHERE path = ?1",
params!["/tmp/dup.txt"],
|r| Ok((r.get(0)?,)),
)
.unwrap();
assert_eq!(id_read, id1);
tx.commit().unwrap();
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn delete_subtree_clears_every_dependent_table() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let add = |tx: &Transaction<'_>, path: &str| -> i64 {
let name = path.rsplit('/').next().unwrap();
let parent = &path[..path.rfind('/').unwrap()];
let id = insert_file(
tx,
&NewFile {
name,
path,
parent,
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
needs_content: true,
},
)
.unwrap()
.expect("unique path");
set_content_done(tx, id, name, "body text", &[("k".into(), "v".into())], true).unwrap();
id
};
{
let tx = conn.transaction().unwrap();
add(&tx, "/tree/a.txt");
add(&tx, "/tree/deep/b.txt");
let failed = add(&tx, "/tree/deep/c.txt");
set_content_failed(&tx, failed, "bad parse").unwrap();
// Outside the range: a prefix sibling, and a LIKE-metacharacter
// neighbour that a `LIKE 'tree_%'` sweep would have swallowed.
add(&tx, "/tree2/keep.txt");
add(&tx, "/treeX/keep.txt");
tx.commit().unwrap();
}
let range = crate::file_handling::ExtractCursor::for_root("/tree");
let removed = {
let tx = conn.transaction().unwrap();
// The directory row itself does not exist (only files are indexed),
// so everything here comes from the range.
let n = delete_subtree(&tx, &range.lo, &range.hi).unwrap();
tx.commit().unwrap();
n
};
assert_eq!(removed, 3, "three files under /tree");
let count = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
assert_eq!(count("SELECT COUNT(*) FROM files"), 2, "siblings survive");
assert_eq!(count("SELECT COUNT(*) FROM searchabletext"), 2);
assert_eq!(count("SELECT COUNT(*) FROM documents_text"), 2);
assert_eq!(count("SELECT COUNT(*) FROM properties"), 2);
assert_eq!(
count("SELECT COUNT(*) FROM failed_files"),
0,
"the failed row went with its file"
);
let survivors: Vec<String> = {
let mut stmt = conn
.prepare("SELECT path FROM files ORDER BY path")
.unwrap();
let v = stmt
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
v
};
assert_eq!(survivors, vec!["/tree2/keep.txt", "/treeX/keep.txt"]);
drop(conn);
std::fs::remove_file(&p).ok();
}
/// The walk's row prefetcher runs `dir_rows` once per directory, against a
/// deliberately tiny page cache, so it must not have to touch the table
/// heap at all. `idx_files_parent` carries `name` and `mtime` for exactly
/// this; trimming it back to `(parent)` would silently reintroduce a row
/// fetch per entry.
#[test]
fn dir_rows_is_served_entirely_from_the_index() {
let p = tmp_path();
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let plan: String = conn
.query_row(
"EXPLAIN QUERY PLAN SELECT name, mtime FROM files WHERE parent = ?1",
params!["/some/dir"],
|r| r.get(3),
)
.unwrap();
assert!(
plan.contains("COVERING INDEX idx_files_parent"),
"dir_rows must be index-only, got: {}",
plan
);
drop(conn);
std::fs::remove_file(&p).ok();
}
/// The whole point of the range form: it is an index seek. `LIKE … ESCAPE`
/// cannot use the index (SQLite disables the LIKE optimisation whenever an
/// ESCAPE clause is present), so the old sweep read every path in the table
/// on every deletion event.
#[test]
fn the_subtree_range_is_an_index_seek_not_a_scan() {
let p = tmp_path();
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let plan: String = conn
.query_row(
"EXPLAIN QUERY PLAN DELETE FROM files WHERE path >= ?1 AND path < ?2",
params!["/tree/", "/tree0"],
|r| r.get(3),
)
.unwrap();
assert!(
plan.contains("SEARCH") && !plan.contains("SCAN"),
"range delete must seek, got: {}",
plan
);
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn last_full_index_round_trip() {
let p = tmp_path();
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
assert_eq!(get_last_full_index(&conn), None, "fresh DB has no marker");
set_last_full_index(&conn, 1_700_000_123).unwrap();
assert_eq!(get_last_full_index(&conn), Some(1_700_000_123));
// Overwrite, not accumulate.
set_last_full_index(&conn, 1_700_000_999).unwrap();
assert_eq!(get_last_full_index(&conn), Some(1_700_000_999));
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn checkpoint_and_close_truncates_wal() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
{
let tx = conn.transaction().unwrap();
insert_file(
&tx,
&NewFile {
name: "w.txt",
path: "/tmp/w.txt",
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: None,
ftype: FileType::EMPTY,
hash: None,
needs_content: false,
},
)
.unwrap();
tx.commit().unwrap();
}
checkpoint_and_close(conn);
// After a TRUNCATE checkpoint + close of the last connection the WAL
// sidecar is gone or empty; the row lives in the main file.
let wal = std::path::PathBuf::from(format!("{}-wal", p.display()));
let wal_len = std::fs::metadata(&wal).map(|m| m.len()).unwrap_or(0);
assert_eq!(wal_len, 0, "WAL should be truncated on clean close");
let conn = crate::db::open_existing(p.to_str().unwrap(), false).unwrap();
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
drop(conn);
std::fs::remove_file(&p).ok();
}
/// Bulk rows, cheap to write, enough of them to make the file grow.
fn seed_rows(conn: &mut Connection, range: std::ops::Range<usize>) {
let tx = conn.transaction().unwrap();
for i in range {
let path = format!("/tmp/bulk/{}.txt", i);
let name = format!("{}.txt", i);
let id = insert_file(
&tx,
&NewFile {
name: &name,
path: &path,
parent: "/tmp/bulk",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
needs_content: true,
},
)
.unwrap()
.unwrap();
set_content_done(
&tx,
id,
&name,
&"lorem ipsum dolor sit amet ".repeat(64),
&[],
true,
)
.unwrap();
}
tx.commit().unwrap();
}
fn wal_bytes(p: &std::path::Path) -> u64 {
std::fs::metadata(format!("{}-wal", p.display()))
.map(|m| m.len())
.unwrap_or(0)
}
#[test]
fn checkpoint_truncate_empties_the_log() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
seed_rows(&mut conn, 0..200);
assert!(wal_bytes(&p) > 0, "the writes should be sitting in the log");
checkpoint_truncate(&conn).expect("nothing is holding the log back");
assert_eq!(wal_bytes(&p), 0, "a completed TRUNCATE leaves no log");
drop(conn);
std::fs::remove_file(&p).ok();
}
/// The case the old `execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")`
/// reported as success: a reader pins the log, SQLite declines to reset
/// it, and the only word of it is in the result row.
#[test]
fn checkpoint_truncate_reports_an_incomplete_checkpoint() {
let p = tmp_path();
let mut writer = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
seed_rows(&mut writer, 0..200);
// Long enough to prove the point, short enough not to stall the suite.
writer
.busy_timeout(std::time::Duration::from_millis(100))
.unwrap();
let reader = crate::db::open_existing(p.to_str().unwrap(), false).unwrap();
let mut stmt = reader.prepare("SELECT id FROM files").unwrap();
let mut rows = stmt.query([]).unwrap();
rows.next().unwrap().expect("a row to hold the snapshot on");
let err = checkpoint_truncate(&writer).expect_err("a reader holds the log open");
assert!(err.contains("incomplete"), "unexpected message: {}", err);
assert!(wal_bytes(&p) > 0, "and the log is still there");
drop(rows);
drop(stmt);
drop(reader);
drop(writer);
std::fs::remove_file(&p).ok();
}
/// The mechanism the whole in-run checkpoint exists for.
///
/// SQLite's autocheckpoint copies committed frames into the database
/// continuously, but the log is only *reset* when the writer opens a
/// transaction at an instant no reader holds a read mark — and it tries
/// that lock exactly once, with no retry. A reader querying back to back,
/// which is what an indexing run's per-root prefetcher is, keeps that
/// instant from arriving, so the log appends for as long as the run lasts.
/// An explicit checkpoint retries the same lock under `busy_timeout` and
/// gets it.
#[test]
fn a_busy_reader_defeats_the_autocheckpoint_but_not_a_forced_one() {
// Bare `files` rows: no text, so no zstd and no tokenising. The log
// grows from committed frames, and frames are what starves the reset —
// paying for extraction here would only make the test slow.
fn seed_bare(conn: &mut Connection, range: std::ops::Range<usize>) {
let tx = conn.transaction().unwrap();
for i in range {
let path = format!("/tmp/bare/{}.txt", i);
let name = format!("{}.txt", i);
insert_file(
&tx,
&NewFile {
name: &name,
path: &path,
parent: "/tmp/bare",
size: i as u64,
mtime: 1,
inode: None,
device_id: None,
mime: None,
ftype: FileType::TEXT,
hash: Some(&[0u8; 32]),
needs_content: false,
},
)
.unwrap();
}
tx.commit().unwrap();
}
fn run(p: &std::path::Path, force_every: usize) -> u64 {
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
seed_bare(&mut conn, 0..500);
// Stands in for a walk prefetcher: short reads, no gaps.
let stop: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
let reader = {
let (path, stop) = (p.to_path_buf(), stop.clone());
std::thread::spawn(move || {
let conn = crate::db::open_existing(path.to_str().unwrap(), false).unwrap();
while !stop.load(Ordering::Relaxed) {
let _: i64 = conn
.query_row(
"SELECT COUNT(*) FROM files WHERE parent = '/tmp/bare'",
[],
|r| r.get(0),
)
.unwrap();
}
})
};
let mut peak = 0u64;
for batch in 0..60 {
let lo = 1000 + batch * 300;
seed_bare(&mut conn, lo..lo + 300);
if force_every > 0 && batch % force_every == force_every - 1 {
let _ = checkpoint_truncate(&conn);
}
peak = peak.max(wal_bytes(p));
}
stop.store(true, Ordering::Relaxed);
reader.join().unwrap();
drop(conn);
peak
}
let unbounded = tmp_path();
let left_alone = run(&unbounded, 0);
std::fs::remove_file(&unbounded).ok();
let bounded = tmp_path();
let forced = run(&bounded, 4);
std::fs::remove_file(&bounded).ok();
eprintln!(
"peak WAL: autocheckpoint only {}, forced {}",
left_alone, forced
);
assert!(
forced * 2 < left_alone,
"forcing checkpoints did not bound the log: {} vs {}",
forced,
left_alone
);
}
#[test]
fn maintain_vacuums_when_slack_is_significant() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
seed_rows(&mut conn, 0..2000);
checkpoint_truncate(&conn).unwrap();
let before = std::fs::metadata(&p).unwrap().len();
{
let tx = conn.transaction().unwrap();
for i in 0..1900 {
delete_file_by_path(&tx, &format!("/tmp/bulk/{}.txt", i)).unwrap();
}
tx.commit().unwrap();
}
drop(conn);
let conn = crate::db::open::open_maintenance(p.to_str().unwrap()).unwrap();
let freelist: i64 = conn
.query_row("PRAGMA freelist_count", [], |r| r.get(0))
.unwrap();
assert!(freelist > 0, "the deletions should have freed pages");
let dir = p.parent().unwrap().to_string_lossy().into_owned();
assert!(
maintain(&conn, &dir).unwrap(),
"that much slack is worth a vacuum"
);
assert_eq!(
wal_bytes(&p),
0,
"the vacuum's own writes are checkpointed too"
);
assert!(
std::fs::metadata(&p).unwrap().len() < before,
"the file should have shrunk"
);
// The surviving rows are still there and still searchable.
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 100);
let hits: i64 = conn
.query_row(
"SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH 'lorem'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(hits, 100, "the FTS index survived the rewrite");
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn maintain_skips_vacuum_on_a_tight_file() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
seed_rows(&mut conn, 0..200);
drop(conn);
let conn = crate::db::open::open_maintenance(p.to_str().unwrap()).unwrap();
let dir = p.parent().unwrap().to_string_lossy().into_owned();
assert!(
!maintain(&conn, &dir).unwrap(),
"a file with no slack is not worth rewriting"
);
// The checkpoint is not conditional on the vacuum, though.
assert_eq!(wal_bytes(&p), 0);
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn set_content_failed_writes_failed_table() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let id = {
let tx = conn.transaction().unwrap();
let id = insert_file(
&tx,
&NewFile {
name: "oops.bin",
path: "/tmp/oops.bin",
parent: "/tmp",
size: 0,
mtime: 1,
inode: None,
device_id: None,
mime: None,
ftype: FileType::EMPTY,
hash: None,
needs_content: false,
},
)
.unwrap()
.expect("unique path");
set_content_failed(&tx, id, "bad parse").unwrap();
tx.commit().unwrap();
id
};
let reason: String = conn
.query_row(
"SELECT reason FROM failed_files WHERE file_id = ?1",
params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(reason, "bad parse");
let content_state: i64 = conn
.query_row(
"SELECT content_state FROM files WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(content_state, STATE_FAILED);
drop(conn);
std::fs::remove_file(&p).ok();
}
}