//! Rejected diagnostic A/B for identical-content overwrite reference accounting. //! //! The caller supplies a disposable PostgreSQL database containing the minimal //! storage schema used below. This exercises the public FileWritePort method, //! not a copy of `swap_blob_hash`. use moka::sync::Cache; use oxicloud::application::ports::blob_lifecycle::BlobLifecycleHook; use oxicloud::application::ports::blob_storage_ports::BlobStorageBackend; use oxicloud::application::ports::storage_ports::FileWritePort; use oxicloud::application::services::blob_lifecycle_service::BlobLifecycleService; use oxicloud::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository; use oxicloud::infrastructure::services::dedup_service::DedupService; use oxicloud::infrastructure::services::local_blob_backend::LocalBlobBackend; use sqlx::postgres::PgPoolOptions; use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Instant; use uuid::Uuid; #[derive(Default)] struct RecordingBlobHook { deleted: Mutex>, } impl RecordingBlobHook { fn deleted(&self) -> Vec { self.deleted.lock().unwrap().clone() } fn clear(&self) { self.deleted.lock().unwrap().clear(); } } impl BlobLifecycleHook for RecordingBlobHook { fn on_blob_created(&self, _blob_hash: &str, _content_type: Option<&str>) {} fn on_blob_deleted(&self, blob_hash: &str) { self.deleted.lock().unwrap().push(blob_hash.to_string()); } } async fn reset(pool: &sqlx::PgPool) { sqlx::query("TRUNCATE storage.files, storage.chunk_manifests, storage.blobs") .execute(pool) .await .unwrap(); } async fn ref_count(pool: &sqlx::PgPool, hash: &str) -> Option { sqlx::query_scalar("SELECT ref_count FROM storage.blobs WHERE hash = $1") .bind(hash) .fetch_optional(pool) .await .unwrap() } async fn manifest_ref_count(pool: &sqlx::PgPool, hash: &str) -> Option { sqlx::query_scalar("SELECT ref_count FROM storage.chunk_manifests WHERE file_hash = $1") .bind(hash) .fetch_optional(pool) .await .unwrap() } fn percentile_ms(samples_ns: &mut [u128], percentile: usize) -> f64 { samples_ns.sort_unstable(); let index = (samples_ns.len() - 1) * percentile / 100; samples_ns[index] as f64 / 1_000_000.0 } #[tokio::main] async fn main() { let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL is required"); let iterations = std::env::var("ITERATIONS") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(1_000); let different_iterations = std::env::var("DIFFERENT_ITERATIONS") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(100); let pool = Arc::new( PgPoolOptions::new() .max_connections(4) .connect(&database_url) .await .unwrap(), ); let temp = tempfile::tempdir().unwrap(); let backend = Arc::new(LocalBlobBackend::new(Path::new(temp.path()))); backend.initialize().await.unwrap(); let recording_hook = Arc::new(RecordingBlobHook::default()); let lifecycle = Arc::new( BlobLifecycleService::new().with_hook(recording_hook.clone() as Arc), ); let dedup = Arc::new( DedupService::new(backend, pool.clone(), pool.clone()).with_blob_lifecycle(lifecycle), ); let repo = FileBlobWriteRepository::new( pool.clone(), dedup.clone(), Cache::builder().max_capacity(16).build(), ); let caller = Uuid::new_v4(); let file_id = Uuid::new_v4(); let hash_a = blake3::hash(b"same-content").to_hex().to_string(); reset(pool.as_ref()).await; sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 12, 1)") .bind(&hash_a) .execute(pool.as_ref()) .await .unwrap(); sqlx::query( "INSERT INTO storage.files (id, blob_hash, size, updated_by) VALUES ($1, $2, 12, $3)", ) .bind(file_id) .bind(&hash_a) .bind(caller) .execute(pool.as_ref()) .await .unwrap(); let started = Instant::now(); let mut identical_samples = Vec::with_capacity(iterations); for _ in 0..iterations { let iteration_started = Instant::now(); // Models the reference acquired by the ingest layer immediately before // FileWritePort consumes it. dedup.add_reference(&hash_a).await.unwrap(); repo.update_file_content_with_blob(&file_id.to_string(), &hash_a, 12, None, caller) .await .unwrap(); identical_samples.push(iteration_started.elapsed().as_nanos()); } let identical_ref = ref_count(pool.as_ref(), &hash_a).await; let identical_p50 = percentile_ms(&mut identical_samples, 50); let identical_p95 = percentile_ms(&mut identical_samples, 95); println!( "identical: iterations={iterations} final_ref_count={:?} elapsed_ms={:.3} \ p50_ms={identical_p50:.3} p95_ms={identical_p95:.3} roundtrips_per_iteration=3", identical_ref, started.elapsed().as_secs_f64() * 1_000.0 ); assert_eq!(identical_ref, Some(1)); assert!(recording_hook.deleted().is_empty()); // Same-hash CDC manifest control. A single-chunk file deliberately has a // row in both tables under the same hash: only the manifest reference is // file-level and the chunk row must remain unchanged. reset(pool.as_ref()).await; recording_hook.clear(); let manifest_iterations = iterations.min(100); sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 12, 1)") .bind(&hash_a) .execute(pool.as_ref()) .await .unwrap(); sqlx::query( "INSERT INTO storage.chunk_manifests (file_hash, chunk_hashes, chunk_sizes, total_size, chunk_count, ref_count) VALUES ($1, ARRAY[$1], ARRAY[12::bigint], 12, 1, 1)", ) .bind(&hash_a) .execute(pool.as_ref()) .await .unwrap(); sqlx::query( "INSERT INTO storage.files (id, blob_hash, size, updated_by) VALUES ($1, $2, 12, $3)", ) .bind(file_id) .bind(&hash_a) .bind(caller) .execute(pool.as_ref()) .await .unwrap(); let started = Instant::now(); let mut manifest_samples = Vec::with_capacity(manifest_iterations); for _ in 0..manifest_iterations { let iteration_started = Instant::now(); dedup.add_reference(&hash_a).await.unwrap(); repo.update_file_content_with_blob(&file_id.to_string(), &hash_a, 12, None, caller) .await .unwrap(); manifest_samples.push(iteration_started.elapsed().as_nanos()); } let manifest_ref = manifest_ref_count(pool.as_ref(), &hash_a).await; let chunk_ref = ref_count(pool.as_ref(), &hash_a).await; let manifest_p50 = percentile_ms(&mut manifest_samples, 50); let manifest_p95 = percentile_ms(&mut manifest_samples, 95); println!( "identical_manifest: iterations={manifest_iterations} manifest_ref={manifest_ref:?} \ chunk_ref={chunk_ref:?} elapsed_ms={:.3} p50_ms={manifest_p50:.3} \ p95_ms={manifest_p95:.3} roundtrips_per_iteration=2", started.elapsed().as_secs_f64() * 1_000.0 ); assert_eq!(manifest_ref, Some(1)); assert_eq!(chunk_ref, Some(1)); assert!(recording_hook.deleted().is_empty()); // Alternating-content latency control. A permanent base reference keeps // both blobs alive, isolating the normal different-hash decrement path. reset(pool.as_ref()).await; recording_hook.clear(); let hash_b = blake3::hash(b"different-content").to_hex().to_string(); sqlx::query( "INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 12, 2), ($2, 17, 1)", ) .bind(&hash_a) .bind(&hash_b) .execute(pool.as_ref()) .await .unwrap(); sqlx::query( "INSERT INTO storage.files (id, blob_hash, size, updated_by) VALUES ($1, $2, 12, $3)", ) .bind(file_id) .bind(&hash_a) .bind(caller) .execute(pool.as_ref()) .await .unwrap(); let started = Instant::now(); let mut alternating_samples = Vec::with_capacity(different_iterations); for i in 0..different_iterations { let iteration_started = Instant::now(); let (target, size) = if i % 2 == 0 { (&hash_b, 17) } else { (&hash_a, 12) }; dedup.add_reference(target).await.unwrap(); repo.update_file_content_with_blob(&file_id.to_string(), target, size, None, caller) .await .unwrap(); alternating_samples.push(iteration_started.elapsed().as_nanos()); } let alternating_a_ref = ref_count(pool.as_ref(), &hash_a).await; let alternating_b_ref = ref_count(pool.as_ref(), &hash_b).await; let expected_a = if different_iterations % 2 == 0 { 2 } else { 1 }; let expected_b = if different_iterations % 2 == 0 { 1 } else { 2 }; let alternating_p50 = percentile_ms(&mut alternating_samples, 50); let alternating_p95 = percentile_ms(&mut alternating_samples, 95); println!( "alternating: iterations={different_iterations} a_ref={alternating_a_ref:?} \ b_ref={alternating_b_ref:?} elapsed_ms={:.3} p50_ms={alternating_p50:.3} \ p95_ms={alternating_p95:.3} roundtrips_per_iteration=8", started.elapsed().as_secs_f64() * 1_000.0 ); assert_eq!(alternating_a_ref, Some(expected_a)); assert_eq!(alternating_b_ref, Some(expected_b)); assert!(recording_hook.deleted().is_empty()); // Different-content control: the old reference must disappear, the new // reference must remain exactly once. reset(pool.as_ref()).await; recording_hook.clear(); sqlx::query( "INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 12, 1), ($2, 17, 1)", ) .bind(&hash_a) .bind(&hash_b) .execute(pool.as_ref()) .await .unwrap(); sqlx::query( "INSERT INTO storage.files (id, blob_hash, size, updated_by) VALUES ($1, $2, 12, $3)", ) .bind(file_id) .bind(&hash_a) .bind(caller) .execute(pool.as_ref()) .await .unwrap(); repo.update_file_content_with_blob(&file_id.to_string(), &hash_b, 17, None, caller) .await .unwrap(); let old_ref = ref_count(pool.as_ref(), &hash_a).await; let new_ref = ref_count(pool.as_ref(), &hash_b).await; println!("different: old_ref={:?} new_ref={:?}", old_ref, new_ref); assert_eq!(old_ref, None); assert_eq!(new_ref, Some(1)); assert_eq!(recording_hook.deleted(), vec![hash_a.clone()]); repo.delete_file(&file_id.to_string()).await.unwrap(); let (deleted, _) = dedup.garbage_collect_force().await.unwrap(); let final_new_ref = ref_count(pool.as_ref(), &hash_b).await; println!( "delete_gc: deleted={deleted} final_new_ref={:?}", final_new_ref ); assert_eq!(deleted, 1); assert_eq!(final_new_ref, None); assert_eq!( recording_hook.deleted(), vec![hash_a.clone(), hash_b.clone()] ); // Missing-file compensation consumes the incoming reference and fires the // deletion hook when it was the only one. reset(pool.as_ref()).await; recording_hook.clear(); let missing_hash = blake3::hash(b"missing-target").to_hex().to_string(); sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 14, 1)") .bind(&missing_hash) .execute(pool.as_ref()) .await .unwrap(); assert!( repo.update_file_content_with_blob( &Uuid::new_v4().to_string(), &missing_hash, 14, None, caller, ) .await .is_err() ); assert_eq!(ref_count(pool.as_ref(), &missing_hash).await, None); assert_eq!(recording_hook.deleted(), vec![missing_hash.clone()]); println!("missing_compensation: ref=None hook=1"); // SQL-error compensation (invalid UUID cast) follows the distinct Err // branch and must likewise consume the incoming reference exactly once. reset(pool.as_ref()).await; recording_hook.clear(); let error_hash = blake3::hash(b"sql-error").to_hex().to_string(); sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 9, 1)") .bind(&error_hash) .execute(pool.as_ref()) .await .unwrap(); assert!( repo.update_file_content_with_blob("not-a-uuid", &error_hash, 9, None, caller) .await .is_err() ); assert_eq!(ref_count(pool.as_ref(), &error_hash).await, None); assert_eq!(recording_hook.deleted(), vec![error_hash]); println!("error_compensation: ref=None hook=1"); reset(pool.as_ref()).await; }