//! Azure download-path benchmark — whole-blob buffering vs streaming (ROUND4). //! //! The old `AzureBlobBackend::get_blob_stream` / `get_blob_range_stream` //! drained the ENTIRE blob (or range) into one `Vec` before yielding //! a single mega-chunk: whole-blob RAM residency per reader, TTFB = full //! download time, and with `read_prefetch() = 8` the CDC reassembly path //! could hold 8 entire chunk-blobs at once. AFTER forwards the SDK's //! page/body streams directly (first page still awaited eagerly so a //! missing blob is an up-front NotFound). //! //! Technique: a local axum stub speaks just enough of the Azure Blob GET //! REST surface (ranged 16 MiB pages, `x-ms-*` headers) for the REAL //! `azure_storage_blobs` client — the backend points at it via the new //! `endpoint_url` override (also the Azurite hook). The stub synthesizes //! blob bytes deterministically per offset, so it holds no buffer and //! the peak-live-heap metric isolates the CLIENT path. BEFORE is the old //! collect-everything logic copied verbatim; AFTER is the real //! `AzureBlobBackend`. BLAKE3 gates assert byte-identical payloads. //! //! Run (no Postgres needed): //! cargo run --release --features bench --example bench_azure_stream //! Tunables (env): BENCH_MB (256) blob size, BENCH_TAIL_MB (128) range tail. use std::alloc::{GlobalAlloc, Layout, System}; use std::env; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; use axum::body::Body; use axum::http::{HeaderMap, Request, Response, StatusCode}; use bytes::Bytes; use futures::StreamExt; use oxicloud::application::ports::blob_storage_ports::BlobStorageBackend; use oxicloud::common::config::AzureStorageConfig; use oxicloud::infrastructure::services::azure_blob_backend::AzureBlobBackend; use tokio::net::TcpListener; // ─── Peak-live-heap tracking allocator ────────────────────────────────────── static LIVE: AtomicU64 = AtomicU64::new(0); static PEAK: AtomicU64 = AtomicU64::new(0); struct PeakAlloc; fn bump(sz: u64) { let live = LIVE.fetch_add(sz, Ordering::Relaxed) + sz; PEAK.fetch_max(live, Ordering::Relaxed); } unsafe impl GlobalAlloc for PeakAlloc { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { bump(layout.size() as u64); unsafe { System.alloc(layout) } } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { LIVE.fetch_sub(layout.size() as u64, Ordering::Relaxed); unsafe { System.dealloc(ptr, layout) } } unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { if new_size > layout.size() { bump((new_size - layout.size()) as u64); } else { LIVE.fetch_sub((layout.size() - new_size) as u64, Ordering::Relaxed); } unsafe { System.realloc(ptr, layout, new_size) } } unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { bump(layout.size() as u64); unsafe { System.alloc_zeroed(layout) } } } #[global_allocator] static GLOBAL: PeakAlloc = PeakAlloc; // ─── Deterministic blob content (no stored buffer) ────────────────────────── fn splitmix64(mut z: u64) -> u64 { z = z.wrapping_add(0x9E3779B97F4A7C15); z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); z ^ (z >> 31) } /// Fill `out` with the blob bytes at absolute offset `offset`. fn fill_at(out: &mut [u8], offset: u64) { let mut i = 0usize; while i < out.len() { let abs = offset + i as u64; let block = abs / 8; let word = splitmix64(block).to_le_bytes(); let start_in_word = (abs % 8) as usize; let take = (8 - start_in_word).min(out.len() - i); out[i..i + take].copy_from_slice(&word[start_in_word..start_in_word + take]); i += take; } } /// BLAKE3 of an arbitrary blob range, streamed in 1 MiB pieces. fn expected_hash(offset: u64, len: u64) -> blake3::Hash { let mut hasher = blake3::Hasher::new(); let mut buf = vec![0u8; 1 << 20]; let mut pos = 0u64; while pos < len { let take = ((len - pos) as usize).min(buf.len()); fill_at(&mut buf[..take], offset + pos); hasher.update(&buf[..take]); pos += take as u64; } hasher.finalize() } // ─── Azure Blob GET stub ──────────────────────────────────────────────────── fn parse_range(headers: &HeaderMap) -> Option<(u64, Option)> { let raw = headers .get("x-ms-range") .or_else(|| headers.get("range"))? .to_str() .ok()?; let spec = raw.strip_prefix("bytes=")?; let (a, b) = spec.split_once('-')?; let start: u64 = a.parse().ok()?; let end: Option = if b.is_empty() { None } else { b.parse().ok() }; Some((start, end)) } /// Serve GET {container}/{blob} with ranged responses in streamed 256 KiB /// frames, synthesizing content per offset — the stub never holds the blob. async fn stub_azure(blob_len: u64) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind stub"); let addr = listener.local_addr().expect("stub addr"); let app = axum::Router::new().fallback(move |req: Request| async move { if req.method() != axum::http::Method::GET { return Response::builder() .status(StatusCode::CREATED) .header("etag", "\"0x1\"") .header("last-modified", "Thu, 01 Jan 2026 00:00:00 GMT") .header("x-ms-request-id", "11111111-1111-1111-1111-111111111111") .header("date", "Thu, 01 Jan 2026 00:00:00 GMT") .body(Body::empty()) .unwrap(); } let (start, end_incl) = parse_range(req.headers()).unwrap_or((0, None)); let end_incl = end_incl.unwrap_or(blob_len - 1).min(blob_len - 1); let this_len = end_incl - start + 1; // Stream the payload in 256 KiB frames, generated on the fly. let body_stream = futures::stream::unfold(0u64, move |sent| async move { if sent >= this_len { return None; } let take = ((this_len - sent) as usize).min(256 * 1024); let mut frame = vec![0u8; take]; fill_at(&mut frame, start + sent); Some(( Ok::(Bytes::from(frame)), sent + take as u64, )) }); Response::builder() .status(StatusCode::PARTIAL_CONTENT) .header("content-type", "application/octet-stream") .header("content-length", this_len.to_string()) .header( "content-range", format!("bytes {start}-{end_incl}/{blob_len}"), ) .header("etag", "\"0x1\"") .header("last-modified", "Thu, 01 Jan 2026 00:00:00 GMT") .header("x-ms-blob-type", "BlockBlob") .header("x-ms-lease-status", "unlocked") .header("x-ms-lease-state", "available") .header("x-ms-request-id", "11111111-1111-1111-1111-111111111111") .header("x-ms-version", "2020-04-08") .header("x-ms-creation-time", "Thu, 01 Jan 2026 00:00:00 GMT") .header("x-ms-server-encrypted", "true") .header("date", "Thu, 01 Jan 2026 00:00:00 GMT") .body(Body::from_stream(body_stream)) .unwrap() }); tokio::spawn(async move { axum::serve(listener, app).await.expect("stub serve"); }); format!("http://{addr}/devaccount") } // ─── BEFORE: verbatim old collect-everything implementations ──────────────── mod before { use super::*; use azure_storage_blobs::prelude::BlobClient; use oxicloud::application::ports::blob_storage_ports::BlobStream; /// Old `get_blob_stream` body (drain everything, yield one chunk). pub async fn get_blob_stream(client: &BlobClient) -> Result { let mut result_data: Vec = Vec::new(); let mut stream = client.get().into_stream(); while let Some(response) = stream.next().await { let response = response.map_err(|e| format!("Failed to get blob: {e}"))?; let mut body = response.data; while let Some(chunk) = body.next().await { let chunk = chunk.map_err(|e| format!("Stream read error: {e}"))?; result_data.extend_from_slice(&chunk); } } let stream: BlobStream = Box::pin(futures::stream::once(async move { Ok(Bytes::from(result_data)) })); Ok(stream) } /// Old `get_blob_range_stream` body. pub async fn get_blob_range_stream( client: &BlobClient, start: u64, end: Option, ) -> Result { let range = match end { Some(e) => azure_core::request_options::Range::new(start, e), None => azure_core::request_options::Range::new(start, u64::MAX), }; let mut result_data: Vec = Vec::new(); let mut stream = client.get().range(range).into_stream(); while let Some(response) = stream.next().await { let response = response.map_err(|e| format!("Failed to get blob range: {e}"))?; let mut body = response.data; while let Some(chunk) = body.next().await { let chunk = chunk.map_err(|e| format!("Stream range read error: {e}"))?; result_data.extend_from_slice(&chunk); } } let stream: BlobStream = Box::pin(futures::stream::once(async move { Ok(Bytes::from(result_data)) })); Ok(stream) } } // ─── Drain helper: TTFB + wall + hash ─────────────────────────────────────── async fn drain( stream: oxicloud::application::ports::blob_storage_ports::BlobStream, t0: Instant, ) -> (f64, f64, blake3::Hash, u64) { let mut stream = stream; let mut hasher = blake3::Hasher::new(); let mut ttfb = None; let mut total = 0u64; while let Some(chunk) = stream.next().await { let chunk = chunk.expect("stream chunk"); if ttfb.is_none() { ttfb = Some(t0.elapsed().as_secs_f64() * 1e3); } total += chunk.len() as u64; hasher.update(&chunk); } ( ttfb.unwrap_or(f64::NAN), t0.elapsed().as_secs_f64() * 1e3, hasher.finalize(), total, ) } fn reset_peak() { PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed); } fn peak_mib() -> f64 { PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0) } #[tokio::main(flavor = "multi_thread")] async fn main() { let mb: u64 = env::var("BENCH_MB") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(256); let tail_mb: u64 = env::var("BENCH_TAIL_MB") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(128); let blob_len = mb * 1024 * 1024; let hash = "aabbccdd00112233445566778899eeff00112233445566778899aabbccddeeff"; let endpoint = stub_azure(blob_len).await; println!("bench_azure_stream — {mb} MiB blob via local stub at {endpoint}\n"); // AFTER: the real backend pointed at the stub via endpoint_url. let backend = AzureBlobBackend::new(&AzureStorageConfig { account_name: "devaccount".to_string(), account_key: base64::Engine::encode( &base64::engine::general_purpose::STANDARD, b"benchkeybenchkeybenchkey", ), container: "blobs".to_string(), sas_token: None, endpoint_url: Some(endpoint.clone()), }); // BEFORE: a raw SDK client at the same endpoint for the verbatim old code. let creds = azure_storage::StorageCredentials::access_key( "devaccount", base64::Engine::encode( &base64::engine::general_purpose::STANDARD, b"benchkeybenchkeybenchkey", ), ); let old_client = azure_storage_blobs::prelude::ClientBuilder::with_location( azure_storage::CloudLocation::Custom { account: "devaccount".to_string(), uri: endpoint.clone(), }, creds, ) .container_client("blobs") .blob_client(format!("{}/{}.blob", &hash[0..2], hash)); let expect_full = expected_hash(0, blob_len); let tail_start = blob_len - tail_mb * 1024 * 1024; let expect_tail = expected_hash(tail_start, blob_len - tail_start); // ── [1] Full-blob download ────────────────────────────────────────────── reset_peak(); let t0 = Instant::now(); let s = before::get_blob_stream(&old_client) .await .expect("before stream"); let (ttfb_b, wall_b, hash_b, len_b) = drain(s, t0).await; let peak_b = peak_mib(); reset_peak(); let t0 = Instant::now(); let s = backend.get_blob_stream(hash).await.expect("after stream"); let (ttfb_a, wall_a, hash_a, len_a) = drain(s, t0).await; let peak_a = peak_mib(); println!("[1] full {mb} MiB download TTFB ms wall ms peak live heap MiB"); println!(" BEFORE (collect-then-yield) {ttfb_b:9.1} {wall_b:9.1} {peak_b:10.1}"); println!( " AFTER (streamed) {ttfb_a:9.1} {wall_a:9.1} {peak_a:10.1} TTFB {:.0}x, heap {:.0}x lower", ttfb_b / ttfb_a, peak_b / peak_a ); // ── [2] Open-ended range (seek to last {tail_mb} MiB) ─────────────────── reset_peak(); let t0 = Instant::now(); let s = before::get_blob_range_stream(&old_client, tail_start, None) .await .expect("before range"); let (rttfb_b, rwall_b, rhash_b, rlen_b) = drain(s, t0).await; let rpeak_b = peak_mib(); reset_peak(); let t0 = Instant::now(); let s = backend .get_blob_range_stream(hash, tail_start, None) .await .expect("after range"); let (rttfb_a, rwall_a, rhash_a, rlen_a) = drain(s, t0).await; let rpeak_a = peak_mib(); println!("[2] range bytes={tail_start}- ({tail_mb} MiB tail)"); println!(" BEFORE (collect-then-yield) {rttfb_b:9.1} {rwall_b:9.1} {rpeak_b:10.1}"); println!( " AFTER (streamed) {rttfb_a:9.1} {rwall_a:9.1} {rpeak_a:10.1} TTFB {:.0}x, heap {:.0}x lower", rttfb_b / rttfb_a, rpeak_b / rpeak_a ); // ── Equivalence gates ─────────────────────────────────────────────────── let mut ok = true; if hash_b != expect_full || hash_a != expect_full || len_b != blob_len || len_a != blob_len { eprintln!("GATE FAIL full blob: hashes/length differ"); ok = false; } if rhash_b != expect_tail || rhash_a != expect_tail || rlen_b != rlen_a { eprintln!("GATE FAIL range: hashes/length differ"); ok = false; } println!( "\n[gate] BLAKE3(BEFORE) == BLAKE3(AFTER) == source: {}", if ok { "OK" } else { "FAILED" } ); if !ok { std::process::exit(1); } }