1use 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
19pub const BUSY_TIMEOUT_MS: u64 = 5_000;
21pub 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
79pub 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#[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#[derive(Clone, Debug, Eq, PartialEq, Hash)]
127pub struct FullKey {
128 bytes: [u8; 32],
129 plane: BlobPlane,
130}
131
132impl FullKey {
133 pub fn as_bytes(&self) -> &[u8; 32] {
135 &self.bytes
136 }
137
138 pub const fn plane(&self) -> BlobPlane {
140 self.plane
141 }
142
143 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#[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 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#[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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
288pub enum PutOutcome {
289 Inserted,
290 Reused,
291 Quarantined,
292 Failed,
293 QuotaExceeded,
294}
295
296#[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#[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#[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
330pub 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#[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 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 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 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 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 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 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 connection.busy_timeout(Duration::from_millis(BUSY_TIMEOUT_MS))?;
663 connection.pragma_update(None, "foreign_keys", "OFF")?;
664 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
676pub(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 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 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 #[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 }
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}