macrame/integrity/shadow.rs
1//! Rebuilding `links_current` beside itself, in chunks (T1.2, D-082).
2//!
3//! # What this is for
4//!
5//! `rebuild_current` is one `BEGIN IMMEDIATE … COMMIT` holding the write lock
6//! for its whole duration — measured at 318 ms for 40,000 rows in `links`
7//! (D-077), and D-023 is why it cannot simply be split: the window between the
8//! `DELETE` and the `INSERT` is the entire of current belief, and a reader
9//! landing in it sees a graph with no edges and no error.
10//!
11//! Building the replacement *beside* the live table removes that window. The
12//! live table stays live and trigger-maintained throughout, so readers and
13//! `trg_links_single_open` keep working, and the only moment anything is
14//! unavailable is the swap.
15//!
16//! # Two things about this are easy to get wrong, and one of them is silent
17//!
18//! **`CREATE TABLE … AS SELECT` does not carry the schema.** The obvious way to
19//! make a shadow copies the rows and *nothing else*: no primary key, no `CHECK`
20//! constraints, no indexes. The swap then succeeds, the rename succeeds, and the
21//! next `INSERT INTO links` fails inside `trg_links_current_sync` with `ON
22//! CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint` —
23//! because the conflict target no longer exists. Probed on libSQL 0.9.30; the
24//! projection had stopped being maintained and the only symptom was an error on
25//! an unrelated write. The shadow is therefore created from
26//! [`CREATE_LINKS_CURRENT_TABLE`](crate::schema::ddl::CREATE_LINKS_CURRENT_TABLE)
27//! with the name substituted, so it cannot drift from the declared table.
28//!
29//! **The rename reparses the whole schema.** `ALTER TABLE … RENAME` (SQLite
30//! ≥ 3.25) re-resolves every trigger body, and both `links` triggers name
31//! `links_current` — so the rename fails with `error in trigger
32//! trg_links_current_sync: no such table: main.links_current` while they exist.
33//! Probed. The order that works, also probed, is `DROP TRIGGER` → `DROP TABLE` →
34//! `RENAME` → `CREATE INDEX` → recreate triggers. `PRAGMA legacy_alter_table=ON`
35//! also works and is **not** used: it disables the reference fixups the modern
36//! rename exists to perform.
37//!
38//! # Why the indexes are built inside the swap and not on the shadow
39//!
40//! This is the one place the shape is dictated by SQLite rather than chosen.
41//! Index names are global, so the shadow cannot carry `idx_lc_traversal_cover`
42//! while the live table still holds that name — and SQLite has no `ALTER INDEX
43//! … RENAME`. Building them on the shadow under temporary names would leave
44//! `links_current` permanently indexed under names that do not appear in
45//! [`CREATE_INDICES`](crate::schema::ddl::CREATE_INDICES), so the next migration
46//! would create a **second** copy of each.
47//!
48//! `DROP TABLE links_current` frees the names, and they are reusable within the
49//! same transaction (probed). So the swap pays the index builds, and what the
50//! chunking buys is that the *projection* — the window function over all of
51//! `links`, which is the O(E log E) term — happens outside the lock. That is a
52//! smaller win than "the swap is microseconds", which is what the naive reading
53//! of the shadow idea promises, and it is the real one.
54
55use super::projection_where;
56use crate::error::{DbError, Result};
57use crate::schema::ddl;
58
59/// The table the replacement is built in.
60///
61/// One fixed name rather than a unique one per attempt: a crashed rebuild must
62/// leave something a later attempt can recognise and drop, not an accumulating
63/// set of orphans that nothing knows the names of.
64pub(crate) const SHADOW_TABLE: &str = "links_current_shadow";
65
66/// Distinct `source_id`s projected per chunk.
67///
68/// Sized against [`CHUNK_BUDGET`](crate::CHUNK_BUDGET) rather than derived from
69/// it, and the unit is sources rather than rows for a reason that is also a
70/// limitation: the chunk boundary has to be a range the window function can be
71/// restricted to, and `PARTITION BY (source_id, target_id, edge_type,
72/// valid_from)` means a partition never spans a `source_id`. So a source is the
73/// smallest safe unit — and a single hub node with a very large out-degree is
74/// one chunk however long it takes. That case is bounded by the graph, not by
75/// this constant, and no chunking of this shape can fix it.
76pub(crate) const SOURCES_PER_CHUNK: usize = 256;
77
78/// One step of a chunked rebuild, as sent to the actor.
79///
80/// Three commands rather than one because each must be its own **turn** — the
81/// whole point is that the actor returns to its `select!` between chunks, so a
82/// high-priority assertion can jump ahead. A loop inside one command would
83/// produce the same small transactions inside one hold and buy nothing, which is
84/// the same trap [`Database::archive_windowed`](crate::Database::archive_windowed)
85/// avoids.
86#[derive(Debug, Clone, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum ShadowStep {
89 /// Drop any orphan shadow, create a fresh one from the declared DDL.
90 Begin,
91 /// Project the next `SOURCES_PER_CHUNK` sources into the shadow.
92 Fill { after: Option<String> },
93 /// Catch up on writes since `build_start`, then swap. One transaction.
94 ///
95 /// `epoch` is the archive count [`ShadowOutcome::Started`] reported. It
96 /// travels out to the caller and back rather than being remembered by the
97 /// actor: the actor is stateless per command by construction, and a single
98 /// remembered slot would be shared — and silently corrupted — by two
99 /// rebuilds running at once.
100 Swap { build_start: String, epoch: u64 },
101}
102
103/// What a [`ShadowStep`] produced.
104#[derive(Debug, Clone, PartialEq, Eq)]
105#[non_exhaustive]
106pub enum ShadowOutcome {
107 /// `build_start`, and the actor's archive epoch as of the start.
108 Started { build_start: String, epoch: u64 },
109 /// The last `source_id` projected, or `None` when the table is exhausted.
110 Filled { last: Option<String> },
111 /// Rows in the new `links_current`.
112 Swapped { rows: usize },
113}
114
115const SHADOW_COLUMNS: &str = "(source_id, target_id, edge_type, valid_from, \
116 valid_to, weight, properties, recorded_at, branch_id)";
117
118/// Create the shadow, and report the transaction time the build starts from.
119///
120/// `build_start` is `MAX(recorded_at)` **before** any chunk runs, so every write
121/// that lands during the build is at or after it and the catch-up pass can find
122/// them all by that one column. Taking it after the first chunk would leave a
123/// gap no later pass could name.
124pub(crate) async fn begin(conn: &libsql::Connection) -> Result<String> {
125 // An orphan from a crashed attempt is dropped rather than reused: its
126 // contents are a projection of a `links` that has since moved on, and there
127 // is no way to tell how far.
128 conn.execute(&format!("DROP TABLE IF EXISTS {SHADOW_TABLE}"), ())
129 .await?;
130 conn.execute(&shadow_ddl(), ()).await?;
131
132 let build_start: Option<String> = conn
133 .query("SELECT MAX(recorded_at) FROM links", ())
134 .await?
135 .next()
136 .await?
137 .and_then(|row| row.get(0).ok());
138
139 // An empty `links` still needs a stamp the catch-up can compare against.
140 // The epoch sentinel is below every canonical timestamp, so the catch-up
141 // sees every row — which on an empty table is none, and on a table written
142 // to during the build is all of them. Correct in both directions.
143 Ok(build_start.unwrap_or_else(|| "0001-01-01T00:00:00.000000Z".to_string()))
144}
145
146/// [`CREATE_LINKS_CURRENT_TABLE`](ddl::CREATE_LINKS_CURRENT_TABLE) with the
147/// table name substituted — primary key, `CHECK`s and all.
148///
149/// Substituted rather than written out, so the shadow cannot drift from the
150/// declared table. See the module header for what happens when it does.
151fn shadow_ddl() -> String {
152 ddl::CREATE_LINKS_CURRENT_TABLE.replacen("links_current", SHADOW_TABLE, 1)
153}
154
155/// Project one chunk of sources into the shadow.
156///
157/// Returns the last `source_id` written, or `None` when there is nothing left.
158pub(crate) async fn fill_chunk(
159 conn: &libsql::Connection,
160 after: Option<&str>,
161) -> Result<Option<String>> {
162 // Keyset pagination over the *distinct* sources, so the boundary is a real
163 // source and a chunk never splits one. `links`'s primary key leads on
164 // `source_id`, so this is an index range scan of at most SOURCES_PER_CHUNK
165 // distinct values rather than a pass over the table.
166 let low = after.unwrap_or("");
167 let high: Option<String> = conn
168 .query(
169 &format!(
170 "SELECT MAX(source_id) FROM ( \
171 SELECT DISTINCT source_id FROM links \
172 WHERE source_id > ?1 ORDER BY source_id LIMIT {SOURCES_PER_CHUNK} \
173 )"
174 ),
175 libsql::params![low],
176 )
177 .await?
178 .next()
179 .await?
180 .and_then(|row| row.get::<Option<String>>(0).ok())
181 .flatten();
182
183 let Some(high) = high else {
184 return Ok(None);
185 };
186
187 conn.execute(
188 &format!(
189 "INSERT INTO {SHADOW_TABLE} {SHADOW_COLUMNS} {projection}",
190 projection = projection_where("source_id > ?1 AND source_id <= ?2")
191 ),
192 libsql::params![low, high.as_str()],
193 )
194 .await?;
195
196 Ok(Some(high))
197}
198
199/// Catch up, then swap. One transaction, and the only moment `links_current` is
200/// not the live table.
201///
202/// `epoch` is the actor's archive count from [`begin`]. If an archive committed
203/// during the build, the shadow is a projection of rows some of which no longer
204/// exist — a *deletion* the catch-up cannot see, because the catch-up finds work
205/// by `recorded_at` and a deleted row has no `recorded_at` to find. Rather than
206/// making the swap verify itself (O(E log E) under the lock, which is the cost
207/// this exists to remove), the rebuild is abandoned and the caller told to
208/// retry. Archives are rare and a retry is cheap; a silently wrong `links_current`
209/// is neither.
210pub(crate) async fn swap(
211 conn: &libsql::Connection,
212 build_start: &str,
213 epoch: u64,
214 epoch_now: u64,
215) -> Result<usize> {
216 if epoch != epoch_now {
217 conn.execute(&format!("DROP TABLE IF EXISTS {SHADOW_TABLE}"), ())
218 .await?;
219 return Err(DbError::RebuildInterrupted {
220 reason: format!(
221 "{} archive session(s) committed during the shadow build; their \
222 deletions are invisible to a catch-up keyed on recorded_at. \
223 Re-run rebuild_current_chunked.",
224 epoch_now - epoch
225 ),
226 });
227 }
228
229 let tx = conn
230 .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
231 .await?;
232
233 // --- catch-up: only the keys written since the build began ---
234 //
235 // Bounded by writes during the rebuild, not by the size of `links`. The
236 // `DELETE` and the re-`INSERT` are both restricted by the same subquery, so
237 // a key written during the build is replaced rather than duplicated.
238 //
239 // `branch_id` is deliberately *not* in this key, and leaving it out is what
240 // keeps the pass correct rather than what breaks it (v12, §15.2). The
241 // `DELETE` clears every lineage's row for a touched edge and the `INSERT`
242 // re-derives every lineage's winner for the same edges, so the pair stays
243 // symmetric. Narrowing the delete by lineage without narrowing the
244 // projection would insert a second lineage's row beside one never removed.
245 let touched = "(source_id, target_id, edge_type, valid_from) IN ( \
246 SELECT source_id, target_id, edge_type, valid_from \
247 FROM links WHERE recorded_at >= ?1)";
248 tx.execute(
249 &format!("DELETE FROM {SHADOW_TABLE} WHERE {touched}"),
250 libsql::params![build_start],
251 )
252 .await?;
253 tx.execute(
254 &format!(
255 "INSERT INTO {SHADOW_TABLE} {SHADOW_COLUMNS} {projection}",
256 projection = projection_where(
257 "(source_id, target_id, edge_type, valid_from) IN ( \
258 SELECT source_id, target_id, edge_type, valid_from \
259 FROM links WHERE recorded_at >= ?1)"
260 )
261 ),
262 libsql::params![build_start],
263 )
264 .await?;
265
266 // --- the swap, in the one order that works ---
267 //
268 // The drop list and the create list below are the same list read twice
269 // (0.15.19, review C-12). This used to filter `CREATE_INDICES` and
270 // `CREATE_TRIGGERS` on `contains("links_current")`, which is a substring
271 // test standing in for a name: correct for every statement declared so far,
272 // and quietly wrong for the first index on `links` whose text mentions the
273 // projection. `ddl::LINKS_CURRENT_INDICES` and
274 // `ddl::LINKS_CURRENT_TRIGGERS` say which, and two tests in that module
275 // fail the build if the schema grows one this swap would not put back.
276 for (name, _) in ddl::LINKS_CURRENT_TRIGGERS {
277 tx.execute(&format!("DROP TRIGGER IF EXISTS {name}"), ())
278 .await?;
279 }
280 tx.execute("DROP TABLE links_current", ()).await?;
281 tx.execute(
282 &format!("ALTER TABLE {SHADOW_TABLE} RENAME TO links_current"),
283 (),
284 )
285 .await?;
286
287 // The names are free now that the old table is gone, and reusable in this
288 // same transaction (probed). Taken from the crate's own DDL so the rebuilt
289 // indexes cannot differ from the declared ones.
290 for stmt in ddl::LINKS_CURRENT_INDICES {
291 tx.execute(stmt, ()).await?;
292 }
293 for (_, trigger) in ddl::LINKS_CURRENT_TRIGGERS {
294 tx.execute(trigger, ()).await?;
295 }
296
297 let rows: i64 = tx
298 .query("SELECT COUNT(*) FROM links_current", ())
299 .await?
300 .next()
301 .await?
302 .and_then(|row| row.get(0).ok())
303 .unwrap_or(0);
304
305 tx.commit().await?;
306 Ok(rows as usize)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 /// The shadow's DDL must be the declared table's, with only the name changed.
314 ///
315 /// This is the silent failure from the module header, pinned at the cheapest
316 /// possible level. If the substitution ever stops producing a primary key,
317 /// the swap still succeeds and `trg_links_current_sync` breaks on the next
318 /// write — a failure whose symptom appears on an unrelated operation.
319 #[test]
320 fn the_shadow_carries_the_declared_schema_not_just_the_columns() {
321 let ddl = shadow_ddl();
322 assert!(ddl.contains(SHADOW_TABLE), "{ddl}");
323 // Not a pinned literal. The property that matters is that the
324 // shadow's key and the sync trigger's `ON CONFLICT` target are the
325 // *same* columns, so the check reads the target out of the trigger
326 // and asks the shadow for it. Pinning the text instead meant that
327 // widening the key at v12 produced a red test whose fix was to
328 // retype the new spelling — which proves the two were edited
329 // together once, and nothing about whether they still agree.
330 let target = ddl::CREATE_LINKS_CURRENT_SYNC
331 .split_once("ON CONFLICT(")
332 .and_then(|(_, rest)| rest.split_once(')'))
333 .map(|(cols, _)| cols.to_string())
334 .expect("the sync trigger declares an ON CONFLICT target");
335 assert!(
336 ddl.contains(&format!("PRIMARY KEY ({target})")),
337 "the shadow's primary key is not the sync trigger's ON CONFLICT \
338 target ({target}), so the trigger breaks on the first write \
339 after the swap: {ddl}"
340 );
341 assert!(
342 ddl.contains("CHECK"),
343 "the shadow dropped the canonical-timestamp checks: {ddl}"
344 );
345 // Only the table name changed — `links_current` must not survive
346 // anywhere in the shadow's own DDL.
347 assert!(
348 !ddl.replace(SHADOW_TABLE, "").contains("links_current"),
349 "the substitution left a reference to the live table: {ddl}"
350 );
351 }
352
353 /// The chunk restriction has to sit inside the window function's subquery.
354 ///
355 /// Outside it, the projection still ranks every partition in `links` and a
356 /// chunk costs what the whole rebuild costs — the query would be correct and
357 /// the chunking pointless, which is the kind of thing that only shows up in
358 /// a benchmark nobody ran.
359 #[test]
360 fn the_chunk_restriction_is_inside_the_window() {
361 let sql = projection_where("source_id > ?1 AND source_id <= ?2");
362 let inner = sql.find("FROM links").unwrap();
363 let outer = sql.rfind("WHERE rn = 1").unwrap();
364 let clause = sql.find("source_id > ?1").unwrap();
365 assert!(
366 clause > inner && clause < outer,
367 "the restriction landed outside the subquery:\n{sql}"
368 );
369 }
370}