243 lines
8.1 KiB
Rust
243 lines
8.1 KiB
Rust
|
|
//! Rejected diagnostic harness for the delta loose-chunk write path.
|
|||
|
|
//!
|
|||
|
|
//! It models an idempotent remote object store whose PUT overwrites the same
|
|||
|
|
//! key (the behaviour of the current S3/Azure adapters) and counts physical
|
|||
|
|
//! PUT calls/bytes. The measured prefilter candidate was rolled back because
|
|||
|
|
//! it regressed the normal negotiated-miss path and weakened self-healing.
|
|||
|
|
|
|||
|
|
use bytes::Bytes;
|
|||
|
|
use futures::stream;
|
|||
|
|
use oxicloud::application::ports::blob_storage_ports::{
|
|||
|
|
BlobStorageBackend, BlobStream, StorageHealthStatus,
|
|||
|
|
};
|
|||
|
|
use oxicloud::domain::errors::DomainError;
|
|||
|
|
use oxicloud::infrastructure::services::dedup_service::DedupService;
|
|||
|
|
use sqlx::postgres::PgPoolOptions;
|
|||
|
|
use std::collections::HashMap;
|
|||
|
|
use std::future::Future;
|
|||
|
|
use std::path::{Path, PathBuf};
|
|||
|
|
use std::pin::Pin;
|
|||
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|||
|
|
use std::sync::{Arc, Mutex};
|
|||
|
|
use std::time::Instant;
|
|||
|
|
use uuid::Uuid;
|
|||
|
|
|
|||
|
|
type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
|||
|
|
|
|||
|
|
#[derive(Default)]
|
|||
|
|
struct CountingRemote {
|
|||
|
|
enable_prefilter: bool,
|
|||
|
|
objects: Mutex<HashMap<String, Bytes>>,
|
|||
|
|
puts: AtomicU64,
|
|||
|
|
put_bytes: AtomicU64,
|
|||
|
|
exists_calls: AtomicU64,
|
|||
|
|
sync_calls: AtomicU64,
|
|||
|
|
sync_hashes: AtomicU64,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl CountingRemote {
|
|||
|
|
fn new(enable_prefilter: bool) -> Self {
|
|||
|
|
Self {
|
|||
|
|
enable_prefilter,
|
|||
|
|
..Self::default()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn reset(&self) {
|
|||
|
|
self.puts.store(0, Ordering::Relaxed);
|
|||
|
|
self.put_bytes.store(0, Ordering::Relaxed);
|
|||
|
|
self.exists_calls.store(0, Ordering::Relaxed);
|
|||
|
|
self.sync_calls.store(0, Ordering::Relaxed);
|
|||
|
|
self.sync_hashes.store(0, Ordering::Relaxed);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl BlobStorageBackend for CountingRemote {
|
|||
|
|
fn initialize(&self) -> BoxFut<'_, Result<(), DomainError>> {
|
|||
|
|
Box::pin(async { Ok(()) })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn put_blob(&self, _hash: &str, _source_path: &Path) -> BoxFut<'_, Result<u64, DomainError>> {
|
|||
|
|
Box::pin(async {
|
|||
|
|
Err(DomainError::internal_error(
|
|||
|
|
"probe",
|
|||
|
|
"put_blob is outside this probe",
|
|||
|
|
))
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn put_blob_from_bytes(&self, hash: &str, data: Bytes) -> BoxFut<'_, Result<u64, DomainError>> {
|
|||
|
|
self.puts.fetch_add(1, Ordering::Relaxed);
|
|||
|
|
self.put_bytes
|
|||
|
|
.fetch_add(data.len() as u64, Ordering::Relaxed);
|
|||
|
|
self.objects
|
|||
|
|
.lock()
|
|||
|
|
.unwrap()
|
|||
|
|
.insert(hash.to_string(), data.clone());
|
|||
|
|
Box::pin(async move { Ok(data.len() as u64) })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn put_blob_from_bytes_unsynced(
|
|||
|
|
&self,
|
|||
|
|
hash: &str,
|
|||
|
|
data: Bytes,
|
|||
|
|
) -> BoxFut<'_, Result<u64, DomainError>> {
|
|||
|
|
self.put_blob_from_bytes(hash, data)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn sync_blobs(&self, hashes: &[String]) -> BoxFut<'_, Result<(), DomainError>> {
|
|||
|
|
self.sync_calls.fetch_add(1, Ordering::Relaxed);
|
|||
|
|
self.sync_hashes
|
|||
|
|
.fetch_add(hashes.len() as u64, Ordering::Relaxed);
|
|||
|
|
Box::pin(async { Ok(()) })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn get_blob_stream(&self, hash: &str) -> BoxFut<'_, Result<BlobStream, DomainError>> {
|
|||
|
|
let data = self.objects.lock().unwrap().get(hash).cloned();
|
|||
|
|
Box::pin(async move {
|
|||
|
|
let data = data.ok_or_else(|| DomainError::not_found("probe blob", "missing"))?;
|
|||
|
|
Ok(Box::pin(stream::once(async move { Ok(data) })) as BlobStream)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn get_blob_range_stream(
|
|||
|
|
&self,
|
|||
|
|
hash: &str,
|
|||
|
|
_start: u64,
|
|||
|
|
_end: Option<u64>,
|
|||
|
|
) -> BoxFut<'_, Result<BlobStream, DomainError>> {
|
|||
|
|
self.get_blob_stream(hash)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn delete_blob(&self, hash: &str) -> BoxFut<'_, Result<(), DomainError>> {
|
|||
|
|
self.objects.lock().unwrap().remove(hash);
|
|||
|
|
Box::pin(async { Ok(()) })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn blob_exists(&self, hash: &str) -> BoxFut<'_, Result<bool, DomainError>> {
|
|||
|
|
self.exists_calls.fetch_add(1, Ordering::Relaxed);
|
|||
|
|
let present = self.objects.lock().unwrap().contains_key(hash);
|
|||
|
|
Box::pin(async move { Ok(present) })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn blob_size(&self, hash: &str) -> BoxFut<'_, Result<u64, DomainError>> {
|
|||
|
|
let size = self
|
|||
|
|
.objects
|
|||
|
|
.lock()
|
|||
|
|
.unwrap()
|
|||
|
|
.get(hash)
|
|||
|
|
.map_or(0, |data| data.len() as u64);
|
|||
|
|
Box::pin(async move { Ok(size) })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn health_check(&self) -> BoxFut<'_, Result<StorageHealthStatus, DomainError>> {
|
|||
|
|
Box::pin(async {
|
|||
|
|
Ok(StorageHealthStatus {
|
|||
|
|
connected: true,
|
|||
|
|
backend_type: "counting-remote".into(),
|
|||
|
|
message: "probe".into(),
|
|||
|
|
available_bytes: None,
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn backend_type(&self) -> &'static str {
|
|||
|
|
if self.enable_prefilter {
|
|||
|
|
"counting-remote"
|
|||
|
|
} else {
|
|||
|
|
// Selects the production raw-local fast path while retaining the
|
|||
|
|
// same remote-style physical PUT counter for an in-binary A/B.
|
|||
|
|
"local"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn payloads(seed: u128, count: usize, size: usize) -> Vec<Bytes> {
|
|||
|
|
(0..count)
|
|||
|
|
.map(|index| {
|
|||
|
|
let mut data = vec![0_u8; size];
|
|||
|
|
data[..16].copy_from_slice(&seed.to_le_bytes());
|
|||
|
|
data[16..24].copy_from_slice(&(index as u64).to_le_bytes());
|
|||
|
|
Bytes::from(data)
|
|||
|
|
})
|
|||
|
|
.collect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async fn run_case(
|
|||
|
|
name: &str,
|
|||
|
|
service: &DedupService,
|
|||
|
|
backend: &CountingRemote,
|
|||
|
|
pool: &sqlx::PgPool,
|
|||
|
|
frames: &[Bytes],
|
|||
|
|
) -> Vec<String> {
|
|||
|
|
let hashes: Vec<String> = frames
|
|||
|
|
.iter()
|
|||
|
|
.map(|frame| blake3::hash(frame).to_hex().to_string())
|
|||
|
|
.collect();
|
|||
|
|
let existing_before: i64 =
|
|||
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM storage.blobs WHERE hash = ANY($1::text[])")
|
|||
|
|
.bind(&hashes)
|
|||
|
|
.fetch_one(pool)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
backend.reset();
|
|||
|
|
let input = stream::iter(frames.iter().cloned().map(Ok::<_, DomainError>));
|
|||
|
|
let started = Instant::now();
|
|||
|
|
let received = service.store_loose_chunks(input).await.unwrap();
|
|||
|
|
let elapsed = started.elapsed();
|
|||
|
|
println!(
|
|||
|
|
"{name}: frames={} existing_before={} logical_bytes={} heads={} puts={} physical_put_bytes={} sync_calls={} sync_hashes={} elapsed_ms={:.3}",
|
|||
|
|
frames.len(),
|
|||
|
|
existing_before,
|
|||
|
|
frames.iter().map(Bytes::len).sum::<usize>(),
|
|||
|
|
backend.exists_calls.load(Ordering::Relaxed),
|
|||
|
|
backend.puts.load(Ordering::Relaxed),
|
|||
|
|
backend.put_bytes.load(Ordering::Relaxed),
|
|||
|
|
backend.sync_calls.load(Ordering::Relaxed),
|
|||
|
|
backend.sync_hashes.load(Ordering::Relaxed),
|
|||
|
|
elapsed.as_secs_f64() * 1000.0,
|
|||
|
|
);
|
|||
|
|
received.into_iter().map(|(hash, _)| hash).collect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[tokio::main]
|
|||
|
|
async fn main() {
|
|||
|
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL is required");
|
|||
|
|
let pool = Arc::new(
|
|||
|
|
PgPoolOptions::new()
|
|||
|
|
.max_connections(2)
|
|||
|
|
.connect(&database_url)
|
|||
|
|
.await
|
|||
|
|
.unwrap(),
|
|||
|
|
);
|
|||
|
|
let enable_prefilter = std::env::var("PROBE_PREFILTER").map_or(true, |v| v != "0");
|
|||
|
|
println!("prefilter={enable_prefilter}");
|
|||
|
|
let backend = Arc::new(CountingRemote::new(enable_prefilter));
|
|||
|
|
let service = DedupService::new(backend.clone(), pool.clone(), pool.clone());
|
|||
|
|
|
|||
|
|
// 400 × 256 KiB = exactly 100 MiB, the default per-request byte budget.
|
|||
|
|
let seed = Uuid::new_v4().as_u128();
|
|||
|
|
let known = payloads(seed, 400, 256 * 1024);
|
|||
|
|
let fresh = payloads(seed.wrapping_add(1), 400, 256 * 1024);
|
|||
|
|
let half_fresh = payloads(seed.wrapping_add(2), 200, 256 * 1024);
|
|||
|
|
|
|||
|
|
let mut cleanup = run_case("seed", &service, &backend, pool.as_ref(), &known).await;
|
|||
|
|
cleanup.extend(run_case("all_hit", &service, &backend, pool.as_ref(), &known).await);
|
|||
|
|
cleanup.extend(run_case("all_miss", &service, &backend, pool.as_ref(), &fresh).await);
|
|||
|
|
|
|||
|
|
let mixed: Vec<Bytes> = known[..200].iter().chain(&half_fresh).cloned().collect();
|
|||
|
|
cleanup.extend(run_case("half_hit", &service, &backend, pool.as_ref(), &mixed).await);
|
|||
|
|
|
|||
|
|
cleanup.sort_unstable();
|
|||
|
|
cleanup.dedup();
|
|||
|
|
sqlx::query("DELETE FROM storage.blobs WHERE hash = ANY($1::text[])")
|
|||
|
|
.bind(&cleanup)
|
|||
|
|
.execute(pool.as_ref())
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
}
|