argand-site-registry/crates/argand-site-registry/src/store.rs
nicweyand 3e0cc1bc50
Some checks failed
Standalone registry checks / check (push) Has been cancelled
release: add Web Graph authority evidence for v0.6
2026-09-22 08:25:24 -04:00

511 lines
18 KiB
Rust

// By Nic Weyand!
//! Transactional source import and durable replay checkpoints.
use crate::{
adapters::{self, RecordSink},
model::{Compression, Format, Record, SourceManifest},
normalize::Normalizer,
};
use anyhow::{Context, ensure};
use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{
cell::RefCell,
collections::BTreeSet,
io::{BufReader, Read},
path::Path,
rc::Rc,
time::Duration,
};
/// Adapter/normalization contract recorded in all generation identities.
pub const RULE_VERSION: &str = "argand.site-rules/v4";
/// Deterministic Web Graph projection selected from already imported website evidence.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct WebGraphSelection {
/// Replacement scope that must be bound into the Web Graph source manifest.
pub scope: String,
/// Number of distinct registrable domains retained from the graph.
pub domains: u64,
}
/// Computes the exact public-identity domain set used by a compact Web Graph import.
///
/// # Errors
/// Requires one completed PSL source and at least one valid website assertion.
pub fn web_graph_selection(db: &Connection) -> anyhow::Result<WebGraphSelection> {
let (selection, _) = web_graph_targets(db)?;
Ok(selection)
}
fn web_graph_targets(db: &Connection) -> anyhow::Result<(WebGraphSelection, BTreeSet<String>)> {
let (psl_source, encoded): (String, String) = db
.query_row(
"SELECT f.source_id,f.value FROM facts f JOIN sources s ON s.id=f.source_id WHERE s.complete=1 AND f.predicate='psl' ORDER BY f.source_id LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.context("import a complete PSL snapshot before Common Crawl Web Graph")?;
let psl: String = serde_json::from_str(&encoded)?;
let normalizer = Normalizer::new(psl.as_bytes(), psl_source)?;
let mut statement = db.prepare(
"SELECT f.value FROM facts f JOIN sources s ON s.id=f.source_id WHERE s.complete=1 AND f.predicate='website' ORDER BY f.id",
)?;
let values = statement.query_map([], |row| row.get::<_, String>(0))?;
let mut domains = BTreeSet::new();
for encoded in values {
let value: serde_json::Value = serde_json::from_str(&encoded?)?;
if let Some(url) = value.get("url").and_then(serde_json::Value::as_str)
&& let Ok(property) = normalizer.url(url)
{
domains.insert(property.domain.registrable_domain);
}
}
ensure!(
!domains.is_empty(),
"import website assertions before Common Crawl Web Graph"
);
let digest = crate::digest(&serde_json::to_vec(&domains)?);
let selection = WebGraphSelection {
scope: format!("candidate-domains:{digest}"),
domains: u64::try_from(domains.len())?,
};
Ok((selection, domains))
}
/// Whether a signed immutable generation uses a reader-compatible rule contract.
#[must_use]
pub fn supported_rule_version(version: &str) -> bool {
version == RULE_VERSION
}
pub(crate) fn legacy_rule_version(version: &str) -> bool {
matches!(
version,
"argand.site-rules/v1" | "argand.site-rules/v2" | "argand.site-rules/v3"
)
}
/// Opens or migrates the local assertion store with bounded page cache.
///
/// # Errors
/// Rejects newer schemas and SQLite/filesystem failures.
pub fn open(path: &Path) -> anyhow::Result<Connection> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
let db = Connection::open(path)?;
configure(&db)?;
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
match version {
0 => {
db.execute_batch("BEGIN IMMEDIATE")?;
db.execute_batch(include_str!("../migrations/001.sql"))?;
db.execute_batch(include_str!("../migrations/002.sql"))?;
db.execute_batch(include_str!("../migrations/003.sql"))?;
db.execute_batch(include_str!("../migrations/004.sql"))?;
db.execute_batch(include_str!("../migrations/005.sql"))?;
db.execute_batch("COMMIT")?;
}
1 => {
db.execute_batch("BEGIN IMMEDIATE")?;
db.execute_batch(include_str!("../migrations/002.sql"))?;
db.execute_batch(include_str!("../migrations/003.sql"))?;
db.execute_batch(include_str!("../migrations/004.sql"))?;
db.execute_batch(include_str!("../migrations/005.sql"))?;
db.execute_batch("COMMIT")?;
}
2 => {
db.execute_batch("BEGIN IMMEDIATE")?;
db.execute_batch(include_str!("../migrations/003.sql"))?;
db.execute_batch(include_str!("../migrations/004.sql"))?;
db.execute_batch(include_str!("../migrations/005.sql"))?;
db.execute_batch("COMMIT")?;
}
3 => {
db.execute_batch("BEGIN IMMEDIATE")?;
db.execute_batch(include_str!("../migrations/004.sql"))?;
db.execute_batch(include_str!("../migrations/005.sql"))?;
db.execute_batch("COMMIT")?;
}
4 => {
db.execute_batch("BEGIN IMMEDIATE")?;
db.execute_batch(include_str!("../migrations/005.sql"))?;
db.execute_batch("COMMIT")?;
}
5 => {}
_ => anyhow::bail!("unsupported registry schema {version}"),
}
let rules: String = db.query_row(
"SELECT rules FROM registry_metadata WHERE singleton=1",
[],
|r| r.get(0),
)?;
ensure!(
rules == RULE_VERSION,
"source parser rules changed; migrate explicitly or reimport pinned sources into a new store"
);
Ok(db)
}
pub(crate) fn configure(db: &Connection) -> anyhow::Result<()> {
db.busy_timeout(Duration::from_secs(10))?;
db.execute_batch("PRAGMA foreign_keys=ON; PRAGMA trusted_schema=OFF; PRAGMA cache_size=-8192; PRAGMA temp_store=FILE; PRAGMA synchronous=FULL;")?;
Ok(())
}
/// Hard resource ceilings applied to one source import.
#[derive(Clone, Copy, Debug)]
pub struct ImportLimits {
/// Maximum bytes produced by the manifest-declared compression layer.
pub maximum_expanded_bytes: u64,
/// Maximum source records accepted in this snapshot.
pub maximum_records: u64,
/// Maximum logical SQLite growth caused by this snapshot.
pub maximum_database_growth_bytes: u64,
}
impl ImportLimits {
/// Computes finite defaults scaled to the pinned compressed source size.
#[must_use]
pub fn for_manifest(manifest: &SourceManifest) -> Self {
Self {
maximum_expanded_bytes: manifest
.bytes
.saturating_mul(100)
.clamp(64 * 1024 * 1024, 1024 * 1024 * 1024 * 1024),
maximum_records: 500_000_000,
maximum_database_growth_bytes: manifest
.bytes
.saturating_mul(8)
.clamp(1024 * 1024 * 1024, 1024 * 1024 * 1024 * 1024),
}
}
}
/// Verifies a source object before and after parsing, publishing completion last.
/// Incomplete transactions are rolled back; committed batches replay idempotently.
///
/// # Errors
/// Returns source integrity, parser, filesystem, or database errors.
pub fn import(
db: &mut Connection,
manifest: &SourceManifest,
path: &Path,
) -> anyhow::Result<String> {
import_with_limits(db, manifest, path, ImportLimits::for_manifest(manifest))
}
/// Imports with explicit resource ceilings, primarily for constrained operators.
///
/// # Errors
/// Returns source integrity, resource, parser, filesystem, or database errors.
#[allow(clippy::too_many_lines)] // Keep verification, checkpoints, and cleanup in one transaction lifecycle.
pub fn import_with_limits(
db: &mut Connection,
manifest: &SourceManifest,
path: &Path,
limits: ImportLimits,
) -> anyhow::Result<String> {
manifest.validate()?;
let graph_targets = if manifest.format == Format::CommonCrawlDomainRanksTsv {
let (selection, targets) = web_graph_targets(db)?;
ensure!(
manifest.scope == selection.scope,
"Web Graph manifest scope does not match current candidate domains; expected {}",
selection.scope
);
Some(targets)
} else {
None
};
ensure!(
limits.maximum_expanded_bytes > 0
&& limits.maximum_records > 0
&& limits.maximum_database_growth_bytes > 0,
"import limits must be positive"
);
let mut file = crate::generation::open_no_follow(path)?;
let metadata = file.metadata()?;
ensure!(
metadata.is_file() && metadata.len() == manifest.bytes,
"source length/type mismatch"
);
let id = manifest.id()?;
let declaration = serde_json::to_string(manifest)?;
db.execute("INSERT OR IGNORE INTO sources(id,source,scope,retrieved_at,manifest) VALUES(?1,?2,?3,?4,?5)", params![id, manifest.source.key(),manifest.scope,manifest.retrieved_at.to_rfc3339(),declaration])?;
let (checkpoint, complete): (u64, bool) = db.query_row(
"SELECT checkpoint,complete FROM sources WHERE id=?1",
[&id],
|r| Ok((unsigned(r, 0)?, r.get(1)?)),
)?;
if complete {
let mut hash = Sha256::new();
let bytes = std::io::copy(&mut file, &mut HashWriter(&mut hash))?;
ensure!(
bytes == manifest.bytes && format!("{:x}", hash.finalize()) == manifest.sha256,
"source digest mismatch"
);
return Ok(id);
}
let observation = Rc::new(RefCell::new(StreamObservation::default()));
let observed = ObservedReader {
inner: file,
observation: Rc::clone(&observation),
};
let reader: Box<dyn Read> = match manifest.compression {
Compression::None => Box::new(observed),
Compression::Gzip => Box::new(flate2::read::MultiGzDecoder::new(observed)),
Compression::Bzip2 => Box::new(bzip2::read::MultiBzDecoder::new(observed)),
};
let mut reader = BufReader::new(ExpandedReader {
inner: reader,
read: 0,
maximum: limits.maximum_expanded_bytes,
});
let database_before = database_bytes(db)?;
let page_size: u64 = db.query_row("PRAGMA page_size", [], |row| unsigned(row, 0))?;
let original_max_pages: u64 =
db.query_row("PRAGMA max_page_count", [], |row| unsigned(row, 0))?;
let import_max_pages = database_before
.saturating_add(limits.maximum_database_growth_bytes)
.checked_div(page_size)
.context("invalid SQLite page size")?
.min(original_max_pages);
db.pragma_update(None, "max_page_count", i64::try_from(import_max_pages)?)?;
db.execute_batch("BEGIN IMMEDIATE")?;
let result = {
let mut sink = SqlSink {
db,
source: &id,
ordinal: 0,
checkpoint,
limits,
database_before,
};
let parsed = adapters::adapter(
manifest.format,
manifest.record_bytes_limit(),
manifest
.coverage
.as_ref()
.is_some_and(|coverage| coverage.kind == crate::model::CoverageKind::Delta),
graph_targets,
)
.ingest(&mut reader, &mut sink);
parsed.and_then(|()| {
std::io::copy(&mut reader, &mut std::io::sink())?;
ensure!(
sink.ordinal > 0 && sink.ordinal >= checkpoint,
"source empty or shorter than checkpoint"
);
let observed = observation.borrow();
ensure!(
observed.bytes == manifest.bytes
&& format!("{:x}", observed.hash.clone().finalize()) == manifest.sha256,
"source digest mismatch"
);
sink.ensure_database_growth()?;
sink.db.execute(
"UPDATE sources SET checkpoint=?2,complete=1 WHERE id=?1",
params![id, i64::try_from(sink.ordinal)?],
)?;
Ok(())
})
};
match result {
Ok(()) => db.execute_batch("COMMIT")?,
Err(error) => {
recover_failed_import(db, &id, original_max_pages)?;
return Err(error);
}
}
db.pragma_update(None, "max_page_count", i64::try_from(original_max_pages)?)?;
Ok(id)
}
fn recover_failed_import(db: &Connection, id: &str, original_max_pages: u64) -> anyhow::Result<()> {
// SQLITE_FULL and some I/O errors can roll back the transaction themselves.
// Cleanup and ceiling restoration must still run.
let rollback = if db.is_autocommit() {
Ok(())
} else {
db.execute_batch("ROLLBACK")
};
let cleanup = discard_incomplete_source(db, id);
let restore = db.pragma_update(None, "max_page_count", i64::try_from(original_max_pages)?);
rollback.context("roll back failed source import")?;
cleanup.context("discard incomplete source after import failure")?;
restore.context("restore database growth ceiling after import failure")?;
Ok(())
}
fn discard_incomplete_source(db: &Connection, id: &str) -> anyhow::Result<()> {
db.execute_batch("BEGIN IMMEDIATE")?;
db.execute("DELETE FROM facts WHERE source_id=?1", [id])?;
db.execute("DELETE FROM records WHERE source_id=?1", [id])?;
db.execute("DELETE FROM sources WHERE id=?1 AND complete=0", [id])?;
db.execute_batch("COMMIT")?;
Ok(())
}
fn database_bytes(db: &Connection) -> anyhow::Result<u64> {
let page_count: u64 = db.query_row("PRAGMA page_count", [], |row| unsigned(row, 0))?;
let page_size: u64 = db.query_row("PRAGMA page_size", [], |row| unsigned(row, 0))?;
Ok(page_count.saturating_mul(page_size))
}
struct SqlSink<'a> {
db: &'a Connection,
source: &'a str,
ordinal: u64,
checkpoint: u64,
limits: ImportLimits,
database_before: u64,
}
impl SqlSink<'_> {
fn ensure_database_growth(&self) -> anyhow::Result<()> {
ensure!(
database_bytes(self.db)?.saturating_sub(self.database_before)
<= self.limits.maximum_database_growth_bytes,
"source database growth cap exceeded"
);
Ok(())
}
}
impl RecordSink for SqlSink<'_> {
fn emit(&mut self, record: Record) -> anyhow::Result<()> {
self.ordinal += 1;
ensure!(
self.ordinal <= self.limits.maximum_records,
"source record cap exceeded"
);
if self.ordinal <= self.checkpoint {
return Ok(());
}
ensure!(
record.native_id.len() <= 8192 && record.facts.len() <= 100_000,
"record identity/fact bound exceeded"
);
self.db
.prepare_cached("INSERT INTO records VALUES(?1,?2,?3,?4)")?
.execute(params![
self.source,
i64::try_from(self.ordinal)?,
record.native_id,
serde_json::to_string(&record.raw)?
])?;
for fact in record.facts {
ensure!(fact.confidence <= 10000, "invalid confidence");
let id = crate::digest(&serde_json::to_vec(&(self.source, self.ordinal, &fact))?);
self.db
.prepare_cached("INSERT OR IGNORE INTO facts VALUES(?1,?2,?3,?4,?5,?6,?7,?8)")?
.execute(params![
id,
self.source,
i64::try_from(self.ordinal)?,
fact.subject,
fact.predicate,
serde_json::to_string(&fact.value)?,
fact.selector,
fact.confidence
])?;
}
if self.ordinal.is_multiple_of(256) {
self.ensure_database_growth()?;
self.db.execute(
"UPDATE sources SET checkpoint=?2 WHERE id=?1",
params![self.source, i64::try_from(self.ordinal)?],
)?;
self.db.execute_batch("COMMIT; BEGIN IMMEDIATE")?;
}
Ok(())
}
}
#[derive(Default)]
struct StreamObservation {
hash: Sha256,
bytes: u64,
}
struct ObservedReader<R> {
inner: R,
observation: Rc<RefCell<StreamObservation>>,
}
impl<R: Read> Read for ObservedReader<R> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
let count = self.inner.read(buffer)?;
let mut observation = self.observation.borrow_mut();
observation.hash.update(&buffer[..count]);
observation.bytes = observation.bytes.saturating_add(count as u64);
Ok(count)
}
}
struct ExpandedReader {
inner: Box<dyn Read>,
read: u64,
maximum: u64,
}
struct HashWriter<'a>(&'a mut Sha256);
impl std::io::Write for HashWriter<'_> {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
self.0.update(buffer);
Ok(buffer.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Read for ExpandedReader {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
let remaining = self.maximum.saturating_sub(self.read);
if remaining == 0 {
let mut probe = [0_u8; 1];
if self.inner.read(&mut probe)? != 0 {
return Err(std::io::Error::other("source expansion cap exceeded"));
}
return Ok(0);
}
let bound = usize::try_from(remaining.min(buffer.len() as u64)).unwrap_or(buffer.len());
let count = self.inner.read(&mut buffer[..bound])?;
self.read = self.read.saturating_add(count as u64);
Ok(count)
}
}
/// Returns the manifest of a complete source snapshot.
///
/// # Errors
/// Fails on missing, incomplete, or malformed declarations.
pub fn source(db: &Connection, id: &str) -> anyhow::Result<SourceManifest> {
let raw: String = db
.query_row(
"SELECT manifest FROM sources WHERE id=?1 AND complete=1",
[id],
|r| r.get(0),
)
.optional()?
.context("source is absent or incomplete")?;
Ok(serde_json::from_str(&raw)?)
}
pub(crate) fn unsigned(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<u64> {
u64::try_from(row.get::<_, i64>(index)?).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
index,
rusqlite::types::Type::Integer,
Box::new(e),
)
})
}