use crate::error::{DbError, Result};
use crate::schema::ddl;
pub(crate) const SHADOW_TABLE: &str = "links_current_shadow";
pub(crate) const SOURCES_PER_CHUNK: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShadowStep {
Begin,
Fill { after: Option<String> },
Swap { build_start: String, epoch: u64 },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShadowOutcome {
Started { build_start: String, epoch: u64 },
Filled { last: Option<String> },
Swapped { rows: usize },
}
fn projection_where(clause: &str) -> String {
format!(
r#"
SELECT source_id, target_id, edge_type, valid_from,
valid_to, weight, properties, recorded_at
FROM (
SELECT source_id, target_id, edge_type, valid_from,
valid_to, weight, properties, recorded_at,
ROW_NUMBER() OVER (
PARTITION BY source_id, target_id, edge_type, valid_from
ORDER BY recorded_at DESC
) AS rn
FROM links
WHERE {clause}
) WHERE rn = 1
"#
)
}
const SHADOW_COLUMNS: &str = "(source_id, target_id, edge_type, valid_from, \
valid_to, weight, properties, recorded_at)";
pub(crate) async fn begin(conn: &libsql::Connection) -> Result<String> {
conn.execute(&format!("DROP TABLE IF EXISTS {SHADOW_TABLE}"), ())
.await?;
conn.execute(&shadow_ddl(), ()).await?;
let build_start: Option<String> = conn
.query("SELECT MAX(recorded_at) FROM links", ())
.await?
.next()
.await?
.and_then(|row| row.get(0).ok());
Ok(build_start.unwrap_or_else(|| "0001-01-01T00:00:00.000000Z".to_string()))
}
fn shadow_ddl() -> String {
ddl::CREATE_LINKS_CURRENT_TABLE.replacen("links_current", SHADOW_TABLE, 1)
}
pub(crate) async fn fill_chunk(
conn: &libsql::Connection,
after: Option<&str>,
) -> Result<Option<String>> {
let low = after.unwrap_or("");
let high: Option<String> = conn
.query(
&format!(
"SELECT MAX(source_id) FROM ( \
SELECT DISTINCT source_id FROM links \
WHERE source_id > ?1 ORDER BY source_id LIMIT {SOURCES_PER_CHUNK} \
)"
),
libsql::params![low],
)
.await?
.next()
.await?
.and_then(|row| row.get::<Option<String>>(0).ok())
.flatten();
let Some(high) = high else {
return Ok(None);
};
conn.execute(
&format!(
"INSERT INTO {SHADOW_TABLE} {SHADOW_COLUMNS} {projection}",
projection = projection_where("source_id > ?1 AND source_id <= ?2")
),
libsql::params![low, high.as_str()],
)
.await?;
Ok(Some(high))
}
pub(crate) async fn swap(
conn: &libsql::Connection,
build_start: &str,
epoch: u64,
epoch_now: u64,
) -> Result<usize> {
if epoch != epoch_now {
conn.execute(&format!("DROP TABLE IF EXISTS {SHADOW_TABLE}"), ())
.await?;
return Err(DbError::RebuildInterrupted {
reason: format!(
"{} archive session(s) committed during the shadow build; their \
deletions are invisible to a catch-up keyed on recorded_at. \
Re-run rebuild_current_chunked.",
epoch_now - epoch
),
});
}
let tx = conn
.transaction_with_behavior(libsql::TransactionBehavior::Immediate)
.await?;
let touched = "(source_id, target_id, edge_type, valid_from) IN ( \
SELECT source_id, target_id, edge_type, valid_from \
FROM links WHERE recorded_at >= ?1)";
tx.execute(
&format!("DELETE FROM {SHADOW_TABLE} WHERE {touched}"),
libsql::params![build_start],
)
.await?;
tx.execute(
&format!(
"INSERT INTO {SHADOW_TABLE} {SHADOW_COLUMNS} {projection}",
projection = projection_where(
"(source_id, target_id, edge_type, valid_from) IN ( \
SELECT source_id, target_id, edge_type, valid_from \
FROM links WHERE recorded_at >= ?1)"
)
),
libsql::params![build_start],
)
.await?;
for stmt in [
"DROP TRIGGER IF EXISTS trg_links_current_sync",
"DROP TRIGGER IF EXISTS trg_links_single_open",
"DROP TABLE links_current",
] {
tx.execute(stmt, ()).await?;
}
tx.execute(
&format!("ALTER TABLE {SHADOW_TABLE} RENAME TO links_current"),
(),
)
.await?;
for stmt in ddl::CREATE_INDICES {
if stmt.contains("links_current") {
tx.execute(stmt, ()).await?;
}
}
for trigger in ddl::CREATE_TRIGGERS {
if trigger.contains("trg_links_current_sync") || trigger.contains("trg_links_single_open") {
tx.execute(trigger, ()).await?;
}
}
let rows: i64 = tx
.query("SELECT COUNT(*) FROM links_current", ())
.await?
.next()
.await?
.and_then(|row| row.get(0).ok())
.unwrap_or(0);
tx.commit().await?;
Ok(rows as usize)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shadow_carries_the_declared_schema_not_just_the_columns() {
let ddl = shadow_ddl();
assert!(ddl.contains(SHADOW_TABLE), "{ddl}");
assert!(
ddl.contains("PRIMARY KEY (source_id, target_id, edge_type, valid_from)"),
"the shadow has no primary key, so the sync trigger's ON CONFLICT \
target will not exist after the swap: {ddl}"
);
assert!(
ddl.contains("CHECK"),
"the shadow dropped the canonical-timestamp checks: {ddl}"
);
assert!(
!ddl.replace(SHADOW_TABLE, "").contains("links_current"),
"the substitution left a reference to the live table: {ddl}"
);
}
#[test]
fn the_chunk_restriction_is_inside_the_window() {
let sql = projection_where("source_id > ?1 AND source_id <= ?2");
let inner = sql.find("FROM links").unwrap();
let outer = sql.rfind("WHERE rn = 1").unwrap();
let clause = sql.find("source_id > ?1").unwrap();
assert!(
clause > inner && clause < outer,
"the restriction landed outside the subquery:\n{sql}"
);
}
}