Skip to main content

aft/blob_store/
mod.rs

1//! Immutable, content-addressed payload storage shared by a repository family.
2//!
3//! Each [`BlobStore`] owns one SQLite connection for exactly one plane.  The
4//! caller supplies the family (`artifact_key`); this module maps it to the
5//! fixed on-disk layout and never derives a family from a checkout path.
6
7use std::collections::HashMap;
8use std::error::Error;
9use std::fmt;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::sync::{Mutex, MutexGuard, OnceLock};
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15use rusqlite::{params, Connection, ErrorCode, OptionalExtension, TransactionBehavior};
16
17use crate::db::lifecycle::{SqliteStore, TrackedConnection};
18
19/// The SQLite busy wait used by every blob-store connection.
20pub const BUSY_TIMEOUT_MS: u64 = 5_000;
21/// SQLite's documented connection default.  The blob store reads this value
22/// after opening instead of overriding it so future SQLite defaults are caught.
23pub const DEFAULT_WAL_AUTOCHECKPOINT_PAGES: i64 = 1_000;
24
25#[derive(Clone, Copy)]
26struct BlobDurabilityState {
27    dirty: bool,
28}
29
30static BLOB_DURABILITY: OnceLock<Mutex<HashMap<PathBuf, BlobDurabilityState>>> = OnceLock::new();
31static BLOB_DURABILITY_BARRIER: Mutex<()> = Mutex::new(());
32
33fn durability_states() -> &'static Mutex<HashMap<PathBuf, BlobDurabilityState>> {
34    BLOB_DURABILITY.get_or_init(|| Mutex::new(HashMap::new()))
35}
36
37fn register_blob_database(path: &Path) {
38    durability_states()
39        .lock()
40        .unwrap_or_else(std::sync::PoisonError::into_inner)
41        .entry(path.to_path_buf())
42        .or_insert(BlobDurabilityState { dirty: true });
43}
44
45fn mark_blob_database_dirty(path: &Path) {
46    durability_states()
47        .lock()
48        .unwrap_or_else(std::sync::PoisonError::into_inner)
49        .entry(path.to_path_buf())
50        .and_modify(|state| state.dirty = true)
51        .or_insert(BlobDurabilityState { dirty: true });
52}
53
54pub(crate) fn blob_database_needs_durability(path: &Path) -> bool {
55    let mut states = durability_states()
56        .lock()
57        .unwrap_or_else(std::sync::PoisonError::into_inner);
58    states
59        .entry(path.to_path_buf())
60        .or_insert(BlobDurabilityState { dirty: true })
61        .dirty
62}
63
64pub(crate) fn mark_blob_database_durable(path: &Path) {
65    durability_states()
66        .lock()
67        .unwrap_or_else(std::sync::PoisonError::into_inner)
68        .entry(path.to_path_buf())
69        .and_modify(|state| state.dirty = false)
70        .or_insert(BlobDurabilityState { dirty: false });
71}
72
73pub(crate) fn publication_durability_barrier() -> MutexGuard<'static, ()> {
74    BLOB_DURABILITY_BARRIER
75        .lock()
76        .unwrap_or_else(std::sync::PoisonError::into_inner)
77}
78
79/// Payload encodings and the key producers that name them are pinned together.
80/// A payload format change must update the corresponding producer string in the
81/// same edit, because producer versions are components of content-address keys.
82pub const SEMANTIC_PAYLOAD_SCHEMA: u32 = 1;
83pub const SEMANTIC_PRODUCER_VERSION: &str = "semantic-v1";
84pub const CALLGRAPH_PAYLOAD_SCHEMA: u32 = 1;
85pub const CALLGRAPH_PRODUCER_VERSION: &str = "callgraph-v1";
86
87const BLOB_SCHEMA: &str = r#"
88CREATE TABLE IF NOT EXISTS blob_payloads (
89    full_key BLOB NOT NULL PRIMARY KEY CHECK(length(full_key) = 32),
90    payload BLOB NOT NULL,
91    payload_digest BLOB NOT NULL CHECK(length(payload_digest) = 32),
92    payload_schema INTEGER NOT NULL,
93    created_at_ms INTEGER NOT NULL DEFAULT 0
94) WITHOUT ROWID;
95CREATE TABLE IF NOT EXISTS blob_quarantine (
96    full_key BLOB NOT NULL PRIMARY KEY CHECK(length(full_key) = 32)
97) WITHOUT ROWID;
98"#;
99
100/// The two repository-family blob planes.  Trigram data is per-view derived
101/// state and deliberately is not represented here.
102#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
103pub enum BlobPlane {
104    Semantic,
105    Callgraph,
106}
107
108impl BlobPlane {
109    pub const fn as_str(self) -> &'static str {
110        match self {
111            Self::Semantic => "semantic",
112            Self::Callgraph => "callgraph",
113        }
114    }
115
116    const fn payload_schema(self) -> u32 {
117        match self {
118            Self::Semantic => SEMANTIC_PAYLOAD_SCHEMA,
119            Self::Callgraph => CALLGRAPH_PAYLOAD_SCHEMA,
120        }
121    }
122}
123
124/// A complete, fixed-size database key.  Its bytes are the BLAKE3 digest of a
125/// length-delimited canonical encoding of the semantic or callgraph key tuple.
126#[derive(Clone, Debug, Eq, PartialEq, Hash)]
127pub struct FullKey {
128    bytes: [u8; 32],
129    plane: BlobPlane,
130}
131
132impl FullKey {
133    /// Returns the bytes stored in SQLite's `full_key` BLOB column.
134    pub fn as_bytes(&self) -> &[u8; 32] {
135        &self.bytes
136    }
137
138    /// The only plane where this key can be stored.
139    pub const fn plane(&self) -> BlobPlane {
140        self.plane
141    }
142
143    /// Stable lower-case hexadecimal form suitable for logs and telemetry.
144    pub fn to_hex(&self) -> String {
145        let mut hex = String::with_capacity(64);
146        for byte in self.bytes {
147            use std::fmt::Write;
148            let _ = write!(hex, "{byte:02x}");
149        }
150        hex
151    }
152}
153
154impl fmt::Display for FullKey {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.write_str(&self.to_hex())
157    }
158}
159
160/// A semantic blob identity.  `rel_path` remains a key component even when the
161/// source bytes are identical, so path-specific embedding text cannot be reused
162/// under a different path by accident.
163#[derive(Clone, Debug, Eq, PartialEq, Hash)]
164pub struct SemanticKey {
165    source_digest: [u8; 32],
166    rel_path: Vec<u8>,
167    chunker_version: String,
168    embed_template_version: String,
169    model_fingerprint: String,
170}
171
172impl SemanticKey {
173    pub fn from_bytes(
174        bytes: &[u8],
175        rel_path: &[u8],
176        chunker_version: impl Into<String>,
177        embed_template_version: impl Into<String>,
178        model_fingerprint: impl Into<String>,
179    ) -> Self {
180        Self {
181            source_digest: *blake3::hash(bytes).as_bytes(),
182            rel_path: rel_path.to_vec(),
183            chunker_version: chunker_version.into(),
184            embed_template_version: embed_template_version.into(),
185            model_fingerprint: model_fingerprint.into(),
186        }
187    }
188
189    /// Builds a key using this release's semantic producer version for both
190    /// producer components.  Callers that independently version the chunker
191    /// and template should use [`Self::from_bytes`] instead.
192    pub fn for_current(
193        bytes: &[u8],
194        rel_path: &[u8],
195        model_fingerprint: impl Into<String>,
196    ) -> Self {
197        Self::from_bytes(
198            bytes,
199            rel_path,
200            SEMANTIC_PRODUCER_VERSION,
201            SEMANTIC_PRODUCER_VERSION,
202            model_fingerprint,
203        )
204    }
205
206    pub fn full_key(&self) -> FullKey {
207        full_key(
208            BlobPlane::Semantic,
209            b"aft/blob-store/semantic/v1",
210            &[
211                &self.source_digest,
212                &self.rel_path,
213                self.chunker_version.as_bytes(),
214                self.embed_template_version.as_bytes(),
215                self.model_fingerprint.as_bytes(),
216            ],
217        )
218    }
219
220    pub fn source_digest(&self) -> &[u8; 32] {
221        &self.source_digest
222    }
223}
224
225/// A callgraph blob identity.  Unlike semantic keys, the relative path is not a
226/// component: equal source bytes with the same language and extractor version
227/// share one parse extraction across all paths in the family.
228#[derive(Clone, Debug, Eq, PartialEq, Hash)]
229pub struct CallgraphKey {
230    source_digest: [u8; 32],
231    language: String,
232    extractor_version: String,
233}
234
235impl CallgraphKey {
236    pub fn from_bytes(
237        bytes: &[u8],
238        language: impl Into<String>,
239        extractor_version: impl Into<String>,
240    ) -> Self {
241        Self {
242            source_digest: *blake3::hash(bytes).as_bytes(),
243            language: language.into(),
244            extractor_version: extractor_version.into(),
245        }
246    }
247
248    /// Uses this release's extractor producer version.  `language = "config"`
249    /// is intentionally accepted because configuration inputs are callgraph
250    /// blobs too.
251    pub fn for_current(bytes: &[u8], language: impl Into<String>) -> Self {
252        Self::from_bytes(bytes, language, CALLGRAPH_PRODUCER_VERSION)
253    }
254
255    pub fn full_key(&self) -> FullKey {
256        full_key(
257            BlobPlane::Callgraph,
258            b"aft/blob-store/callgraph/v1",
259            &[
260                &self.source_digest,
261                self.language.as_bytes(),
262                self.extractor_version.as_bytes(),
263            ],
264        )
265    }
266
267    pub fn source_digest(&self) -> &[u8; 32] {
268        &self.source_digest
269    }
270}
271
272fn full_key(plane: BlobPlane, domain: &[u8], fields: &[&[u8]]) -> FullKey {
273    let mut hasher = blake3::Hasher::new();
274    hasher.update(domain);
275    for field in fields {
276        hasher.update(&(field.len() as u64).to_be_bytes());
277        hasher.update(field);
278    }
279    FullKey {
280        bytes: *hasher.finalize().as_bytes(),
281        plane,
282    }
283}
284
285/// The only public put outcomes.  A durable report is impossible for the last
286/// three outcomes because [`PutReport::durable`] derives from this enum.
287#[derive(Clone, Copy, Debug, Eq, PartialEq)]
288pub enum PutOutcome {
289    Inserted,
290    Reused,
291    Quarantined,
292    Failed,
293    QuotaExceeded,
294}
295
296/// The result of a successful put attempt.
297#[derive(Clone, Copy, Debug, Eq, PartialEq)]
298pub struct PutReport {
299    pub outcome: PutOutcome,
300    pub durable: bool,
301}
302
303impl PutReport {
304    fn new(outcome: PutOutcome) -> Self {
305        Self {
306            durable: matches!(outcome, PutOutcome::Inserted | PutOutcome::Reused),
307            outcome,
308        }
309    }
310}
311
312/// The pragma values read back from the open connection after configuration.
313#[derive(Clone, Debug, Eq, PartialEq)]
314pub struct BlobStorePragmas {
315    pub journal_mode: String,
316    pub synchronous: i64,
317    pub busy_timeout_ms: i64,
318    pub foreign_keys: i64,
319    pub wal_autocheckpoint_pages: i64,
320}
321
322/// Space and row usage for one immutable family/plane store.
323#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
324pub struct BlobUsage {
325    pub rows: u64,
326    pub payload_bytes: u64,
327    pub file_bytes: u64,
328}
329
330/// A breaker implementation is notified exactly once after a corrupt database
331/// has been moved aside and a clean replacement has opened successfully; the
332/// notification records that recovery event.
333pub trait BlobStoreBreaker {
334    fn record_corruption_death(&self, artifact_key: &str, plane: BlobPlane);
335}
336
337#[derive(Debug)]
338pub enum BlobStoreError {
339    Io(std::io::Error),
340    Sqlite(rusqlite::Error),
341    InvalidArtifactKey(String),
342    PragmaMismatch {
343        name: &'static str,
344        expected: String,
345        actual: String,
346    },
347    PlaneKeyMismatch {
348        store_plane: BlobPlane,
349        key_plane: BlobPlane,
350    },
351}
352
353impl fmt::Display for BlobStoreError {
354    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355        match self {
356            Self::Io(error) => write!(f, "blob-store I/O error: {error}"),
357            Self::Sqlite(error) => write!(f, "blob-store SQLite error: {error}"),
358            Self::InvalidArtifactKey(key) => write!(f, "invalid artifact key `{key}`"),
359            Self::PlaneKeyMismatch {
360                store_plane,
361                key_plane,
362            } => write!(
363                f,
364                "a {} key cannot be stored in the {} plane",
365                key_plane.as_str(),
366                store_plane.as_str()
367            ),
368            Self::PragmaMismatch {
369                name,
370                expected,
371                actual,
372            } => write!(
373                f,
374                "blob-store PRAGMA {name} was `{actual}`, expected `{expected}`"
375            ),
376        }
377    }
378}
379
380impl Error for BlobStoreError {
381    fn source(&self) -> Option<&(dyn Error + 'static)> {
382        match self {
383            Self::Io(error) => Some(error),
384            Self::Sqlite(error) => Some(error),
385            Self::InvalidArtifactKey(_)
386            | Self::PragmaMismatch { .. }
387            | Self::PlaneKeyMismatch { .. } => None,
388        }
389    }
390}
391
392impl From<std::io::Error> for BlobStoreError {
393    fn from(error: std::io::Error) -> Self {
394        Self::Io(error)
395    }
396}
397
398impl From<rusqlite::Error> for BlobStoreError {
399    fn from(error: rusqlite::Error) -> Self {
400        Self::Sqlite(error)
401    }
402}
403
404/// One SQLite-backed immutable payload plane for one repository family.
405#[derive(Debug)]
406pub struct BlobStore {
407    artifact_key: String,
408    plane: BlobPlane,
409    path: PathBuf,
410    pragmas: BlobStorePragmas,
411    connection: TrackedConnection,
412}
413
414impl BlobStore {
415    /// Opens `<storage>/blobs/<artifact_key>/<plane>.sqlite` and creates its
416    /// schema under `BEGIN IMMEDIATE`.  Existing corrupt SQLite headers are
417    /// preserved beside the database before a clean empty store is created.
418    pub fn open(
419        storage: &Path,
420        artifact_key: impl Into<String>,
421        plane: BlobPlane,
422    ) -> Result<Self, BlobStoreError> {
423        Self::open_with_optional_breaker(storage, artifact_key.into(), plane, None)
424    }
425
426    /// Like [`Self::open`], but invokes the supplied breaker exactly once after
427    /// corruption recovery has completed successfully.
428    pub fn open_with_breaker(
429        storage: &Path,
430        artifact_key: impl Into<String>,
431        plane: BlobPlane,
432        breaker: &dyn BlobStoreBreaker,
433    ) -> Result<Self, BlobStoreError> {
434        Self::open_with_optional_breaker(storage, artifact_key.into(), plane, Some(breaker))
435    }
436
437    fn open_with_optional_breaker(
438        storage: &Path,
439        artifact_key: String,
440        plane: BlobPlane,
441        breaker: Option<&dyn BlobStoreBreaker>,
442    ) -> Result<Self, BlobStoreError> {
443        validate_artifact_key(&artifact_key)?;
444        let path = storage
445            .join("blobs")
446            .join(&artifact_key)
447            .join(format!("{}.sqlite", plane.as_str()));
448        if let Some(parent) = path.parent() {
449            fs::create_dir_all(parent)?;
450        }
451
452        match Self::open_at(&artifact_key, plane, path.clone()) {
453            Ok(store) => Ok(store),
454            Err(error) if is_corrupt_database_error(&error) && path.exists() => {
455                let corrupt_path = move_corrupt_database_aside(&path)?;
456                log::warn!(
457                    "blob store database at {} was corrupt; moved it to {}",
458                    path.display(),
459                    corrupt_path.display()
460                );
461                let store = Self::open_at(&artifact_key, plane, path)?;
462                if let Some(breaker) = breaker {
463                    breaker.record_corruption_death(&artifact_key, plane);
464                }
465                Ok(store)
466            }
467            Err(error) => Err(error),
468        }
469    }
470
471    fn open_at(
472        artifact_key: &str,
473        plane: BlobPlane,
474        path: PathBuf,
475    ) -> Result<Self, BlobStoreError> {
476        let mut connection = TrackedConnection::open(&path, SqliteStore::BlobStore)?;
477        configure_connection(&connection)?;
478        ensure_schema(&mut connection)?;
479        let pragmas = read_and_assert_pragmas(&connection)?;
480        register_blob_database(&path);
481        Ok(Self {
482            artifact_key: artifact_key.to_owned(),
483            plane,
484            path,
485            pragmas,
486            connection,
487        })
488    }
489
490    pub fn artifact_key(&self) -> &str {
491        &self.artifact_key
492    }
493
494    pub const fn plane(&self) -> BlobPlane {
495        self.plane
496    }
497
498    pub fn path(&self) -> &Path {
499        &self.path
500    }
501
502    pub fn pragmas(&self) -> &BlobStorePragmas {
503        &self.pragmas
504    }
505
506    /// Returns current payload rows plus the SQLite file allocation.  Quota
507    /// admission uses this before an insert so a rejected payload never enters
508    /// the immutable store.
509    pub fn usage(&self) -> Result<BlobUsage, BlobStoreError> {
510        let (rows, payload_bytes): (u64, u64) = self.connection.query_row(
511            "SELECT COUNT(*), COALESCE(SUM(length(payload)), 0) FROM blob_payloads",
512            [],
513            |row| Ok((row.get(0)?, row.get(1)?)),
514        )?;
515        let page_count: u64 = self
516            .connection
517            .pragma_query_value(None, "page_count", |row| row.get(0))?;
518        let page_size: u64 = self
519            .connection
520            .pragma_query_value(None, "page_size", |row| row.get(0))?;
521        Ok(BlobUsage {
522            rows,
523            payload_bytes,
524            file_bytes: page_count.saturating_mul(page_size),
525        })
526    }
527
528    /// Inserts a payload once.  An existing row is never updated, even if its
529    /// bytes fail a later integrity check; callers must use a new producer key.
530    pub fn put(&mut self, full_key: &FullKey, payload: &[u8]) -> Result<PutReport, BlobStoreError> {
531        self.ensure_key_plane(full_key)?;
532        let _durability = publication_durability_barrier();
533        let tx = self
534            .connection
535            .transaction_with_behavior(TransactionBehavior::Immediate)?;
536        let quarantined = tx
537            .query_row(
538                "SELECT 1 FROM blob_quarantine WHERE full_key = ?1",
539                params![full_key.as_bytes().as_slice()],
540                |_| Ok(()),
541            )
542            .optional()?
543            .is_some();
544        if quarantined {
545            tx.commit()?;
546            return Ok(PutReport::new(PutOutcome::Quarantined));
547        }
548
549        let payload_digest = blake3::hash(payload);
550        let inserted = tx.execute(
551            "INSERT INTO blob_payloads
552             (full_key, payload, payload_digest, payload_schema, created_at_ms)
553             VALUES (?1, ?2, ?3, ?4, ?5)
554             ON CONFLICT(full_key) DO NOTHING",
555            params![
556                full_key.as_bytes().as_slice(),
557                payload,
558                payload_digest.as_bytes().as_slice(),
559                i64::from(self.plane.payload_schema()),
560                unix_millis_now(),
561            ],
562        )?;
563        tx.commit()?;
564        if inserted == 1 {
565            mark_blob_database_dirty(&self.path);
566        }
567        Ok(PutReport::new(if inserted == 1 {
568            PutOutcome::Inserted
569        } else {
570            PutOutcome::Reused
571        }))
572    }
573
574    /// Reads a payload only after verifying its stored digest and schema.  A
575    /// malformed row is indistinguishable from a miss to consumers, but remains
576    /// on disk for quarantine/forensics rather than being rewritten in place.
577    pub fn get(&self, full_key: &FullKey) -> Result<Option<Vec<u8>>, BlobStoreError> {
578        self.ensure_key_plane(full_key)?;
579        let row = self
580            .connection
581            .query_row(
582                "SELECT payload, payload_digest, payload_schema
583                 FROM blob_payloads WHERE full_key = ?1",
584                params![full_key.as_bytes().as_slice()],
585                |row| {
586                    Ok((
587                        row.get::<_, Vec<u8>>(0)?,
588                        row.get::<_, Vec<u8>>(1)?,
589                        row.get::<_, i64>(2)?,
590                    ))
591                },
592            )
593            .optional()?;
594        let Some((payload, payload_digest, payload_schema)) = row else {
595            return Ok(None);
596        };
597
598        let digest_matches = payload_digest.as_slice() == blake3::hash(&payload).as_bytes();
599        let schema_matches = payload_schema == i64::from(self.plane.payload_schema());
600        if digest_matches && schema_matches {
601            return Ok(Some(payload));
602        }
603
604        let reason = match (digest_matches, schema_matches) {
605            (false, false) => "payload digest and schema mismatch",
606            (false, true) => "payload digest mismatch",
607            (true, false) => "payload schema mismatch",
608            (true, true) => unreachable!("matching payload was returned above"),
609        };
610        log::warn!(
611            "blob store rejected committed payload for key {} in {}/{}: {}",
612            full_key,
613            self.artifact_key,
614            self.plane.as_str(),
615            reason
616        );
617        Ok(None)
618    }
619
620    /// Records a deterministic failure without modifying the immutable payload
621    /// table.  Subsequent puts report `quarantined` until a changed key is used.
622    pub fn quarantine(&mut self, full_key: &FullKey) -> Result<(), BlobStoreError> {
623        self.ensure_key_plane(full_key)?;
624        let _durability = publication_durability_barrier();
625        let inserted = self.connection.execute(
626            "INSERT INTO blob_quarantine (full_key) VALUES (?1)
627             ON CONFLICT(full_key) DO NOTHING",
628            params![full_key.as_bytes().as_slice()],
629        )?;
630        if inserted == 1 {
631            mark_blob_database_dirty(&self.path);
632        }
633        Ok(())
634    }
635
636    fn ensure_key_plane(&self, full_key: &FullKey) -> Result<(), BlobStoreError> {
637        if full_key.plane() == self.plane {
638            Ok(())
639        } else {
640            Err(BlobStoreError::PlaneKeyMismatch {
641                store_plane: self.plane,
642                key_plane: full_key.plane(),
643            })
644        }
645    }
646}
647
648fn validate_artifact_key(artifact_key: &str) -> Result<(), BlobStoreError> {
649    if artifact_key.is_empty()
650        || artifact_key == "."
651        || artifact_key == ".."
652        || artifact_key.contains(['/', '\\', '\0'])
653    {
654        return Err(BlobStoreError::InvalidArtifactKey(artifact_key.to_owned()));
655    }
656    Ok(())
657}
658
659fn configure_connection(connection: &Connection) -> Result<(), BlobStoreError> {
660    // Set the wait policy before WAL attempts to acquire the journal lock so
661    // concurrent first-open callers wait instead of failing immediately.
662    connection.busy_timeout(Duration::from_millis(BUSY_TIMEOUT_MS))?;
663    connection.pragma_update(None, "foreign_keys", "OFF")?;
664    // Switching a rollback-journal file to WAL needs an exclusive lock, and
665    // SQLite skips the busy handler on that upgrade when another connection
666    // is mid-switch (it would risk a deadlock), so two first-openers racing on
667    // a fresh file see SQLITE_BUSY straight away. Wait it out ourselves within
668    // the same budget the busy handler would have used.
669    retry_while_busy(Duration::from_millis(BUSY_TIMEOUT_MS), || {
670        connection.pragma_update(None, "journal_mode", "WAL")
671    })?;
672    connection.pragma_update(None, "synchronous", "NORMAL")?;
673    Ok(())
674}
675
676/// Run `operation` until it stops failing with SQLITE_BUSY/SQLITE_LOCKED or
677/// `budget` elapses; the last error is returned when the budget runs out.
678/// Shared with the view store, whose pointer database has the same first-open
679/// WAL-switch race.
680pub(crate) fn retry_while_busy<T>(
681    budget: Duration,
682    mut operation: impl FnMut() -> rusqlite::Result<T>,
683) -> rusqlite::Result<T> {
684    let deadline = std::time::Instant::now() + budget;
685    let mut backoff = Duration::from_millis(1);
686    loop {
687        match operation() {
688            Err(rusqlite::Error::SqliteFailure(error, _))
689                if matches!(
690                    error.code,
691                    ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked
692                ) && std::time::Instant::now() < deadline =>
693            {
694                std::thread::sleep(backoff);
695                backoff = (backoff * 2).min(Duration::from_millis(50));
696            }
697            result => return result,
698        }
699    }
700}
701
702fn ensure_schema(connection: &mut Connection) -> Result<(), BlobStoreError> {
703    let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
704    tx.execute_batch(BLOB_SCHEMA)?;
705    let has_created_at = tx
706        .prepare("PRAGMA table_info(blob_payloads)")?
707        .query_map([], |row| row.get::<_, String>(1))?
708        .collect::<Result<Vec<_>, _>>()?
709        .iter()
710        .any(|column| column == "created_at_ms");
711    if !has_created_at {
712        // Existing immutable rows predate per-row timestamps and are eligible for
713        // the normal age-floor policy immediately after this migration.
714        tx.execute(
715            "ALTER TABLE blob_payloads ADD COLUMN created_at_ms INTEGER NOT NULL DEFAULT 0",
716            [],
717        )?;
718    }
719    tx.commit()?;
720    Ok(())
721}
722
723fn unix_millis_now() -> u64 {
724    SystemTime::now()
725        .duration_since(UNIX_EPOCH)
726        .unwrap_or_default()
727        .as_millis() as u64
728}
729
730fn read_and_assert_pragmas(connection: &Connection) -> Result<BlobStorePragmas, BlobStoreError> {
731    let pragmas = BlobStorePragmas {
732        journal_mode: connection.pragma_query_value(None, "journal_mode", |row| row.get(0))?,
733        synchronous: connection.pragma_query_value(None, "synchronous", |row| row.get(0))?,
734        busy_timeout_ms: connection.pragma_query_value(None, "busy_timeout", |row| row.get(0))?,
735        foreign_keys: connection.pragma_query_value(None, "foreign_keys", |row| row.get(0))?,
736        // Do not write this pragma: preserving SQLite's default is part of the
737        // storage contract and querying it catches accidental future overrides.
738        wal_autocheckpoint_pages: connection.pragma_query_value(
739            None,
740            "wal_autocheckpoint",
741            |row| row.get(0),
742        )?,
743    };
744    assert_pragma("journal_mode", "wal", &pragmas.journal_mode)?;
745    assert_pragma("synchronous", "1", &pragmas.synchronous.to_string())?;
746    assert_pragma(
747        "busy_timeout",
748        &BUSY_TIMEOUT_MS.to_string(),
749        &pragmas.busy_timeout_ms.to_string(),
750    )?;
751    assert_pragma("foreign_keys", "0", &pragmas.foreign_keys.to_string())?;
752    assert_pragma(
753        "wal_autocheckpoint",
754        &DEFAULT_WAL_AUTOCHECKPOINT_PAGES.to_string(),
755        &pragmas.wal_autocheckpoint_pages.to_string(),
756    )?;
757    Ok(pragmas)
758}
759
760fn assert_pragma(name: &'static str, expected: &str, actual: &str) -> Result<(), BlobStoreError> {
761    if actual.eq_ignore_ascii_case(expected) {
762        Ok(())
763    } else {
764        Err(BlobStoreError::PragmaMismatch {
765            name,
766            expected: expected.to_owned(),
767            actual: actual.to_owned(),
768        })
769    }
770}
771
772fn is_corrupt_database_error(error: &BlobStoreError) -> bool {
773    matches!(
774        error,
775        BlobStoreError::Sqlite(rusqlite::Error::SqliteFailure(sqlite_error, _))
776            if matches!(sqlite_error.code, ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase)
777    )
778}
779
780fn move_corrupt_database_aside(path: &Path) -> Result<PathBuf, BlobStoreError> {
781    let timestamp = SystemTime::now()
782        .duration_since(UNIX_EPOCH)
783        .unwrap_or_default()
784        .as_secs();
785    let file_name = path
786        .file_name()
787        .and_then(|name| name.to_str())
788        .ok_or_else(|| BlobStoreError::InvalidArtifactKey(path.display().to_string()))?;
789    let destination = path.with_file_name(format!("{file_name}.corrupt-{timestamp}"));
790    fs::rename(path, &destination)?;
791    for suffix in ["-wal", "-shm"] {
792        let sidecar = PathBuf::from(format!("{}{suffix}", path.display()));
793        if sidecar.exists() {
794            fs::rename(
795                &sidecar,
796                PathBuf::from(format!("{}{suffix}", destination.display())),
797            )?;
798        }
799    }
800    Ok(destination)
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806
807    /// Eight racing first-openers on a fresh file trip the WAL-switch BUSY that
808    /// the busy handler does not cover. Without the retry this sees 1-3
809    /// failures per 60 rounds on an idle laptop; 150 rounds make the red
810    /// reliable while keeping the test under two seconds.
811    #[test]
812    fn concurrent_first_opens_never_see_busy() {
813        let mut failures = Vec::new();
814        for round in 0..150 {
815            let dir = tempfile::tempdir().expect("tempdir");
816            let handles: Vec<_> = (0..8)
817                .map(|_| {
818                    let storage = dir.path().to_path_buf();
819                    std::thread::spawn(move || {
820                        BlobStore::open(&storage, "fam", BlobPlane::Semantic).map(|_| ())
821                    })
822                })
823                .collect();
824            for handle in handles {
825                if let Err(error) = handle.join().expect("opener thread") {
826                    failures.push(format!("round {round}: {error}"));
827                }
828            }
829        }
830        assert!(
831            failures.is_empty(),
832            "concurrent first-open must wait out the WAL switch: {failures:?}"
833        );
834    }
835
836    #[test]
837    fn payload_schema_and_producer_version_pairs_are_pinned() {
838        assert_eq!(
839            [
840                (SEMANTIC_PAYLOAD_SCHEMA, SEMANTIC_PRODUCER_VERSION),
841                (CALLGRAPH_PAYLOAD_SCHEMA, CALLGRAPH_PRODUCER_VERSION),
842            ],
843            [(1, "semantic-v1"), (1, "callgraph-v1")],
844            "a payload encoding change must bump its producer key version in the same edit"
845        );
846    }
847
848    #[test]
849    fn semantic_paths_are_distinct_while_callgraph_content_reuses() {
850        let bytes = b"same source";
851        let semantic_a = SemanticKey::for_current(bytes, b"src/a.rs", "model-a").full_key();
852        let semantic_b = SemanticKey::for_current(bytes, b"src/b.rs", "model-a").full_key();
853        let callgraph_a = CallgraphKey::for_current(bytes, "rust").full_key();
854        let callgraph_b = CallgraphKey::for_current(bytes, "rust").full_key();
855
856        assert_ne!(semantic_a, semantic_b);
857        assert_eq!(callgraph_a, callgraph_b);
858    }
859
860    #[test]
861    fn config_is_a_valid_callgraph_language() {
862        let key = CallgraphKey::for_current(b"[package]", "config");
863        assert_ne!(key.source_digest(), &[0; 32]);
864    }
865
866    #[test]
867    fn abandoned_insert_transaction_leaves_no_partial_payload_row() {
868        let directory = tempfile::tempdir().expect("create temporary storage");
869        let mut store = BlobStore::open(directory.path(), "family-a", BlobPlane::Semantic)
870            .expect("open blob store");
871        let key = SemanticKey::for_current(b"source", b"src/lib.rs", "model-a").full_key();
872        let payload = b"payload";
873        let payload_digest = blake3::hash(payload);
874
875        {
876            let tx = store
877                .connection
878                .transaction_with_behavior(TransactionBehavior::Immediate)
879                .expect("start payload transaction");
880            tx.execute(
881                "INSERT INTO blob_payloads (full_key, payload, payload_digest, payload_schema)
882                 VALUES (?1, ?2, ?3, ?4)",
883                params![
884                    key.as_bytes().as_slice(),
885                    payload,
886                    payload_digest.as_bytes().as_slice(),
887                    i64::from(SEMANTIC_PAYLOAD_SCHEMA),
888                ],
889            )
890            .expect("stage payload row");
891            // Dropping an uncommitted SQLite transaction models a process that
892            // dies after staging a row but before the put transaction commits.
893        }
894
895        assert_eq!(store.get(&key).expect("read after aborted put"), None);
896    }
897
898    #[test]
899    fn usage_counts_rows_payloads_and_sqlite_pages() {
900        let directory = tempfile::tempdir().expect("create temporary storage");
901        let mut store = BlobStore::open(directory.path(), "family-a", BlobPlane::Semantic)
902            .expect("open blob store");
903        let key = SemanticKey::for_current(b"source", b"src/lib.rs", "model-a").full_key();
904        store.put(&key, b"payload").expect("insert payload");
905
906        let usage = store.usage().expect("read usage");
907        assert_eq!(usage.rows, 1);
908        assert_eq!(usage.payload_bytes, 7);
909        assert!(usage.file_bytes >= usage.payload_bytes);
910    }
911
912    #[test]
913    fn only_inserted_and_reused_are_durable() {
914        for outcome in [
915            PutOutcome::Inserted,
916            PutOutcome::Reused,
917            PutOutcome::Quarantined,
918            PutOutcome::Failed,
919            PutOutcome::QuotaExceeded,
920        ] {
921            assert_eq!(
922                PutReport::new(outcome).durable,
923                matches!(outcome, PutOutcome::Inserted | PutOutcome::Reused)
924            );
925        }
926    }
927}