akar_processor/physical/write_ops/fts_sync.rs
1//! Commit-time FTS propagation (P107.1).
2//!
3//! [`sync_indexes_on_commit`] is invoked by the connection layer's commit
4//! pipe-line (Step 4.5 of `Connection::commit_write_txn`) with the txn's
5//! written `(table_id, row_id)` pairs. For every registered FTS index over a
6//! touched table it snapshots the current text values of those rows and applies
7//! them to the on-disk Tantivy index via [`akar_fts::build::apply_doc_writes`]:
8//! an update replaces the row's document, a delete (NULL / missing column)
9//! removes it.
10//!
11//! This is the *single* incremental writer AND the *single* reloader: the hook
12//! reloads the shared [`akar_fts::index::FtsIndexHandle`] reader after applying
13//! writes (P107.2). The `PhysicalFtsScan` read path only reuses that cached
14//! reader — never opening, reloading, or writing. Failures here are non-fatal
15//! by contract — they are surfaced as warnings by the caller so a stale index
16//! can never roll back an already-durable commit.
17//!
18//! **Crash recovery (P107.3):** the Tantivy segment commit is the crash
19//! boundary. A crash between the durable akar commit and this hook's
20//! `apply_doc_writes` commit loses only the in-flight increment (the index ends
21//! *stale*, never over-visible, never corrupt — `FtsIndexHandle::open_on_disk`
22//! reopens the last committed generation); the last committed state always
23//! survives. Pinned by `test_fts_crash_recovery_last_committed_survives`
24//! (akar-fts) and `test_fts_crash_recovery_across_db_reopen` (akar-main).
25
26use std::sync::Arc;
27
28use akar_common::types::Value;
29use akar_storage::table::TableCatalog;
30
31/// Apply the written rows of a committed transaction to every FTS index over
32/// a touched table.
33///
34/// `fts_indexes` is the `(name, table_name, column_name)` snapshot from the
35/// catalog; `written_rows` is the txn's deduplicated `(table_id, row_id)`
36/// write set. Returns the number of `(row, text)` writes applied, or an error
37/// message. Indexes with no on-disk Tantivy data yet (index never built /
38/// legacy schema) are skipped with a warning.
39pub fn sync_indexes_on_commit(
40 table_catalog: &Arc<TableCatalog>,
41 fts_indexes: &[(String, String, String)],
42 written_rows: &[(u64, u64)],
43) -> Result<usize, String> {
44 if fts_indexes.is_empty() || written_rows.is_empty() {
45 return Ok(0);
46 }
47
48 // On-disk indexes only: an in-memory database has no `<db_path>/fts/`
49 // directory, so there is nothing to propagate to.
50 let Some(base) = table_catalog.db_path() else {
51 return Ok(0);
52 };
53 if base.to_string_lossy() == ":memory:" {
54 return Ok(0);
55 }
56
57 let written_tables: std::collections::HashSet<u64> = written_rows.iter().map(|(t, _)| *t).collect();
58
59 let mut synced = 0usize;
60 for (name, table_name, column_name) in fts_indexes {
61 // Only indexes whose source table actually wrote rows need syncing.
62 let Some(source_table) = table_catalog.get_node_table_by_name(table_name) else {
63 continue;
64 };
65 let table_id = source_table.table_id;
66 if !written_tables.contains(&table_id) {
67 continue;
68 }
69 let Some(col_idx) = source_table.columns.iter().position(|c| c.name == *column_name) else {
70 continue;
71 };
72
73 let index_dir = base.join("fts").join(name);
74 if !index_dir.join("meta.json").exists() {
75 tracing::warn!(
76 "FTS: index '{name}' has no on-disk Tantivy data; skipping commit-time sync \
77 (rebuild with DROP + CREATE FTS INDEX if the schema changed)"
78 );
79 continue;
80 }
81
82 // Snapshot the current value of every written row. A single row may
83 // have several undo records within one txn; `get_value` returns the
84 // last committed value. The DashMap `Ref` is scoped out before the
85 // Tantivy writer is opened (DashMap is not re-entrant — the FTS test
86 // flake, P53.x).
87 let writes: Vec<(i64, Option<String>)> = {
88 let mut seen = std::collections::HashSet::with_capacity(written_rows.len());
89 let mut out = Vec::with_capacity(written_rows.len());
90 for (tid, row_id) in written_rows {
91 if *tid != table_id || !seen.insert(*row_id) {
92 continue;
93 }
94 let Ok(row) = usize::try_from(*row_id) else {
95 continue;
96 };
97 let text = match source_table.get_value(row, col_idx) {
98 Some(Value::String(s)) => Some(s.clone()),
99 _ => None, // NULL (soft-deleted / never set) → delete-term
100 };
101 out.push((*row_id as i64, text));
102 }
103 out
104 };
105
106 if writes.is_empty() {
107 continue;
108 }
109
110 let handle = akar_fts::index::runtime_handle(table_catalog, name, &index_dir)
111 .map_err(|e| format!("FTS: open index '{name}': {e}"))?;
112 akar_fts::build::apply_doc_writes(handle.inner(), column_name, &writes)
113 .map_err(|e| format!("FTS: sync index '{name}': {e}"))?;
114 // P107.2: reload the shared reader HERE, at the akar commit point.
115 // Scans reuse the same cached reader, so this single reload makes every
116 // subsequent scan see the rows just committed — read-after-write
117 // consistency with reload() called only on commit.
118 handle
119 .reload()
120 .map_err(|e| format!("FTS: reload index '{name}': {e}"))?;
121 synced += writes.len();
122 }
123
124 Ok(synced)
125}