diff --git a/Cargo.lock b/Cargo.lock index eb2b412a7..6a6401f36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -613,11 +613,14 @@ version = "0.3.0" dependencies = [ "arc-swap", "async-trait", + "base64", "bincode", "bytes", + "crowdb-access-dataset", "crowdb-chunk-client", "crowdb-chunk-kv-client", "crowdb-protocol", + "crowdb-rpc-ffi", "futures", "md-5", "reqwest", @@ -693,6 +696,7 @@ dependencies = [ "crowdb-common", "crowdb-kv-client", "crowdb-protocol", + "crowdb-rpc-ffi", "flatbuffers", "futures", "hmac", @@ -823,6 +827,7 @@ dependencies = [ "crowdb-rpc-ffi", "futures", "rand 0.8.6", + "serde", "serde_json", "thiserror 2.0.18", "tokio", @@ -839,6 +844,7 @@ dependencies = [ "clap", "crowdb-chunk-client", "crowdb-chunk-kv", + "crowdb-chunk-kv-client", "crowdb-chunk-kv-server", "crowdb-chunk-stream", "crowdb-common", @@ -850,6 +856,7 @@ dependencies = [ "crowdb-tree-ffi", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tokio", "toml", @@ -1248,6 +1255,8 @@ dependencies = [ "crowdb-rpc-ffi", "ctor", "flatbuffers", + "serde", + "serde_json", "tokio", ] @@ -1268,6 +1277,48 @@ dependencies = [ "tracing", ] +[[package]] +name = "crowdb-sdk-native" +version = "0.3.0" +dependencies = [ + "arc-swap", + "async-trait", + "bincode", + "bytes", + "crossbeam-skiplist", + "crowdb-access-dataset", + "crowdb-access-multipart", + "crowdb-access-s3", + "crowdb-chunk-client", + "crowdb-chunk-kv-client", + "crowdb-diskio-client", + "crowdb-kv-client", + "crowdb-protocol", + "crowdb-rpc-ffi", + "crowdb-sdk-native", + "flatbuffers", + "futures", + "md-5", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "crowdb-sdk-native-python" +version = "0.3.0" +dependencies = [ + "crowdb-access-dataset", + "crowdb-protocol", + "crowdb-rpc-ffi", + "crowdb-sdk-native", + "pyo3", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "crowdb-test-harness" version = "0.3.0" @@ -1526,6 +1577,7 @@ dependencies = [ "crowdb-monitor", "crowdb-protocol", "crowdb-rpc-ffi", + "crowdb-sdk-native", "flatbuffers", "futures", "hmac", @@ -2998,6 +3050,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.5" @@ -3050,6 +3108,63 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "700d18fa267b73b9b521fd7e13580e2f446916f176cacee1ab63fcc8191f1655" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6100e8a4b5eba53afaa5ed078364851a0b2499c553a44c31026b929049b49dc6" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6143877a16e82b5a727b7127ff4cd86858a24a28d745d72f43e6f227c7b1bdb3" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3967,6 +4082,12 @@ dependencies = [ "libc", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" diff --git a/Cargo.toml b/Cargo.toml index 7d8855b18..737475838 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ members = [ "lib/crowdb-access-iceberg", "lib/crowdb-access-dataset", "lib/crowdb-access-multipart", + "lib/crowdb-sdk-native", + "lib/crowdb-sdk-native-python", ] exclude = ["third-party/hyper", "sdk"] # : crowdb-tree/ffi moved from `exclude` into `members` now that diff --git a/app/crowdb-access-server/Cargo.toml b/app/crowdb-access-server/Cargo.toml index 3ef96d1a4..b36a54e3c 100644 --- a/app/crowdb-access-server/Cargo.toml +++ b/app/crowdb-access-server/Cargo.toml @@ -55,6 +55,7 @@ thiserror = { workspace = true } sync_wrapper = { version = "1", features = ["futures"] } [dev-dependencies] +crowdb-access-dataset = { path = "../../lib/crowdb-access-dataset", features = ["test-util"] } crowdb-kv = { path = "../../lib/crowdb-kv" } bytes = "1" tempfile = "3" diff --git a/app/crowdb-access-server/src/config.rs b/app/crowdb-access-server/src/config.rs index f17c5f381..524b5901e 100644 --- a/app/crowdb-access-server/src/config.rs +++ b/app/crowdb-access-server/src/config.rs @@ -58,6 +58,7 @@ pub struct CommonConfig { pub management_seeds: Vec, pub diskio_connections_per_endpoint: usize, pub diskio_rpc_workers: u32, + pub chunk_kv_rpc: crowdb_chunk_kv_client::ChunkKvRpcTransportConfig, } impl Default for CommonConfig { @@ -66,6 +67,7 @@ impl Default for CommonConfig { management_seeds: Vec::new(), diskio_connections_per_endpoint: 2, diskio_rpc_workers: 2, + chunk_kv_rpc: crowdb_chunk_kv_client::ChunkKvRpcTransportConfig::default(), } } } @@ -249,7 +251,6 @@ pub struct DatasetConfig { pub max_in_flight: usize, pub max_batch_size: usize, pub request_timeout_seconds: u64, - pub lease_ttl_seconds: u64, } impl Default for DatasetConfig { @@ -259,7 +260,6 @@ impl Default for DatasetConfig { max_in_flight: 8, max_batch_size: 10_000, request_timeout_seconds: 60, - lease_ttl_seconds: 60, } } } @@ -311,6 +311,10 @@ pub struct IcebergGcConfig { impl BaseConfig for AccessConfig { #[allow(clippy::too_many_lines)] fn validate(&self) -> Result<(), String> { + self.common + .chunk_kv_rpc + .validate() + .map_err(|error| error.to_string())?; if self.common.diskio_connections_per_endpoint == 0 || self.common.diskio_rpc_workers == 0 { return Err("common DiskIO connections and RPC workers must be nonzero".into()); } @@ -432,9 +436,6 @@ impl AccessConfig { if self.dataset.request_timeout_seconds == 0 || self.dataset.request_timeout_seconds > 300 { return Err("dataset.request_timeout_seconds must be between 1 and 300".into()); } - if self.dataset.lease_ttl_seconds == 0 || self.dataset.lease_ttl_seconds > 3600 { - return Err("dataset.lease_ttl_seconds must be between 1 and 3600".into()); - } Ok(()) } diff --git a/app/crowdb-access-server/src/credentials.rs b/app/crowdb-access-server/src/credentials.rs index 493e9bb12..4e42c014a 100644 --- a/app/crowdb-access-server/src/credentials.rs +++ b/app/crowdb-access-server/src/credentials.rs @@ -34,8 +34,6 @@ pub enum CredentialAuthorityError { InvalidKey, #[error("S3 user has multiple credential records")] DuplicateUser, - #[error("S3 user credential preparation is malformed or belongs to another user")] - InvalidUserPreparation, #[error("S3 user credential is disabled or malformed")] InvalidUserCredential, } @@ -116,11 +114,11 @@ impl CredentialAuthority { if found.is_some() { return Err(CredentialAuthorityError::DuplicateUser); } - let credential = self.cipher.decrypt(user, access_key, &record.encrypted)?; + let mut credential = self.cipher.decrypt(user, access_key, &record.encrypted)?; if !credential.enabled { return Err(CredentialAuthorityError::InvalidUserCredential); } - let secret_key = String::from_utf8(credential.secret_key.clone()) + let secret_key = String::from_utf8(std::mem::take(&mut credential.secret_key)) .map_err(|_| CredentialAuthorityError::InvalidUserCredential)?; found = Some(IssuedUserToken { access_key_id: access_key.to_owned(), diff --git a/app/crowdb-access-server/src/credentials/ensure.rs b/app/crowdb-access-server/src/credentials/ensure.rs index 8d5929ac5..6a0693e7d 100644 --- a/app/crowdb-access-server/src/credentials/ensure.rs +++ b/app/crowdb-access-server/src/credentials/ensure.rs @@ -1,122 +1,73 @@ // Copyright 2026-present Gian // Licensed under the Apache License, Version 2.0. -//! Concurrent bootstrap reserves one encrypted candidate before credential publication. +//! Bootstrap publishes one final encrypted credential without preparation state. -use super::{credential_key, CredentialAuthority, CredentialAuthorityError, CREATE_ATTEMPTS}; +use super::{credential_key, CredentialAuthority, CredentialAuthorityError}; use crowdb_access_s3::auth::{DurableCredentialRecord, IssuedUserToken}; use crowdb_kv_client::{Error as KvError, GetOutcome, ReadMode}; -use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct PreparedUser { - version: u32, - access_key_id: String, - record: Vec, -} - impl CredentialAuthority { - /// Reuse one credential across concurrent bootstrap callers and restarts. - /// The user-scoped preparation contains only the existing encrypted format; - /// publication never replaces another access key's durable record. - /// + /// Reuses an existing token or creates one final resource at a stable public + /// identifier. Concurrent bootstrap callers converge through create-if-absent; + /// each candidate secret is random. No upload/workflow recovery record exists. /// # Errors - /// Fails closed on duplicate/disabled users, malformed preparation, collisions - /// and uncertain Group-0 writes. A retry reconciles the same encrypted candidate. + /// Fails closed on disabled/duplicate users, conflicting user bindings, + /// malformed ciphertext and unavailable or uncertain Group-0 storage. pub async fn ensure_user(&self, user: &[u8]) -> Result { if let Some(token) = self.lookup_user(user).await? { return Ok(token); } - let prepared = self.prepare_user(user).await?; - let key = credential_key(&prepared.access_key_id); - match self.control.put_cas(0, 0, &key, &prepared.record, 0).await { - Ok(_) => {} - Err(KvError::CasFailed { .. } | KvError::CasBusy | KvError::OutcomeUnknown) => { - match self.control.get(0, 0, &key, ReadMode::Linearizable, None).await? { - GetOutcome::Found { value, .. } if value.as_ref() == prepared.record.as_slice() => {} - GetOutcome::Found { .. } => return Err(CredentialAuthorityError::Collision), - GetOutcome::NotFound => return Err(KvError::OutcomeUnknown.into()), - } - } - Err(error) => return Err(error.into()), - } - self.lookup_user(user) - .await? - .ok_or(CredentialAuthorityError::InvalidUserCredential) - } - - async fn prepare_user(&self, user: &[u8]) -> Result { - let key = format!("crowdb/s3/user-bootstrap/{}", hex::encode(Sha256::digest(user))); - let mut candidate = None; - for _ in 0..CREATE_ATTEMPTS { - match self - .control - .get(0, 0, key.as_bytes(), ReadMode::Linearizable, None) - .await? - { - GetOutcome::Found { value, .. } => { - if value.len() > 65536 { - return Err(CredentialAuthorityError::InvalidUserPreparation); - } - let prepared: PreparedUser = serde_json::from_slice(&value) - .map_err(|_| CredentialAuthorityError::InvalidUserPreparation)?; - self.validate_prepared(user, &prepared)?; - return Ok(prepared); - } - GetOutcome::NotFound => {} - } - let prepared = match candidate.as_ref() { - Some(prepared) => prepared, - None => candidate.insert(self.new_prepared(user)?), - }; - let value = - serde_json::to_vec(prepared).map_err(|_| CredentialAuthorityError::InvalidUserPreparation)?; - if value.len() > 65536 { - return Err(CredentialAuthorityError::InvalidUserPreparation); - } - match self.control.put_cas(0, 0, key.as_bytes(), &value, 0).await { - Ok(_) => return Ok(candidate.take().expect("candidate is present")), - Err(KvError::CasFailed { .. } | KvError::CasBusy | KvError::OutcomeUnknown) => {} - Err(error) => return Err(error.into()), - } - } - Err(KvError::OutcomeUnknown.into()) - } - - fn new_prepared(&self, user: &[u8]) -> Result { - let (issued, encrypted) = self.cipher.issue_user_token(user, 1)?; - let record = DurableCredentialRecord { + let mut digest = Sha256::new(); + digest.update(b"crowdb/s3/user-credential/v1\0"); + digest.update(user); + let access_key_id = format!("CROWU{}", hex::encode(digest.finalize())); + let (issued, encrypted) = self.cipher.issue_user_token_for_key(user, access_key_id, 1)?; + let key = credential_key(&issued.access_key_id); + let bytes = DurableCredentialRecord { user: user.to_vec(), encrypted, } .encode()?; - Ok(PreparedUser { - version: 1, - access_key_id: issued.access_key_id.clone(), - record, - }) + match self.control.put_cas(0, 0, &key, &bytes, 0).await { + Ok(_) => Ok(issued), + Err(KvError::CasFailed { + observation: Some(observation), + .. + }) if observation.key.as_ref() == key.as_slice() && observation.revision != 0 => { + self.token(user, &issued.access_key_id, &observation.value) + } + // Older peers and uncertain/busy outcomes have no comparison value. + // Reconcile only the final resource, never a preparation record. + Err(error @ (KvError::CasFailed { .. } | KvError::CasBusy | KvError::OutcomeUnknown)) => { + match self.control.get(0, 0, &key, ReadMode::Linearizable, None).await? { + GetOutcome::Found { value, .. } => self.token(user, &issued.access_key_id, &value), + GetOutcome::NotFound => Err(error.into()), + } + } + Err(error) => Err(error.into()), + } } - fn validate_prepared( + fn token( &self, user: &[u8], - prepared: &PreparedUser, - ) -> Result<(), CredentialAuthorityError> { - if prepared.version != 1 || prepared.access_key_id.is_empty() || prepared.access_key_id.len() > 80 { - return Err(CredentialAuthorityError::InvalidUserPreparation); - } - let record = DurableCredentialRecord::decode(&prepared.record)?; + access_key: &str, + value: &[u8], + ) -> Result { + let record = DurableCredentialRecord::decode(value)?; if record.user != user { - return Err(CredentialAuthorityError::InvalidUserPreparation); + return Err(CredentialAuthorityError::Collision); } - let credential = self - .cipher - .decrypt(user, &prepared.access_key_id, &record.encrypted)?; + let mut credential = self.cipher.decrypt(user, access_key, &record.encrypted)?; if !credential.enabled { return Err(CredentialAuthorityError::InvalidUserCredential); } - Ok(()) + Ok(IssuedUserToken { + access_key_id: access_key.to_owned(), + secret_key: String::from_utf8(std::mem::take(&mut credential.secret_key)) + .map_err(|_| CredentialAuthorityError::InvalidUserCredential)?, + }) } } diff --git a/app/crowdb-access-server/src/dataset.rs b/app/crowdb-access-server/src/dataset.rs index a2523639f..76c1aeab1 100644 --- a/app/crowdb-access-server/src/dataset.rs +++ b/app/crowdb-access-server/src/dataset.rs @@ -11,7 +11,7 @@ use std::time::Duration; use crowdb_access_dataset::{ AuthorityError, ChunkKvDatasetStore, ChunkPayloadStorage, DatasetAuthority, DatasetReadService, - DatasetSnapshotRequest, DatasetUploads, UploadedChunkReader, + DatasetSnapshotRequest, DatasetUploads, UploadExpiryCursor, UploadedChunkReader, }; use crowdb_chunk_kv_client::{ ChunkKvClient, ChunkKvRpcTransport, ClientConfig as ChunkKvConfig, Group0ChunkKvRangeCatalogSource, @@ -93,7 +93,9 @@ fn status_for_authority(error: &AuthorityError) -> StatusCode { AuthorityError::AlreadyExists | AuthorityError::ParentConflict | AuthorityError::HeadConflict + | AuthorityError::SnapshotHeadConflict { .. } | AuthorityError::CursorConflict + | AuthorityError::ReclaimPending { .. } | AuthorityError::SnapshotProtected => StatusCode::CONFLICT, AuthorityError::NotFound | AuthorityError::SnapshotNotFound => StatusCode::NOT_FOUND, AuthorityError::AncestryCycle | AuthorityError::Corrupt | AuthorityError::Store(_) => { @@ -162,7 +164,12 @@ pub async fn run_with_shutdown( let control = Arc::new(CrowdbKvClient::new(KvConfig::new(seeds.clone()))); let source = Arc::new(Group0ChunkKvRangeCatalogSource::from_shared(Arc::clone(&control))); let chunk_config = ChunkKvConfig::default(); - let transport = Arc::new(ChunkKvRpcTransport::new(chunk_config.max_owner_connections, 1, 2)); + let transport = Arc::new(ChunkKvRpcTransport::with_config( + chunk_config.max_owner_connections, + 1, + 2, + &access.common.chunk_kv_rpc, + )?); let client = Arc::new(ChunkKvClient::new(chunk_config, source, transport)?); client.refresh_catalog().await?; let store = Arc::new(ChunkKvDatasetStore::new(client)); @@ -185,12 +192,10 @@ pub async fn run_with_shutdown( crowdb_access_dataset::NamespacePath::root(), std::env::var("CROWDB_DATASET_NAME").unwrap_or_else(|_| "default".into()), )?; - let reads = Arc::new(DatasetReadService::with_ttl( - authority, + let reads = Arc::new(DatasetReadService::new( + Arc::clone(&authority), identity, access.dataset.max_in_flight, - unix_seconds(), - access.dataset.lease_ttl_seconds, )?); let mut service = DatasetHttpService::new(reads, Duration::from_secs(access.dataset.request_timeout_seconds)) @@ -209,15 +214,7 @@ pub async fn run_with_shutdown( service = service.with_bearer(Arc::::from(token)); } let listener = TcpListener::bind(address).await?; - let expiry = tokio::spawn(async move { - let mut tick = tokio::time::interval(Duration::from_secs(15)); - loop { - tick.tick().await; - if let Err(error) = uploads.expire(unix_seconds()).await { - tracing::error!(%error, "Dataset upload cleanup failed; retaining cleanup records for retry"); - } - } - }); + let expiry = start_cleanup(authority, Some(uploads)); let result = serve(listener, Arc::new(service), shutdown).await; expiry.abort(); let _ = expiry.await; @@ -226,6 +223,112 @@ pub async fn run_with_shutdown( Ok(()) } +fn start_cleanup( + authority: Arc, + uploads: Option>, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut snapshot_cursor = crowdb_access_dataset::SnapshotReclaimSweep::default(); + let mut next_snapshot = tokio::time::Instant::now(); + let mut candidate_cursor = crowdb_access_dataset::SnapshotExpirySweep::default(); + let mut next_candidate = tokio::time::Instant::now(); + let mut composition_cursor = crowdb_access_dataset::composition::CompositionReclaimSweep::default(); + let mut next_composition = tokio::time::Instant::now(); + let mut split_cursor = crowdb_access_dataset::split::SplitReclaimSweep::default(); + let mut next_split = tokio::time::Instant::now(); + let mut revision_cursor = crowdb_access_dataset::split::SplitRevisionReclaimSweep::default(); + let mut next_revision = tokio::time::Instant::now(); + let mut upload_cursor = UploadExpiryCursor::default(); + let mut next_upload = tokio::time::Instant::now(); + loop { + tokio::select! { + () = tokio::time::sleep_until(next_revision) => { + let result = authority.reclaim_split_revisions_page(&mut revision_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = if revision_cursor.has_more() { + if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } + } else { Duration::from_secs(15) }; + next_revision = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset Split revision cleanup failed; deletion marks retained for retry"); + } + } + () = tokio::time::sleep_until(next_candidate) => { + let result = authority.expire_snapshot_candidates_page(&mut candidate_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = if candidate_cursor.has_more() { + if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } + } else { Duration::from_secs(15) }; + next_candidate = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset candidate expiry failed; resource deadlines retained for retry"); + } + } + () = tokio::time::sleep_until(next_split) => { + let result = authority.reclaim_splits_page(&mut split_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = if split_cursor.has_more() { + if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } + } else { Duration::from_secs(15) }; + next_split = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset legacy Split cleanup failed; resource facts retained for retry"); + } + } + () = tokio::time::sleep_until(next_composition), if uploads.is_some() => { + let Some(uploads) = &uploads else { continue }; + let result = authority.reclaim_compositions_page(uploads, &mut composition_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = if composition_cursor.has_more() { + if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } + } else { Duration::from_secs(15) }; + next_composition = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset legacy Composition cleanup failed; resource facts retained for retry"); + } + } + () = tokio::time::sleep_until(next_snapshot), if uploads.is_some() => { + let Some(uploads) = &uploads else { continue }; + let result = authority.reclaim_due_page(uploads, &mut snapshot_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = if snapshot_cursor.has_more() { + if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } + } else { Duration::from_secs(15) }; + next_snapshot = tokio::time::Instant::now() + delay; + if let Err(error) = result { + if matches!(error, crowdb_access_dataset::SnapshotReclaimError::Limit) { + tracing::debug!("Dataset snapshot cleanup yielded at its work deadline"); + } else { + tracing::error!(%error, "Dataset snapshot cleanup failed; durable deletion marks retained for retry"); + } + } + } + () = tokio::time::sleep_until(next_upload), if uploads.is_some() => { + let Some(uploads) = &uploads else { continue }; + let result = uploads.expire_step(unix_seconds(), &mut upload_cursor).await; + let delay = if upload_cursor.has_more() { + if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } + } else { Duration::from_secs(15) }; + next_upload = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset upload cleanup failed; retaining cleanup records for retry"); + } + } + } + } + }) +} + +/// Starts the real bounded cleanup loop without opening a listener. +#[cfg(feature = "test-util")] +#[must_use] +pub fn start_dataset_cleanup_for_tests( + authority: Arc, + uploads: Option>, +) -> tokio::task::JoinHandle<()> { + start_cleanup(authority, uploads) +} + async fn connect_payloads( seeds: Vec, control: Arc, diff --git a/app/crowdb-access-server/src/dataset/api.rs b/app/crowdb-access-server/src/dataset/api.rs index b67932f25..84e8335e6 100644 --- a/app/crowdb-access-server/src/dataset/api.rs +++ b/app/crowdb-access-server/src/dataset/api.rs @@ -9,7 +9,7 @@ mod conversion; mod error; mod management; mod publication; -mod session; +mod query; mod upload; mod v2; @@ -110,8 +110,8 @@ impl DatasetHttpService { let tail: Vec<&str> = parts[7..].iter().map(String::as_str).collect(); let method = request.method().clone(); let authority = self.reads.authority(); - if tail.first() == Some(&"read-plans") { - return self.session_route(&identity, &tail, request).await; + if tail.first() == Some(&"reads") && tail.len() > 1 { + return self.query_route(&identity, &tail, request).await; } if tail.first() == Some(&"uploads") { return self.upload_route(&identity, &tail, request).await; @@ -141,7 +141,7 @@ impl DatasetHttpService { json(&conversion::snapshot(record, authority.latest(&identity).await?)) } (Method::GET, ["snapshots", id, "manifest"]) => { - let manifest = authority.get_manifest(&identity, snapshot(id)?).await?; + let manifest = authority.read_manifest(&identity, snapshot(id)?).await?; json(&conversion::public_manifest(manifest, &identity)?) } (Method::PUT, ["names", "stable"]) => { @@ -208,6 +208,8 @@ async fn read_body_limit( .and_then(|v| v.parse::().ok()) .is_some_and(|v| v > limit as u64) { + // Dropping an unread body can reset HTTP before its client receives the error. + admission::drain(request.into_body(), std::time::Duration::from_secs(5)).await; return Err(ApiError::too_large()); } Limited::new(request.into_body(), limit) diff --git a/app/crowdb-access-server/src/dataset/api/admission.rs b/app/crowdb-access-server/src/dataset/api/admission.rs index 08c5cd951..127fc4cc0 100644 --- a/app/crowdb-access-server/src/dataset/api/admission.rs +++ b/app/crowdb-access-server/src/dataset/api/admission.rs @@ -54,16 +54,7 @@ pub(super) async fn reject( mut response: hyper::Response, timeout: std::time::Duration, ) -> hyper::Response { - use http_body_util::BodyExt; - let mut body = http_body_util::Limited::new(request.into_body(), crate::dataset::MAX_REQUEST_BYTES); - let drained = tokio::time::timeout(timeout.min(std::time::Duration::from_secs(5)), async { - while let Some(frame) = body.frame().await { - frame.map_err(|_| ())?; - } - Ok::<_, ()>(()) - }) - .await; - if !matches!(drained, Ok(Ok(()))) { + if !drain(request.into_body(), timeout).await { response.headers_mut().insert( hyper::header::CONNECTION, hyper::header::HeaderValue::from_static("close"), @@ -71,3 +62,19 @@ pub(super) async fn reject( } response } + +/// Discards at most one admitted request window, without allocating a collected body. +pub(super) async fn drain(body: hyper::body::Incoming, timeout: std::time::Duration) -> bool { + use http_body_util::BodyExt; + let mut body = http_body_util::Limited::new(body, crate::dataset::MAX_REQUEST_BYTES); + matches!( + tokio::time::timeout(timeout.min(std::time::Duration::from_secs(5)), async { + while let Some(frame) = body.frame().await { + frame.map_err(|_| ())?; + } + Ok::<_, ()>(()) + }) + .await, + Ok(Ok(())) + ) +} diff --git a/app/crowdb-access-server/src/dataset/api/batch.rs b/app/crowdb-access-server/src/dataset/api/batch.rs index 003542ddf..30b9c1f33 100644 --- a/app/crowdb-access-server/src/dataset/api/batch.rs +++ b/app/crowdb-access-server/src/dataset/api/batch.rs @@ -2,9 +2,11 @@ // Licensed under the Apache License, Version 2.0. use super::{error::ApiError, read_json, snapshot, ApiResult}; -use crate::dataset::{unix_seconds, DatasetBody, DatasetHttpService}; +use crate::dataset::{DatasetBody, DatasetHttpService}; use base64::{engine::general_purpose::STANDARD, Engine}; -use crowdb_access_dataset::{DatasetIdentity, FieldLocator, SampleView, SnapshotId}; +use crowdb_access_dataset::{ + DatasetIdentity, FieldLocator, ReadPage, ResolvedReadPage, SampleView, SnapshotId, UploadReadDescriptor, +}; use futures::stream; use http_body_util::{BodyExt, StreamBody}; use hyper::{ @@ -12,18 +14,13 @@ use hyper::{ Request, Response, }; use serde::{Deserialize, Serialize}; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; +use std::sync::Arc; #[derive(Deserialize)] pub(super) struct BatchRequest { pub snapshot: String, pub sample_ids: Vec, pub fields: Vec, - #[serde(skip)] - pub sample_id_limit: Option, } impl BatchRequest { fn ids(&self) -> Result>, ApiError> { @@ -31,10 +28,7 @@ impl BatchRequest { .iter() .map(|value| { let bytes = STANDARD.decode(value).map_err(|_| ApiError::invalid())?; - if bytes.is_empty() - || bytes.len() > self.sample_id_limit.unwrap_or(1024) - || STANDARD.encode(&bytes) != *value - { + if bytes.is_empty() || bytes.len() > 1024 || STANDARD.encode(&bytes) != *value { return Err(ApiError::invalid()); } Ok(bytes) @@ -43,18 +37,18 @@ impl BatchRequest { } } #[derive(Serialize)] -struct BatchField { - sample: String, - field: String, +struct BatchField<'a> { + sample: &'a str, + field: &'a str, state: &'static str, length: u64, md5: Option, } #[derive(Serialize)] -struct Header { +struct Header<'a> { snapshot: String, - sample_ids: Vec, - fields: Vec, + sample_ids: &'a [String], + fields: Vec>, next: Option, end: bool, } @@ -72,77 +66,60 @@ impl DatasetHttpService { .0 .clone(); let request: BatchRequest = read_json(request).await?; - self.batch(identity, request, None, true, None, request_id).await + self.batch(identity, request, request_id).await } pub(super) async fn batch( &self, identity: &DatasetIdentity, request: BatchRequest, - next: Option, - end: bool, - session: Option, request_id: String, ) -> ApiResult { let id = snapshot(&request.snapshot)?; let ids = request.ids()?; let authority = self.reads.authority(); - let lease = Lease::acquire(authority.clone(), identity, id, session).await?; - let weak = Arc::downgrade(&lease); - tokio::spawn(async move { - let mut timer = tokio::time::interval(std::time::Duration::from_secs(15)); - loop { - timer.tick().await; - let Some(lease) = weak.upgrade() else { break }; - let result = async { - if let Some(session) = lease.session { - crowdb_access_dataset::DatasetSessions::new(lease.authority.clone()) - .resume(&lease.identity, session, unix_seconds()) - .await?; - } - lease - .authority - .touch_read_handle(&lease.identity, lease.snapshot, lease.handle, unix_seconds(), 60) - .await - } - .await; - if let Err(error) = result { - lease.failed.store(true, Ordering::Release); - tracing::error!(%error,"Dataset stream lease renewal failed; delivery cancelled"); - break; - } - } - }); + authority.check_snapshot_readable(identity, id).await?; let samples = if ids.is_empty() { Vec::new() - } else if request.sample_id_limit.is_some() { - authority - .resolve_inspection_batch_locators( - identity, - id, - &ids, - &request.fields, - &crowdb_access_dataset::ReadCancellation::new(), - ) - .await? } else { authority .resolve_batch_locators(identity, id, &ids, &request.fields) .await? }; - let header = make_header(id, &request.sample_ids, &request.fields, &samples, next, end); - let encoded = serde_json::to_vec(&header).map_err(|_| ApiError::integrity())?; - if encoded.len() > 8 * 1024 * 1024 { - return Err(ApiError::too_large()); - } - let mut prefix = Vec::from(b"CDBDS001".as_slice()); - prefix.extend_from_slice(&frame(1, &Bytes::from(encoded))?); + self.render_batch( + ResolvedReadPage { + page: ReadPage { + snapshot: id, + ids, + fields: request.fields, + next: None, + }, + samples, + }, + request_id, + ) + } + + pub(super) fn render_batch(&self, resolved: ResolvedReadPage, request_id: String) -> ApiResult { + let request = resolved.page; + let samples = resolved.samples; + let ids: Vec<_> = request.ids.iter().map(|id| STANDARD.encode(id)).collect(); + let end = request.next.is_none(); + let header = make_header( + request.snapshot, + &ids, + &request.fields, + &samples, + request.next, + end, + ); + let (prefix, encoded) = encode_header(&header)?; let locators = samples .into_iter() - .flat_map(|sample| { + .flat_map(|mut sample| { request .fields .iter() - .filter_map(move |name| sample.fields.get(name).cloned()) + .filter_map(move |name| sample.fields.remove(name)) .filter(|v| !matches!(v, FieldLocator::Tombstone)) }) .collect(); @@ -152,9 +129,10 @@ impl DatasetHttpService { locators, index: 0, part: 0, - pending: Some(Bytes::from(prefix)), + upload: None, + pending: Some(prefix), + metadata: Some(encoded), finished: false, - lease, }; Ok(Response::builder() .header("content-type", "application/vnd.crowdb.dataset-batch.v1") @@ -162,14 +140,49 @@ impl DatasetHttpService { .expect("static response")) } } -fn make_header( + +#[cfg(feature = "test-util")] +impl DatasetHttpService { + /// Encodes only the real batch prefix and metadata, without storage IO. + /// + /// # Errors + /// Returns the API error code if metadata serialization exceeds the header limit. + pub fn batch_header_for_tests(resolved: ResolvedReadPage) -> Result<(Bytes, Bytes), &'static str> { + let request = resolved.page; + let ids: Vec<_> = request.ids.iter().map(|id| STANDARD.encode(id)).collect(); + let end = request.next.is_none(); + let header = make_header( + request.snapshot, + &ids, + &request.fields, + &resolved.samples, + request.next, + end, + ); + encode_header(&header).map_err(|error| error.code()) + } +} +fn encode_header(header: &Header<'_>) -> Result<(Bytes, Bytes), ApiError> { + let encoded = serde_json::to_vec(header).map_err(|_| ApiError::integrity())?; + if encoded.len() > 8 * 1024 * 1024 { + return Err(ApiError::too_large()); + } + let length = u32::try_from(encoded.len()).map_err(|_| ApiError::too_large())?; + let mut prefix = Vec::with_capacity(13); + prefix.extend_from_slice(b"CDBDS001"); + prefix.push(1); + prefix.extend_from_slice(&length.to_be_bytes()); + Ok((Bytes::from(prefix), Bytes::from(encoded))) +} + +fn make_header<'a>( snapshot: SnapshotId, - ids: &[String], - fields: &[String], + ids: &'a [String], + fields: &'a [String], samples: &[SampleView], next: Option, end: bool, -) -> Header { +) -> Header<'a> { let mut values = Vec::new(); for (sample, id) in samples.iter().zip(ids) { for name in fields { @@ -182,8 +195,8 @@ fn make_header( None => ("missing", 0, None), }; values.push(BatchField { - sample: id.clone(), - field: name.clone(), + sample: id, + field: name, state, length, md5, @@ -192,63 +205,22 @@ fn make_header( } Header { snapshot: snapshot.to_string(), - sample_ids: ids.to_vec(), + sample_ids: ids, fields: values, next, end, } } -pub(super) struct Lease { - authority: Arc, - identity: DatasetIdentity, - snapshot: SnapshotId, - handle: crowdb_access_dataset::OperationId, - session: Option, - failed: AtomicBool, -} -impl Lease { - pub(super) async fn acquire( - authority: Arc, - identity: &DatasetIdentity, - snapshot: SnapshotId, - session: Option, - ) -> Result, ApiError> { - let handle = crowdb_access_dataset::OperationId::random(); - authority - .touch_read_handle(identity, snapshot, handle, unix_seconds(), 60) - .await?; - Ok(Arc::new(Self { - authority, - identity: identity.clone(), - snapshot, - handle, - session, - failed: AtomicBool::new(false), - })) - } -} -impl Drop for Lease { - fn drop(&mut self) { - let authority = self.authority.clone(); - let identity = self.identity.clone(); - let snapshot = self.snapshot; - let handle = self.handle; - if let Ok(runtime) = tokio::runtime::Handle::try_current() { - runtime.spawn(async move { - if let Err(error)=authority.release_read_handle(&identity,snapshot,handle).await {tracing::error!(%error,"Dataset read release failed; lease expiry protects disconnected reads");} - }); - } - } -} struct BatchState { uploads: Option>, request_id: String, locators: Vec, index: usize, part: u32, + upload: Option, pending: Option, + metadata: Option, finished: bool, - lease: Arc, } fn stream_body(state: BatchState) -> DatasetBody { let frames = stream::unfold(state, |mut state| async move { @@ -264,34 +236,19 @@ fn stream_body(state: BatchState) -> DatasetBody { frame(4, &Bytes::from(record.to_string())).expect("bounded error frame") } }; - let owner = FrameOwner { - bytes, - lease: state.lease.clone(), - }; - Some(( - Ok::<_, crate::dataset::BoxError>(Frame::data(Bytes::from_owner(owner))), - state, - )) + Some((Ok::<_, crate::dataset::BoxError>(Frame::data(bytes)), state)) }); StreamBody::new(frames).boxed_unsync() } impl BatchState { async fn next(&mut self) -> Result { - if self.lease.failed.load(Ordering::Acquire) { - return Err(crowdb_access_dataset::AuthorityError::from( - crowdb_access_dataset::DatasetError::ReadCancelled, - ) - .into()); - } - if let Some(session) = self.lease.session { - crowdb_access_dataset::DatasetSessions::new(self.lease.authority.clone()) - .check_open(&self.lease.identity, session) - .await?; - } if let Some(bytes) = self.pending.take() { return Ok(bytes); } - let Some(locator) = self.locators.get(self.index) else { + if let Some(bytes) = self.metadata.take() { + return Ok(bytes); + } + let Some(locator) = self.locators.get_mut(self.index) else { self.finished = true; return frame(0, &Bytes::new()); }; @@ -303,18 +260,29 @@ impl BatchState { if value.is_empty() { return self.end_field(); } - let bytes = Bytes::copy_from_slice(value); + let bytes = Bytes::from(std::mem::take(value)); let digest = *md5; self.part = 1; self.data_frame(bytes, digest) } - FieldLocator::Chunk { location, length, .. } => { - let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; - let metadata = uploads.metadata(location).await?; - if u64::from(self.part) >= length.div_ceil(u64::from(metadata.part_size)) { + FieldLocator::Chunk { + location, + length, + md5, + } => { + if self.upload.is_none() { + let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; + let descriptor = uploads.open_read(location).await?; + if descriptor.length() != *length || descriptor.md5() != *md5 { + return Err(ApiError::integrity()); + } + self.upload = Some(descriptor); + } + let descriptor = self.upload.as_ref().ok_or_else(ApiError::integrity)?; + if self.part >= descriptor.part_count() { return self.end_field(); } - let (bytes, digest) = uploads.read_part_verified(location, self.part).await?; + let (bytes, digest) = descriptor.read_part_verified(self.part).await?; self.part += 1; self.data_frame(bytes, digest) } @@ -333,6 +301,7 @@ impl BatchState { fn end_field(&mut self) -> Result { self.index += 1; self.part = 0; + self.upload = None; frame(3, &Bytes::new()) } } @@ -344,13 +313,3 @@ fn frame(kind: u8, bytes: &Bytes) -> Result { output.extend_from_slice(bytes); Ok(Bytes::from(output)) } -struct FrameOwner { - bytes: Bytes, - lease: Arc, -} -impl AsRef<[u8]> for FrameOwner { - fn as_ref(&self) -> &[u8] { - let _ = &self.lease; - &self.bytes - } -} diff --git a/app/crowdb-access-server/src/dataset/api/conversion.rs b/app/crowdb-access-server/src/dataset/api/conversion.rs index ebb01ee69..67acd9fcd 100644 --- a/app/crowdb-access-server/src/dataset/api/conversion.rs +++ b/app/crowdb-access-server/src/dataset/api/conversion.rs @@ -4,8 +4,8 @@ use super::{error::ApiError, models}; use base64::{engine::general_purpose::STANDARD, Engine}; use crowdb_access_dataset::{ - DatasetRecord, FieldLocator, FieldRecord, ManifestRecord, PublicationState, SampleRecord, SchemaRecord, - SnapshotId, SnapshotRecord, + DatasetRecord, FieldLocator, FieldRecord, ManifestRecord, OperationId, PublicationState, SampleRecord, + SchemaRecord, SnapshotId, SnapshotRecord, }; use md5::{Digest, Md5}; use std::collections::BTreeMap; @@ -21,17 +21,26 @@ pub(super) fn dataset(value: &DatasetRecord) -> models::DatasetInfo { } pub(super) fn snapshot(value: SnapshotRecord, latest: Option) -> models::Snapshot { let value = value.publication; + snapshot_metadata(value.snapshot, value.parent, value.operation, value.state, latest) +} +pub(super) fn snapshot_metadata( + snapshot: SnapshotId, + parent: Option, + operation: OperationId, + state: PublicationState, + latest: Option, +) -> models::Snapshot { models::Snapshot { - id: value.snapshot.to_string(), - parent: value.parent.map(|v| v.to_string()), - operation: value.operation.to_string(), - state: match value.state { + id: snapshot.to_string(), + parent: parent.map(|v| v.to_string()), + operation: operation.to_string(), + state: match state { PublicationState::Prepared => "prepared", PublicationState::Published => "published", PublicationState::Aborted => "aborted", } .into(), - is_latest: latest == Some(value.snapshot), + is_latest: latest == Some(snapshot), } } pub(super) fn manifest( diff --git a/app/crowdb-access-server/src/dataset/api/error.rs b/app/crowdb-access-server/src/dataset/api/error.rs index 7a447d646..8c721fc70 100644 --- a/app/crowdb-access-server/src/dataset/api/error.rs +++ b/app/crowdb-access-server/src/dataset/api/error.rs @@ -10,6 +10,7 @@ pub(super) struct ApiError { status: StatusCode, code: &'static str, retryable: bool, + observed_head: Option>, } impl ApiError { pub(super) fn conflict() -> Self { @@ -20,6 +21,7 @@ impl ApiError { status: StatusCode::TOO_MANY_REQUESTS, code: "capacity", retryable: true, + observed_head: None, } } pub(super) fn invalid() -> Self { @@ -48,16 +50,24 @@ impl ApiError { status, code, retryable: status == StatusCode::SERVICE_UNAVAILABLE, + observed_head: None, } } pub(super) fn code(&self) -> &'static str { self.code } + pub(super) fn with_observed_head(mut self, head: serde_json::Value) -> Self { + self.observed_head = Some(Box::new(head)); + self + } pub(super) fn retryable(&self) -> bool { self.retryable } pub(super) fn response(&self, request_id: &str) -> Response { - let body = serde_json::json!({"code":self.code,"message":self.code,"request_id":request_id,"retryable":self.retryable}); + let mut body = serde_json::json!({"code":self.code,"message":self.code,"request_id":request_id,"retryable":self.retryable}); + if let Some(head) = &self.observed_head { + body["observed_head"] = head.as_ref().clone(); + } Response::builder() .status(self.status) .header("content-type", "application/json") @@ -86,6 +96,13 @@ impl From for ApiError { } impl From for ApiError { fn from(error: AuthorityError) -> Self { + if let AuthorityError::SnapshotHeadConflict { observed } = error { + return Self::conflict().with_observed_head(serde_json::json!({ + "latest":observed.latest.map(|id|id.to_string()), + "stable":observed.stable.map(|id|id.to_string()), + "generation":observed.generation.map(|generation|hex::encode(generation.as_bytes())), + })); + } if matches!( &error, AuthorityError::Invalid(crowdb_access_dataset::DatasetError::ReadCancelled) diff --git a/app/crowdb-access-server/src/dataset/api/management.rs b/app/crowdb-access-server/src/dataset/api/management.rs index 4b40ba08d..fad1f6dd6 100644 --- a/app/crowdb-access-server/src/dataset/api/management.rs +++ b/app/crowdb-access-server/src/dataset/api/management.rs @@ -3,9 +3,7 @@ use super::{conversion, error::ApiError, json, read_body, snapshot, ApiResult}; use crate::dataset::DatasetHttpService; -use crowdb_access_dataset::{ - DatasetIdentity, DatasetUploads, FieldLocator, ManifestRecord, ReclaimState, UploadState, -}; +use crowdb_access_dataset::{DatasetIdentity, ReclaimState}; use hyper::{body::Incoming, Method, Request}; use std::collections::BTreeSet; @@ -71,47 +69,15 @@ impl DatasetHttpService { ) } (Method::POST, ["snapshots", id, "reclaim"]) => { - read_body(request, false).await?; - let id = snapshot(id)?; - let record = authority.begin_reclaim(identity, id).await?; - let manifest: ManifestRecord = - bincode::deserialize(&record.publication.manifest).map_err(|_| ApiError::integrity())?; - let locations: BTreeSet<_> = manifest - .samples - .iter() - .flat_map(|s| &s.fields) - .filter_map(|f| { - if let FieldLocator::Chunk { location, .. } = &f.value { - Some(location.clone()) - } else { - None - } - }) - .collect(); - let mut pending = false; - for location in locations { - // External legacy references never transfer physical ownership. - if DatasetUploads::upload_id(identity, &location).is_err() { - continue; - } - let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; - uploads.release(&location, record.publication.operation).await?; - let metadata = uploads.metadata(&location).await?; - if metadata.state == UploadState::Claimed { - continue; - } - match uploads.abort(&location).await { - Ok(result) => pending |= !result.cleanup_complete, - Err(crowdb_access_dataset::UploadError::Conflict) => {} // Another durable owner now protects it. - Err(error) => return Err(error.into()), - } - } - if !pending { - authority.reclaim_snapshot_metadata(identity, id).await?; + if !read_body(request, false).await?.is_empty() { + return Err(ApiError::invalid()); } - json( - &serde_json::json!({"snapshot":id.to_string(),"state":if pending{"pending"}else{"completed"},"chunks_reclaimed":0}), - ) + let id = snapshot(id)?; + let progress = authority.mark_snapshot_reclaim(identity, id).await?; + json(&serde_json::json!({"snapshot":id.to_string(), + "state":if progress.complete {"completed"}else{"pending"}, + "reclaim_after":authority.snapshot_reclaim_after(identity, id).await?, + "chunks_reclaimed":0})) } _ => Err(ApiError::not_found()), } diff --git a/app/crowdb-access-server/src/dataset/api/publication.rs b/app/crowdb-access-server/src/dataset/api/publication.rs index c13076ea8..d1fddc46e 100644 --- a/app/crowdb-access-server/src/dataset/api/publication.rs +++ b/app/crowdb-access-server/src/dataset/api/publication.rs @@ -3,9 +3,9 @@ use super::{conversion, error::ApiError, json, models, operation, read_json, snapshot, ApiResult}; use crate::dataset::{unix_seconds, DatasetHttpService}; -use crowdb_access_dataset::{AuthorityError, DatasetIdentity, FieldLocator, ManifestRecord, UploadState}; +use crowdb_access_dataset::{DatasetIdentity, FieldLocator, ManifestRecord, PublicationState, UploadState}; use hyper::{body::Incoming, Request}; -use std::collections::BTreeSet; +use std::collections::BTreeMap; impl DatasetHttpService { pub(super) async fn publication_route( @@ -21,54 +21,36 @@ impl DatasetHttpService { let locations = self.resolve_payloads(&mut manifest).await?; let bytes = bincode::serialize(&manifest).map_err(|_| ApiError::integrity())?; let authority = self.reads.authority(); - match authority.publication_status(identity, operation).await { - Ok(record) if record.publication.parent != parent || record.publication.manifest != bytes => { - return Err(ApiError::conflict()) - } - Ok(_) => {} - Err(AuthorityError::SnapshotNotFound) => { - if authority.latest(identity).await? != parent { - return Err(ApiError::conflict()); - } - if let Some(parent) = parent { - authority.get_snapshot(identity, parent).await?; - } - } - Err(error) => return Err(error.into()), - } // The candidate is durable before ownership is acquired, so failed or // disconnected requests always have a recoverable publication identity. - authority - .prepare_snapshot_with_operation(identity, parent, bytes.clone(), operation) + let snapshot = authority + .prepare_snapshot_with_operation(identity, parent, bytes, operation) .await?; if !locations.is_empty() { let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; - for location in &locations { + for location in locations.keys() { uploads.claim(location, operation, unix_seconds()).await?; } } - let result = if action == "prepare" { + authority.complete_snapshot_candidate(identity, snapshot).await?; + if action == "publish" { authority - .prepare_snapshot_with_operation(identity, parent, bytes, operation) - .await - } else { - authority - .publish_snapshot_with_operation(identity, parent, bytes, operation) - .await - }; - let _snapshot = result?; - if let Some(uploads) = &self.uploads { - for location in &locations { - uploads.untrack(location).await?; - } + .publish_prepared_snapshot(identity, snapshot, parent) + .await?; } - json(&conversion::snapshot( - authority.publication_status(identity, operation).await?, + json(&conversion::snapshot_metadata( + snapshot, + parent, + operation, + PublicationState::Published, authority.latest(identity).await?, )) } - async fn resolve_payloads(&self, manifest: &mut ManifestRecord) -> Result>, ApiError> { - let mut locations = BTreeSet::new(); + async fn resolve_payloads( + &self, + manifest: &mut ManifestRecord, + ) -> Result, (u64, [u8; 16])>, ApiError> { + let mut locations = BTreeMap::new(); for field in manifest.samples.iter_mut().flat_map(|s| &mut s.fields) { if let FieldLocator::Chunk { location, @@ -76,14 +58,20 @@ impl DatasetHttpService { md5, } = &mut field.value { - let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; - let record = uploads.metadata(location).await?; - if !matches!(record.state, UploadState::Complete | UploadState::Claimed) { - return Err(ApiError::conflict()); - } - *length = record.length; - *md5 = record.md5; - locations.insert(location.clone()); + let (resolved_length, resolved_md5) = if let Some(metadata) = locations.get(location) { + *metadata + } else { + let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; + let record = uploads.metadata(location).await?; + if !matches!(record.state, UploadState::Complete | UploadState::Claimed) { + return Err(ApiError::conflict()); + } + let metadata = (record.length, record.md5); + locations.insert(location.clone(), metadata); + metadata + }; + *length = resolved_length; + *md5 = resolved_md5; } } manifest.validate().map_err(|_| ApiError::invalid())?; diff --git a/app/crowdb-access-server/src/dataset/api/session.rs b/app/crowdb-access-server/src/dataset/api/query.rs similarity index 56% rename from app/crowdb-access-server/src/dataset/api/session.rs rename to app/crowdb-access-server/src/dataset/api/query.rs index c8df7077f..5860d0e20 100644 --- a/app/crowdb-access-server/src/dataset/api/session.rs +++ b/app/crowdb-access-server/src/dataset/api/query.rs @@ -1,28 +1,26 @@ // Copyright 2026-present Gian // Licensed under the Apache License, Version 2.0. -use super::{batch::BatchRequest, error::ApiError, json, operation, read_body, read_json, ApiResult}; -use crate::dataset::{unix_seconds, DatasetHttpService}; +use super::{json, read_json, ApiError, ApiResult, DatasetHttpService}; use base64::{engine::general_purpose::STANDARD, Engine}; use crowdb_access_dataset::{ - DatasetIdentity, DatasetSessions, Selection, SessionPartition, SessionShuffle, SessionSpec, + DatasetIdentity, DatasetReads, ReadCancellation, ReadPartition, ReadShuffle, ReadSpec, Selection, }; use hyper::{body::Incoming, Method, Request}; use serde::Deserialize; #[derive(Deserialize)] struct Plan { - id: String, snapshot: String, selection: Predicate, fields: Vec, batch_size: usize, - partition: SessionPartition, + partition: ReadPartition, #[serde(deserialize_with = "required_shuffle")] - shuffle: Option, + shuffle: Option, } -fn required_shuffle<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { - Option::::deserialize(d) +fn required_shuffle<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Option::::deserialize(d) } #[derive(Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] @@ -83,77 +81,56 @@ impl Predicate { } } #[derive(Deserialize)] -struct Acknowledge { - expected: String, - next: String, +#[serde(deny_unknown_fields)] +pub(super) struct NextRead { + pub cursor: String, } impl DatasetHttpService { - pub(super) async fn session_route( + pub(super) async fn query_route( &self, identity: &DatasetIdentity, tail: &[&str], request: Request, ) -> ApiResult { - let request_id = request - .extensions() - .get::() - .ok_or_else(ApiError::integrity)? - .0 - .clone(); - let sessions = DatasetSessions::new(self.reads.authority()); - let now = unix_seconds(); - match (request.method().clone(), tail) { - (Method::POST, ["read-plans"]) => { + if request.method() != Method::POST { + return Err(ApiError::not_found()); + } + match tail { + ["reads", "bind"] => { let p: Plan = read_json(request).await?; - let spec = SessionSpec { - id: operation(&p.id)?, + let spec = ReadSpec { snapshot: p.snapshot, selection: p.selection.native(0, &mut 1024)?, fields: p.fields, batch_size: p.batch_size, partition: p.partition, shuffle: p.shuffle, + split: None, }; - json(&sessions.start(identity, spec, now).await?) - } - (Method::GET, ["read-plans", id]) => json(&sessions.resume(identity, operation(id)?, now).await?), - (Method::DELETE, ["read-plans", id]) => { - read_body(request, false).await?; - json(&sessions.close(identity, operation(id)?).await?) - } - (Method::POST, ["read-plans", id, "next"]) => { - read_body(request, false).await?; - let batch = sessions.next(identity, operation(id)?, now).await?; - self.batch( - identity, - BatchRequest { - snapshot: batch.snapshot.to_string(), - sample_ids: batch.ids.iter().map(|id| STANDARD.encode(id)).collect(), - fields: batch.fields, - sample_id_limit: None, - }, - Some(batch.next), - batch.end, - Some(operation(id)?), - request_id, - ) - .await - } - (Method::PUT, ["read-plans", id, "progress"]) => { - let ack: Acknowledge = read_json(request).await?; - if ack.expected.len() > 4096 || ack.next.len() > 4096 { - return Err(ApiError::too_large()); - } - let r = sessions - .acknowledge(identity, operation(id)?, &ack.expected, &ack.next, now) + let binding = DatasetReads::new(self.reads.authority()) + .bind(identity, spec) .await?; - json(&serde_json::json!({"plan":r.plan,"cursor":r.acknowledged})) - } - (Method::GET, ["read-plans", id, "progress"]) => { - let r = sessions.resume(identity, operation(id)?, now).await?; - json(&serde_json::json!({"plan":r.plan,"cursor":r.acknowledged})) + json(&serde_json::json!({"snapshot":binding.snapshot.to_string(), "cursor":binding.cursor})) } + ["reads", "next"] => self.query_next(identity, request).await, _ => Err(ApiError::not_found()), } } + pub(super) async fn query_next( + &self, + identity: &DatasetIdentity, + request: Request, + ) -> ApiResult { + let request_id = request + .extensions() + .get::() + .ok_or_else(ApiError::integrity)? + .0 + .clone(); + let input: NextRead = read_json(request).await?; + let page = DatasetReads::new(self.reads.authority()) + .next_with_locators(identity, &input.cursor, &ReadCancellation::new()) + .await?; + self.render_batch(page, request_id) + } } diff --git a/app/crowdb-access-server/src/dataset/api/v2.rs b/app/crowdb-access-server/src/dataset/api/v2.rs index f5dbdcf6f..1f9014cfe 100644 --- a/app/crowdb-access-server/src/dataset/api/v2.rs +++ b/app/crowdb-access-server/src/dataset/api/v2.rs @@ -4,10 +4,11 @@ //! Capability-gated control API. Unsupported operations never fall back to v1. mod auxiliary; -mod composition_fields; mod composition_models; +mod composition_preparation; mod composition_responses; mod compositions; +mod heads; mod inspection; mod inspection_models; mod predicate; @@ -16,6 +17,7 @@ mod query; mod read_models; mod reads; mod split_models; +mod split_preparation; mod split_requests; mod split_responses; mod splits; @@ -32,13 +34,6 @@ struct Capabilities { capabilities: Vec<&'static str>, } -#[derive(Serialize)] -struct Head { - latest: Option, - stable: Option, - generation: Option, -} - impl DatasetHttpService { pub(super) async fn route_v2(&self, parts: &[String], request: Request) -> ApiResult { if parts == ["api", "dataset", "v2", "namespaces", "dataset-ns", "datasets"] { @@ -49,42 +44,31 @@ impl DatasetHttpService { read_body(request, false).await?; let mut capabilities = vec![ "head-inspection", + "snapshot-head-update", "bounded-inspection", "inspection-preparation", "dataset-management", "split-revisions", - "split-reads", + "split-revision-delete", + "split-preparation", + "split-head-update", + "stateless-reads", ]; if self.uploads.is_some() { - capabilities.extend(["uploads", "composition", "snapshot-reclaim"]); + capabilities.extend(["uploads", "composition-preparation", "snapshot-reclaim"]); } return json(&Capabilities { api_version: 2, capabilities, }); } - if parts.len() == 8 - && parts[..6] == ["api", "dataset", "v2", "namespaces", "dataset-ns", "datasets"] - && parts[7] == "head" - && request.method() == Method::GET - { - query::Query::parse(request.uri().query(), &[])?; - read_body(request, false).await?; - let identity = - DatasetIdentity::new(NamespacePath::root(), &parts[6]).map_err(|_| ApiError::invalid())?; - let head = self.reads.authority().inspect_head(&identity).await?; - return json(&Head { - latest: head.latest.map(|id| id.to_string()), - stable: head.stable.map(|id| id.to_string()), - generation: head - .generation - .map(|generation| hex::encode(generation.as_bytes())), - }); - } if parts.len() >= 7 && parts[..6] == ["api", "dataset", "v2", "namespaces", "dataset-ns", "datasets"] { let identity = DatasetIdentity::new(NamespacePath::root(), &parts[6]).map_err(|_| ApiError::invalid())?; + if parts.len() == 8 && parts[7] == "head" { + return self.v2_head(&identity, request).await; + } let tail: Vec<_> = parts[7..].iter().map(String::as_str).collect(); if tail.first() == Some(&"uploads") { return self.v2_uploads(&identity, &tail, request).await; @@ -108,11 +92,16 @@ impl DatasetHttpService { .await; } } + if let ["snapshots", id] = tail.as_slice() { + if request.method() == Method::PUT { + return self.v2_prepare_snapshot(&identity, id, request).await; + } + } if tail.first() == Some(&"compositions") { return self.v2_compositions(&identity, &tail, request).await; } - if tail.first() == Some(&"read-plans") { - return self.v2_read_session(&identity, &tail, request).await; + if tail.first() == Some(&"reads") { + return self.v2_read_query(&identity, &tail, request).await; } if matches!( tail.as_slice(), diff --git a/app/crowdb-access-server/src/dataset/api/v2/auxiliary.rs b/app/crowdb-access-server/src/dataset/api/v2/auxiliary.rs index 5c53a95d1..dcfa8fa71 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/auxiliary.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/auxiliary.rs @@ -54,19 +54,18 @@ impl DatasetHttpService { .await?; return json(&serde_json::json!({"snapshot": value.snapshot.to_string(), "operation": value.operation.map(|id| id.to_string()), "latest": value.latest, - "stable": value.stable, "retained": value.retained, "active_read_lease": value.active_read_lease, + "stable": value.stable, "retained": value.retained, "active_read_lease": value.active_read_lease, "reclaim_after":value.reclaim_after, "state": if value.completed { "completed" } else if value.retiring { "pending" } else { "not_started" }, "physical_cleanup_deferred": value.retiring || value.completed, "parent_protection": "not_evaluated"})); } (Method::POST, ["snapshots", id, "reclaim"]) => { - read_body(request, false).await?; - let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; - let progress = authority - .reclaim_snapshot_step(identity, snapshot(id)?, uploads, &ReadCancellation::new()) - .await?; + if !read_body(request, false).await?.is_empty() { + return Err(ApiError::invalid()); + } + let progress = authority.mark_snapshot_reclaim(identity, snapshot(id)?).await?; return json( - &serde_json::json!({"snapshot":progress.snapshot.to_string(),"phase":progress.phase,"complete":progress.complete,"physical_cleanup_deferred":progress.physical_cleanup_deferred}), + &serde_json::json!({"snapshot":progress.snapshot.to_string(),"phase":progress.phase,"complete":progress.complete,"physical_cleanup_deferred":progress.physical_cleanup_deferred,"reclaim_after":authority.snapshot_reclaim_after(identity, progress.snapshot).await?}), ); } (Method::GET, ["commits", id]) => { @@ -76,13 +75,8 @@ impl DatasetHttpService { ); } (Method::POST, ["split-drafts", id, "cleanup"]) => { - read_body(request, false).await?; - let progress = authority - .cleanup_split_scratch_step(identity, operation(id)?, &ReadCancellation::new()) - .await?; - return json( - &serde_json::json!({"input_revision":progress.input_revision.to_string(),"next_page":progress.next_page.to_string(),"complete":progress.complete}), - ); + operation(id)?; + return Err(ApiError::unsupported()); } _ => return Err(ApiError::not_found()), } diff --git a/app/crowdb-access-server/src/dataset/api/v2/composition_fields.rs b/app/crowdb-access-server/src/dataset/api/v2/composition_fields.rs deleted file mode 100644 index c1f6e0d18..000000000 --- a/app/crowdb-access-server/src/dataset/api/v2/composition_fields.rs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright 2026-present Gian -// Licensed under the Apache License, Version 2.0. - -use super::{ - super::{operation, ApiError}, - query::bytes, -}; -use crowdb_access_dataset::{ - composition, DatasetIdentity, DatasetUploads, FieldLocator, FieldRecord, SampleRecord, UploadState, -}; -use md5::{Digest, Md5}; -use serde::Deserialize; - -#[derive(Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub(super) enum SampleChange { - Remove { id: String }, - Upsert { id: String, fields: Vec }, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Field { - name: String, - value: Value, -} -#[derive(Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -enum Value { - Inline { data: String }, - Upload { id: String }, - Tombstone, -} -impl SampleChange { - pub(super) async fn native( - self, - identity: &DatasetIdentity, - uploads: &DatasetUploads, - ) -> Result { - match self { - Self::Remove { id } => Ok(composition::SampleChange::Remove(bytes(&id, 16384, false)?)), - Self::Upsert { id, fields } => { - if fields.len() > 256 { - return Err(ApiError::too_large()); - } - let sample_id = bytes(&id, 16384, false)?; - let mut values = Vec::new(); - for field in fields { - values.push(field.native(identity, uploads).await?); - } - Ok(composition::SampleChange::Upsert(SampleRecord { - sample_id, - fields: values, - })) - } - } - } -} -impl Field { - async fn native( - self, - identity: &DatasetIdentity, - uploads: &DatasetUploads, - ) -> Result { - if self.name.is_empty() || self.name.len() > 1024 { - return Err(ApiError::invalid()); - } - let value = match self.value { - Value::Inline { data } => { - let value = bytes(&data, 65536, true)?; - FieldLocator::Inline { - md5: Md5::digest(&value).into(), - value, - } - } - Value::Tombstone => FieldLocator::Tombstone, - Value::Upload { id } => { - let location = DatasetUploads::location(identity, operation(&id)?)?; - let record = uploads.metadata(&location).await?; - if !matches!(record.state, UploadState::Complete | UploadState::Claimed) { - return Err(ApiError::conflict()); - } - FieldLocator::Chunk { - location, - length: record.length, - md5: record.md5, - } - } - }; - Ok(FieldRecord { - name: self.name, - value, - }) - } -} diff --git a/app/crowdb-access-server/src/dataset/api/v2/composition_models.rs b/app/crowdb-access-server/src/dataset/api/v2/composition_models.rs index ef6ca141a..460459814 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/composition_models.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/composition_models.rs @@ -1,11 +1,7 @@ // Copyright 2026-present Gian // Licensed under the Apache License, Version 2.0. -use super::{ - super::{operation, snapshot, ApiError}, - predicate::Predicate, - split_models::{decimal, required_option, revision, DraftSplit, Policy, Source}, -}; +use super::{super::ApiError, predicate::Predicate, split_models::Source}; use crowdb_access_dataset::{composition as native, FieldDefinition, SchemaRecord}; use serde::{Deserialize, Serialize}; @@ -67,7 +63,7 @@ pub(super) enum CompositionSource { }, } impl CompositionSource { - fn native(self) -> Result { + pub(super) fn native(self) -> Result { Ok(match self { Self::All => native::CompositionSource::All, Self::Selection { predicate } => native::CompositionSource::Selection(predicate.native()?), @@ -92,87 +88,3 @@ impl From for CompositionSource { } } } -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Begin { - pub operation: String, - #[serde(deserialize_with = "required_option")] - pub parent: Option, - pub source: CompositionSource, - pub schema: Option, - pub inherit_parent_splits: bool, - pub parent_split_revision: Option, - pub splits: Vec, - pub policy: Option, -} -impl Begin { - pub(super) fn native( - self, - ) -> Result<(crowdb_access_dataset::OperationId, native::CompositionInput), ApiError> { - Ok(( - operation(&self.operation)?, - native::CompositionInput { - parent: self.parent.as_deref().map(snapshot).transpose()?, - source: self.source.native()?, - schema: self.schema.map(Schema::native).transpose()?, - inherit_parent_splits: self.inherit_parent_splits, - parent_split_revision: self.parent_split_revision.as_deref().map(revision).transpose()?, - splits: self - .splits - .into_iter() - .map(DraftSplit::native) - .collect::>()?, - policy: self.policy.map(Into::into), - }, - )) - } -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct CompositionEdit { - pub edit: String, - pub expected_revision: String, - pub schema: Option, - pub samples: Vec, - pub splits: Option>, - pub policy: Option, -} -impl CompositionEdit { - pub(super) async fn native( - self, - identity: &crowdb_access_dataset::DatasetIdentity, - uploads: &crowdb_access_dataset::DatasetUploads, - ) -> Result { - if self.samples.len() > 20 { - return Err(ApiError::too_large()); - } - let mut samples = Vec::new(); - for sample in self.samples { - samples.push(sample.native(identity, uploads).await?); - } - Ok(native::CompositionEdit { - id: operation(&self.edit)?, - expected_revision: decimal(&self.expected_revision)?, - schema: self.schema.map(Schema::native).transpose()?, - samples, - splits: self - .splits - .map(|splits| { - splits - .into_iter() - .map(DraftSplit::native) - .collect::>() - }) - .transpose()?, - policy: self.policy.map(Into::into), - }) - } -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Publish { - pub attempt: String, - pub snapshot: String, - #[serde(deserialize_with = "required_option")] - pub expected_head: Option, -} diff --git a/app/crowdb-access-server/src/dataset/api/v2/composition_preparation.rs b/app/crowdb-access-server/src/dataset/api/v2/composition_preparation.rs new file mode 100644 index 000000000..27fc1a51f --- /dev/null +++ b/app/crowdb-access-server/src/dataset/api/v2/composition_preparation.rs @@ -0,0 +1,72 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +mod fields; + +use super::{ + super::{json, read_body_limit, snapshot, ApiError, ApiResult, DatasetHttpService}, + composition_models::{CompositionSource, Schema}, + query::Query, + split_models::{required_option, DraftSplit, Policy}, +}; +use crowdb_access_dataset::{composition::CompositionRequest, DatasetIdentity, ReadCancellation}; +use hyper::{body::Incoming, Request}; +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Prepare { + #[serde(deserialize_with = "required_option")] + parent: Option, + source: CompositionSource, + #[serde(deserialize_with = "required_option")] + schema: Option, + samples: Vec, + splits: Vec, + policy: Policy, +} + +impl DatasetHttpService { + pub(super) async fn v2_prepare_snapshot( + &self, + identity: &DatasetIdentity, + id: &str, + request: Request, + ) -> ApiResult { + Query::parse(request.uri().query(), &[])?; + let snapshot_id = snapshot(id)?; + let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; + let bytes = read_body_limit(request, true, 8 * 1024 * 1024).await?; + let input: Prepare = serde_json::from_slice(&bytes).map_err(|_| ApiError::invalid())?; + let input = CompositionRequest { + parent: input.parent.as_deref().map(snapshot).transpose()?, + source: input.source.native()?, + schema: input.schema.map(Schema::native).transpose()?, + samples: input + .samples + .into_iter() + .map(|sample| sample.native(identity)) + .collect::>()?, + splits: input + .splits + .into_iter() + .map(DraftSplit::native) + .collect::>()?, + policy: input.policy.into(), + }; + let result = self + .reads + .authority() + .prepare_composition_resource( + identity, + snapshot_id, + &input, + Some(uploads), + &ReadCancellation::new(), + ) + .await?; + json(&serde_json::json!({"snapshot":result.snapshot.to_string(), + "initial_split":result.initial_split.map(|revision| revision.to_string()), + "sample_count":result.sample_count.to_string()})) + } +} diff --git a/app/crowdb-access-server/src/dataset/api/v2/composition_preparation/fields.rs b/app/crowdb-access-server/src/dataset/api/v2/composition_preparation/fields.rs new file mode 100644 index 000000000..2a1b96687 --- /dev/null +++ b/app/crowdb-access-server/src/dataset/api/v2/composition_preparation/fields.rs @@ -0,0 +1,80 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::super::{ + super::{operation, ApiError}, + query::bytes, + split_models::decimal, + uploads::digest, +}; +use crowdb_access_dataset::{ + composition::SampleChange, DatasetIdentity, DatasetUploads, FieldLocator, FieldRecord, SampleRecord, +}; +use md5::{Digest, Md5}; +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub(super) enum Change { + Remove { id: String }, + Upsert { id: String, fields: Vec }, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Field { + name: String, + value: Value, +} +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum Value { + Inline { data: String }, + Upload { id: String, length: String, md5: String }, + Tombstone, +} + +impl Change { + pub(super) fn native(self, identity: &DatasetIdentity) -> Result { + match self { + Self::Remove { id } => Ok(SampleChange::Remove(bytes(&id, 16384, false)?)), + Self::Upsert { id, fields } => { + if fields.len() > 256 { + return Err(ApiError::too_large()); + } + Ok(SampleChange::Upsert(SampleRecord { + sample_id: bytes(&id, 16384, false)?, + fields: fields + .into_iter() + .map(|field| field.native(identity)) + .collect::>()?, + })) + } + } + } +} +impl Field { + fn native(self, identity: &DatasetIdentity) -> Result { + if self.name.is_empty() || self.name.len() > 1024 { + return Err(ApiError::invalid()); + } + let value = match self.value { + Value::Inline { data } => { + let value = bytes(&data, 65536, true)?; + FieldLocator::Inline { + md5: Md5::digest(&value).into(), + value, + } + } + Value::Tombstone => FieldLocator::Tombstone, + Value::Upload { id, length, md5 } => FieldLocator::Chunk { + location: DatasetUploads::location(identity, operation(&id)?)?, + length: decimal(&length)?, + md5: digest(&md5)?, + }, + }; + Ok(FieldRecord { + name: self.name, + value, + }) + } +} diff --git a/app/crowdb-access-server/src/dataset/api/v2/composition_responses.rs b/app/crowdb-access-server/src/dataset/api/v2/composition_responses.rs index e0c76fa76..484628a71 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/composition_responses.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/composition_responses.rs @@ -8,7 +8,7 @@ use super::{ split_responses, }; use crowdb_access_dataset::{ - composition::{CompositionDraft, CompositionError, CompositionPreview, CompositionUploadStatus}, + composition::{CompositionDraft, CompositionError, CompositionUploadStatus}, SnapshotReclaimError, UploadReclaimError, }; use hyper::StatusCode; @@ -21,9 +21,7 @@ pub(super) fn draft(d: CompositionDraft, uploads: &CompositionUploadStatus) -> V "preparation":d.preparation.map(|p| json!({"snapshot":p.candidate.to_string(), "split_preview":p.split_preview.map(split_responses::preview)})), "uploads":{"publication_ready":uploads.publication_ready, "staged_uploads":uploads.staged_uploads.to_string(), "candidate_uploads":uploads.candidate_uploads.to_string()}}) } -pub(super) fn preview(p: &CompositionPreview) -> Value { - json!({"input_revision":p.input_revision.to_string(), "next_page":p.next_page.to_string(), "members":p.members.to_string(), "complete":p.complete, "binding":hex::encode(p.binding)}) -} + impl From for ApiError { fn from(error: CompositionError) -> Self { match error { diff --git a/app/crowdb-access-server/src/dataset/api/v2/compositions.rs b/app/crowdb-access-server/src/dataset/api/v2/compositions.rs index d315a09fc..37c2d926c 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/compositions.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/compositions.rs @@ -2,15 +2,13 @@ // Licensed under the Apache License, Version 2.0. use super::{ - super::{json, operation, read_body, snapshot, ApiError, ApiResult, DatasetHttpService}, - composition_models as input, composition_responses as output, + super::{json, operation, read_body, ApiError, ApiResult, DatasetHttpService}, + composition_responses as output, query::Query, split_models::decimal, - split_requests, split_responses, -}; -use crowdb_access_dataset::{ - composition::DraftProgress, CommitAttempt, CommitTarget, DatasetIdentity, OperationId, ReadCancellation, + split_requests, }; +use crowdb_access_dataset::{DatasetIdentity, OperationId}; use hyper::{body::Incoming, Method, Request}; impl DatasetHttpService { pub(super) async fn v2_compositions( @@ -20,35 +18,24 @@ impl DatasetHttpService { request: Request, ) -> ApiResult { Query::parse(request.uri().query(), &[])?; - self.reads.authority().get_dataset(identity).await?; - let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; - if tail == ["compositions"] && request.method() == Method::POST { - return self.v2_composition_begin(identity, request).await; + if tail == ["compositions"] && matches!(*request.method(), Method::PUT | Method::POST) { + return Err(ApiError::unsupported()); } let ["compositions", id, rest @ ..] = tail else { return Err(ApiError::not_found()); }; let operation = operation(id)?; + if request.method() == Method::PUT || request.method() == Method::POST { + return Err(ApiError::unsupported()); + } + self.reads.authority().get_dataset(identity).await?; + self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; let authority = self.reads.authority(); match (request.method().clone(), rest) { (Method::GET, []) => { read_body(request, false).await?; self.v2_composition_status(identity, operation).await } - (Method::PUT, []) => { - let input: input::CompositionEdit = read_json(request).await?; - let edit = - tokio::time::timeout(std::time::Duration::from_secs(2), input.native(identity, uploads)) - .await - .map_err(|_| ApiError::timeout())??; - let draft = authority - .edit_composition_with_uploads(identity, operation, &edit, uploads) - .await?; - json(&output::draft( - draft, - &authority.composition_upload_status(identity, operation).await?, - )) - } (Method::DELETE, []) => { let input: split_requests::Prepare = read_json(request).await?; authority @@ -56,72 +43,9 @@ impl DatasetHttpService { .await?; self.v2_composition_status(identity, operation).await } - (Method::POST, ["evaluate"]) => { - let input: split_requests::Evaluate = read_json(request).await?; - json(&output::preview( - &authority - .evaluate_composition( - identity, - operation, - decimal(&input.expected_revision)?, - decimal(&input.expected_page)?, - &ReadCancellation::new(), - ) - .await?, - )) - } - (Method::POST, ["prepare"]) => { - let input: split_requests::Prepare = read_json(request).await?; - let draft = authority - .prepare_composition( - identity, - operation, - decimal(&input.expected_revision)?, - &ReadCancellation::new(), - ) - .await?; - if matches!(draft.record.progress, DraftProgress::Prepared { .. }) { - authority - .prepare_composition_uploads(identity, operation, uploads, &ReadCancellation::new()) - .await?; - } - self.v2_composition_status(identity, operation).await - } - (Method::POST, ["publish"]) => { - let input: input::Publish = read_json(request).await?; - let attempt = CommitAttempt { - version: 1, - id: super::super::operation(&input.attempt)?, - target: CommitTarget::Snapshot { - snapshot: snapshot(&input.snapshot)?, - }, - expected_head: split_requests::generation(input.expected_head.as_deref())?, - }; - json(&split_responses::receipt( - &authority - .publish_composition(identity, operation, &attempt) - .await?, - )) - } - (Method::POST, ["cleanup"]) => { - read_body(request, false).await?; - self.v2_composition_cleanup(identity, operation).await - } _ => Err(ApiError::not_found()), } } - async fn v2_composition_begin( - &self, - identity: &DatasetIdentity, - request: Request, - ) -> ApiResult { - let input: input::Begin = read_json(request).await?; - let (operation, input) = input.native()?; - let authority = self.reads.authority(); - authority.begin_composition(identity, operation, &input).await?; - authority.manage_composition_uploads(identity, operation).await?; - self.v2_composition_status(identity, operation).await - } async fn v2_composition_status(&self, identity: &DatasetIdentity, operation: OperationId) -> ApiResult { let authority = self.reads.authority(); json(&output::draft( @@ -129,40 +53,6 @@ impl DatasetHttpService { &authority.composition_upload_status(identity, operation).await?, )) } - async fn v2_composition_cleanup(&self, identity: &DatasetIdentity, operation: OperationId) -> ApiResult { - let authority = self.reads.authority(); - let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; - let cancel = ReadCancellation::new(); - let candidates = authority - .cleanup_composition_candidates_step(identity, operation, &cancel) - .await?; - let owners = authority - .cleanup_composition_uploads_step(identity, operation, uploads) - .await?; - let scratch = authority - .cleanup_composition_scratch_step(identity, operation, &cancel) - .await?; - let draft = authority.composition_status(identity, operation).await?; - let edits = if matches!( - draft.record.progress, - DraftProgress::Published { .. } | DraftProgress::Abandoned - ) { - Some( - authority - .cleanup_composition_edits_step(identity, operation, &cancel) - .await?, - ) - } else { - None - }; - json( - &serde_json::json!({"complete":candidates.complete && owners.complete && scratch.complete && edits.as_ref().map_or(true, |e| e.complete), - "candidates":{"input_revision":candidates.input_revision.to_string(),"complete":candidates.complete}, - "uploads":{"retired_owners":owners.retired_owners.to_string(),"complete":owners.complete}, - "scratch":{"input_revision":scratch.input_revision.to_string(),"next_page":scratch.next_page.to_string(),"complete":scratch.complete}, - "edits":edits.map(|e|serde_json::json!({"retired_pages":e.retired_pages.to_string(),"complete":e.complete})), "physical_cleanup_deferred":true}), - ) - } } async fn read_json(request: Request) -> Result { diff --git a/app/crowdb-access-server/src/dataset/api/v2/heads.rs b/app/crowdb-access-server/src/dataset/api/v2/heads.rs new file mode 100644 index 000000000..0e37499a4 --- /dev/null +++ b/app/crowdb-access-server/src/dataset/api/v2/heads.rs @@ -0,0 +1,65 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::{ + super::{read_body_limit, snapshot}, + json, + query::Query, + read_body, + split_models::required_option, + split_requests::generation, + ApiError, ApiResult, DatasetHttpService, +}; +use crowdb_access_dataset::{DatasetHead, DatasetIdentity, SnapshotHeadUpdate}; +use hyper::{body::Incoming, Method, Request}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UpdateHead { + snapshot: String, + #[serde(deserialize_with = "required_option")] + expected_head: Option, +} +#[derive(Serialize)] +struct Head { + latest: Option, + stable: Option, + generation: Option, +} +impl From for Head { + fn from(head: DatasetHead) -> Self { + Self { + latest: head.latest.map(|id| id.to_string()), + stable: head.stable.map(|id| id.to_string()), + generation: head.generation.map(|value| hex::encode(value.as_bytes())), + } + } +} +impl DatasetHttpService { + pub(super) async fn v2_head(&self, identity: &DatasetIdentity, request: Request) -> ApiResult { + Query::parse(request.uri().query(), &[])?; + let authority = self.reads.authority(); + let head = match *request.method() { + Method::GET => { + read_body(request, false).await?; + authority.inspect_head(identity).await? + } + Method::PUT => { + let body = read_body_limit(request, true, 4096).await?; + let input: UpdateHead = serde_json::from_slice(&body).map_err(|_| ApiError::invalid())?; + authority + .update_snapshot_head( + identity, + &SnapshotHeadUpdate { + snapshot: snapshot(&input.snapshot)?, + expected_head: generation(input.expected_head.as_deref())?, + }, + ) + .await? + } + _ => return Err(ApiError::not_found()), + }; + json(&Head::from(head)) + } +} diff --git a/app/crowdb-access-server/src/dataset/api/v2/inspection.rs b/app/crowdb-access-server/src/dataset/api/v2/inspection.rs index 1d0686e8c..7790523b2 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/inspection.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/inspection.rs @@ -8,7 +8,7 @@ use super::{ }; use base64::{engine::general_purpose::STANDARD, Engine}; use crowdb_access_dataset::{ - inspection::{InspectionError, PreparationLimits, PreparationState}, + inspection::{InspectionError, InspectionPreparationLimits, PreparationState}, DatasetIdentity, NamespacePath, ReadCancellation, }; use hyper::{body::Incoming, Method, Request}; @@ -124,14 +124,19 @@ impl DatasetHttpService { &self, identity: &DatasetIdentity, snapshot: crowdb_access_dataset::SnapshotId, - advance: bool, + prepare: bool, ) -> ApiResult { let authority = self.reads.authority(); let cancel = ReadCancellation::new(); - let progress = if advance { + let progress = if prepare { authority.require_committed_snapshot(identity, snapshot).await?; authority - .prepare_inspection_page(identity, snapshot, PreparationLimits::default(), &cancel) + .prepare_inspection( + identity, + snapshot, + InspectionPreparationLimits::default(), + &cancel, + ) .await? } else { authority diff --git a/app/crowdb-access-server/src/dataset/api/v2/preview.rs b/app/crowdb-access-server/src/dataset/api/v2/preview.rs index 27cacc5b1..2f7823775 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/preview.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/preview.rs @@ -1,7 +1,7 @@ // Copyright 2026-present Gian // Licensed under the Apache License, Version 2.0. -use super::super::{batch::Lease, json, read_body, read_json, ApiError, ApiResult, DatasetHttpService}; +use super::super::{json, read_body, read_json, ApiError, ApiResult, DatasetHttpService}; use super::{ inspection_models::Sample, query::{bytes, Query}, @@ -73,7 +73,6 @@ impl DatasetHttpService { } read_body(request, false).await?; let authority = self.reads.authority(); - let _lease = Lease::acquire(authority.clone(), identity, snapshot, None).await?; let mut rows = authority .resolve_inspection_batch_locators( identity, @@ -120,14 +119,14 @@ impl DatasetHttpService { crowdb_access_dataset::DatasetUploads::upload_id(identity, &location) .map_err(|_| ApiError::unsupported())?; let uploads = self.uploads.as_ref().ok_or_else(ApiError::unsupported)?; - let metadata = uploads.metadata(&location).await?; - if metadata.length != length || metadata.md5 != md5 { + let metadata = uploads.open_read(&location).await?; + if metadata.length() != length || metadata.md5() != md5 { return Err(ApiError::integrity()); } let mut value = Vec::new(); let mut part = 0; while value.len() < limit && (value.len() as u64) < length { - let (bytes, _) = uploads.read_part_verified(&location, part).await?; + let (bytes, _) = metadata.read_part_verified(part).await?; let count = bytes.len().min(limit - value.len()); value.extend_from_slice(&bytes[..count]); part += 1; diff --git a/app/crowdb-access-server/src/dataset/api/v2/read_models.rs b/app/crowdb-access-server/src/dataset/api/v2/read_models.rs index 32b7a45aa..54115022d 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/read_models.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/read_models.rs @@ -2,21 +2,17 @@ // Licensed under the Apache License, Version 2.0. use super::{ - super::{operation, snapshot, ApiError}, + super::{snapshot, ApiError}, predicate::Predicate, split_models::{decimal, required_option, revision, Source}, }; -use crowdb_access_dataset::{ - split::SplitName, ReadPreparation, ReadSession, SessionPartition, SessionShuffle, SessionSpec, - SessionSpecV2, SessionSplit, -}; +use crowdb_access_dataset::{split::SplitName, ReadBinding, ReadPartition, ReadShuffle, ReadSpec, ReadSplit}; use serde::Deserialize; use serde_json::{json, Value}; #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct Plan { - pub id: String, pub snapshot: String, pub selection: Predicate, pub fields: Vec, @@ -26,7 +22,6 @@ pub(super) struct Plan { pub shuffle: Option, #[serde(deserialize_with = "required_split")] pub split: Option, - pub expected_page: String, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -56,55 +51,42 @@ fn required_split<'de, D: serde::Deserializer<'de>>(d: D) -> Result Result<(SessionSpecV2, u64), ApiError> { + pub(super) fn native(self) -> Result { if self.snapshot != "latest" && self.snapshot != "stable" { snapshot(&self.snapshot)?; } - let spec = SessionSpecV2 { - read: SessionSpec { - id: operation(&self.id)?, - snapshot: self.snapshot, - selection: self.selection.native()?, - fields: self.fields, - batch_size: self.batch_size, - partition: SessionPartition { - worker: self.partition.worker, - workers: self.partition.workers, - rank: self.partition.rank, - world_size: self.partition.world_size, - }, - shuffle: self - .shuffle - .map(|s| { - Ok::<_, ApiError>(SessionShuffle { - seed: decimal(&s.seed)?, - group_size: s.group_size, - }) - }) - .transpose()?, + Ok(ReadSpec { + snapshot: self.snapshot, + selection: self.selection.native()?, + fields: self.fields, + batch_size: self.batch_size, + partition: ReadPartition { + worker: self.partition.worker, + workers: self.partition.workers, + rank: self.partition.rank, + world_size: self.partition.world_size, }, + shuffle: self + .shuffle + .map(|s| { + Ok::<_, ApiError>(ReadShuffle { + seed: decimal(&s.seed)?, + group_size: s.group_size, + }) + }) + .transpose()?, split: self .split .map(|s| { - Ok::<_, ApiError>(SessionSplit { + Ok::<_, ApiError>(ReadSplit { revision: s.revision.as_deref().map(revision).transpose()?, name: SplitName::new(s.name).map_err(|_| ApiError::invalid())?, }) }) .transpose()?, - }; - Ok((spec, decimal(&self.expected_page)?)) + }) } } -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Acknowledge { - pub expected: String, - pub next: String, -} -pub(super) fn session(s: &ReadSession) -> Value { - json!({"id":s.id, "snapshot":s.snapshot, "plan":s.plan, "acknowledged":s.acknowledged, "expires_at":s.expires_at.to_string(), "closed":s.closed}) -} -pub(super) fn preparation(p: ReadPreparation) -> Value { - json!({"snapshot":p.snapshot.to_string(), "split":p.split.map(Source::from), "next_page":p.next_page.to_string(), "admitted":p.admitted.to_string(), "complete":p.complete, "session":p.session.as_ref().map(session)}) +pub(super) fn binding(value: ReadBinding) -> Value { + json!({"snapshot":value.snapshot.to_string(), "split":value.split.map(Source::from), "cursor":value.cursor}) } diff --git a/app/crowdb-access-server/src/dataset/api/v2/reads.rs b/app/crowdb-access-server/src/dataset/api/v2/reads.rs index f0df35d9b..b6327edd0 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/reads.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/reads.rs @@ -1,92 +1,31 @@ // Copyright 2026-present Gian // Licensed under the Apache License, Version 2.0. -use super::super::{ - batch::BatchRequest, json, operation, read_body, read_json, ApiError, ApiResult, DatasetHttpService, - RequestId, -}; +use super::super::{json, read_json, ApiError, ApiResult, DatasetHttpService}; use super::{query::Query, read_models as models}; -use crate::dataset::unix_seconds; -use base64::{engine::general_purpose::STANDARD, Engine}; -use crowdb_access_dataset::{DatasetIdentity, DatasetSessions, ReadCancellation}; +use crowdb_access_dataset::{DatasetIdentity, DatasetReads}; use hyper::{body::Incoming, Method, Request}; - impl DatasetHttpService { - pub(super) async fn v2_read_session( + pub(super) async fn v2_read_query( &self, identity: &DatasetIdentity, tail: &[&str], request: Request, ) -> ApiResult { Query::parse(request.uri().query(), &[])?; - let sessions = DatasetSessions::new(self.reads.authority()); - let now = unix_seconds(); - let cancel = ReadCancellation::new(); - if request.method() == Method::POST && tail == ["read-plans"] { - let input: models::Plan = read_json(request).await?; - let (spec, page) = input.native()?; - return json(&models::preparation( - sessions - .prepare_split_read(identity, &spec, page, now, &cancel) - .await?, - )); - } - let ["read-plans", id, rest @ ..] = tail else { + if request.method() != Method::POST { return Err(ApiError::not_found()); - }; - let id = operation(id)?; - if request.method() == Method::DELETE && rest.is_empty() { - read_body(request, false).await?; - return json(&models::session(&sessions.cancel_split_read(identity, id).await?)); } - let status = sessions.split_read_status(identity, id, now, &cancel).await?; - match (request.method().clone(), rest) { - (Method::GET, []) => { - read_body(request, false).await?; - json(&models::preparation(status)) - } - (Method::GET, ["progress"]) => { - read_body(request, false).await?; - let session = status.session.ok_or_else(ApiError::conflict)?; - json(&serde_json::json!({"plan":session.plan, "cursor":session.acknowledged})) - } - (Method::PUT, ["progress"]) => { - let ack: models::Acknowledge = read_json(request).await?; - if ack.expected.len() > 4096 || ack.next.len() > 4096 { - return Err(ApiError::too_large()); - } - let session = sessions - .acknowledge(identity, id, &ack.expected, &ack.next, now) - .await?; - json(&serde_json::json!({"plan":session.plan,"cursor":session.acknowledged})) - } - (Method::POST, ["next"]) => { - let request_id = request - .extensions() - .get::() - .ok_or_else(ApiError::integrity)? - .0 - .clone(); - read_body(request, false).await?; - if !status.complete { - return Err(ApiError::conflict()); - } - let batch = sessions.next(identity, id, now).await?; - self.batch( - identity, - BatchRequest { - snapshot: batch.snapshot.to_string(), - sample_ids: batch.ids.iter().map(|id| STANDARD.encode(id)).collect(), - fields: batch.fields, - sample_id_limit: Some(16 * 1024), - }, - Some(batch.next), - batch.end, - Some(id), - request_id, - ) - .await + match tail { + ["reads", "bind"] => { + let input: models::Plan = read_json(request).await?; + json(&models::binding( + DatasetReads::new(self.reads.authority()) + .bind(identity, input.native()?) + .await?, + )) } + ["reads", "next"] => self.query_next(identity, request).await, _ => Err(ApiError::not_found()), } } diff --git a/app/crowdb-access-server/src/dataset/api/v2/split_models.rs b/app/crowdb-access-server/src/dataset/api/v2/split_models.rs index c50b9580e..9667e852d 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/split_models.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/split_models.rs @@ -20,7 +20,9 @@ pub(super) fn decimal(value: &str) -> Result { } value.parse().map_err(|_| ApiError::invalid()) } -pub(super) fn required_option<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { +pub(super) fn required_option<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>( + d: D, +) -> Result, D::Error> { Option::deserialize(d) } diff --git a/app/crowdb-access-server/src/dataset/api/v2/split_preparation.rs b/app/crowdb-access-server/src/dataset/api/v2/split_preparation.rs new file mode 100644 index 000000000..809b1c2c2 --- /dev/null +++ b/app/crowdb-access-server/src/dataset/api/v2/split_preparation.rs @@ -0,0 +1,48 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::{ + super::{read_body_limit, snapshot}, + json, + split_models::{revision, DraftSplit, Policy}, + split_responses, ApiError, ApiResult, DatasetHttpService, +}; +use crowdb_access_dataset::{split::SplitPreparation, DatasetIdentity, ReadCancellation}; +use hyper::{body::Incoming, Request}; +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Prepare { + policy: Policy, + splits: Vec, +} +impl DatasetHttpService { + pub(super) async fn v2_prepare_split_revision( + &self, + identity: &DatasetIdentity, + owner: &str, + revision_id: &str, + request: Request, + ) -> ApiResult { + let snapshot = snapshot(owner)?; + let revision = revision(revision_id)?; + let body = read_body_limit(request, true, 1024 * 1024).await?; + let input: Prepare = serde_json::from_slice(&body).map_err(|_| ApiError::invalid())?; + let request = SplitPreparation { + revision, + policy: input.policy.into(), + splits: input + .splits + .into_iter() + .map(DraftSplit::native) + .collect::>()?, + }; + json(&split_responses::revision( + self.reads + .authority() + .prepare_split_revision(identity, snapshot, &request, &ReadCancellation::new()) + .await?, + )) + } +} diff --git a/app/crowdb-access-server/src/dataset/api/v2/split_requests.rs b/app/crowdb-access-server/src/dataset/api/v2/split_requests.rs index f909f0c95..249b5cfc4 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/split_requests.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/split_requests.rs @@ -1,36 +1,10 @@ // Copyright 2026-present Gian // Licensed under the Apache License, Version 2.0. -use super::{ - super::ApiError, - split_models::{required_option, DraftSplit, Policy}, -}; +use super::{super::ApiError, split_models::required_option}; use crowdb_access_dataset::CommitGeneration; use serde::Deserialize; -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Begin { - pub operation: String, - #[serde(deserialize_with = "required_option")] - pub expected_revision: Option, - pub policy: Policy, - pub splits: Vec, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct SplitEdit { - pub edit: String, - pub expected_revision: String, - pub policy: Policy, - pub splits: Vec, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Evaluate { - pub expected_revision: String, - pub expected_page: String, -} #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct Prepare { @@ -38,10 +12,11 @@ pub(super) struct Prepare { } #[derive(Deserialize)] #[serde(deny_unknown_fields)] -pub(super) struct Publish { - pub attempt: String, +pub(super) struct UpdateHead { pub revision: String, #[serde(deserialize_with = "required_option")] + pub expected_revision: Option, + #[serde(deserialize_with = "required_option")] pub expected_head: Option, } pub(super) fn generation(value: Option<&str>) -> Result, ApiError> { diff --git a/app/crowdb-access-server/src/dataset/api/v2/split_responses.rs b/app/crowdb-access-server/src/dataset/api/v2/split_responses.rs index 97f76d251..20224d8e9 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/split_responses.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/split_responses.rs @@ -56,6 +56,10 @@ pub(super) fn revision(r: split::SplitRevisionRecord) -> Value { }).collect(); json!({"snapshot":r.snapshot.to_string(), "revision":r.revision.to_string(), "operation":r.operation.to_string(), "evaluator_version":r.evaluator_version, "policy":Policy::from(r.policy), "splits":splits}) } +pub(super) fn deletion(value: &split::SplitRevisionDeletion) -> Value { + json!({"snapshot":value.snapshot.to_string(), "revision":value.revision.to_string(), + "reclaim_after":value.reclaim_after.map(|deadline|deadline.to_string()), "complete":value.complete}) +} pub(super) fn preview(p: split::SplitPreview) -> Value { let v = p.validation; json!({"input_revision":p.input_revision.to_string(), "next_page":p.next_page.to_string(), "complete":p.complete, "member_counts":p.member_counts.iter().map(ToString::to_string).collect::>(), "validation":{ @@ -65,6 +69,9 @@ pub(super) fn preview(p: split::SplitPreview) -> Value { pub(super) fn receipt(r: &SuccessfulCommit) -> Value { json!({"attempt":r.attempt.id.to_string(), "target":target(r.attempt.target), "expected_head":r.attempt.expected_head.map(|h|hex::encode(h.as_bytes())), "committed_head":hex::encode(r.committed_head.as_bytes())}) } +pub(super) fn head(snapshot: crowdb_access_dataset::SnapshotId, head: &split::SplitReference) -> Value { + json!({"snapshot":snapshot.to_string(),"revision":head.revision.map(|id|id.to_string()),"generation":head.generation.map(|g|hex::encode(g.as_bytes()))}) +} impl From for ApiError { fn from(error: SplitLifecycleError) -> Self { use hyper::StatusCode; @@ -74,6 +81,9 @@ impl From for ApiError { SplitLifecycleError::Inspection(error) => error.into(), SplitLifecycleError::NotFound => Self::not_found(), SplitLifecycleError::Conflict => Self::conflict(), + SplitLifecycleError::HeadConflict { snapshot, observed } => { + Self::conflict().with_observed_head(head(snapshot, &observed)) + } SplitLifecycleError::Corrupt => Self::integrity(), SplitLifecycleError::Limit | SplitLifecycleError::Split(split::SplitError::Limit) => { Self::new(StatusCode::PAYLOAD_TOO_LARGE, "read_limit") diff --git a/app/crowdb-access-server/src/dataset/api/v2/splits.rs b/app/crowdb-access-server/src/dataset/api/v2/splits.rs index 2e7bacc57..72b588759 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/splits.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/splits.rs @@ -7,14 +7,11 @@ use super::super::{ use super::{ inspection_models::Page, query::Query, - split_models::{decimal, revision, DraftSplit}, + split_models::{decimal, revision}, split_requests as input, split_responses as output, }; use base64::{engine::general_purpose::STANDARD, Engine}; -use crowdb_access_dataset::{ - split::{SplitDraftEdit, SplitName}, - CommitAttempt, CommitTarget, DatasetIdentity, ReadCancellation, -}; +use crowdb_access_dataset::{split::SplitName, DatasetIdentity, ReadCancellation}; use hyper::{body::Incoming, Method, Request}; impl DatasetHttpService { @@ -39,32 +36,20 @@ impl DatasetHttpService { )?; let authority = self.reads.authority(); match (request.method().clone(), tail) { - (Method::POST, ["snapshots", owner, "split-drafts"]) => { - let input: input::Begin = read_json(request).await?; - let splits = input - .splits - .into_iter() - .map(DraftSplit::native) - .collect::>()?; - let record = authority - .begin_split_draft( - identity, - operation(&input.operation)?, - snapshot(owner)?, - input.expected_revision.as_deref().map(revision).transpose()?, - input.policy.into(), - splits, - ) - .await?; - json(&output::draft(record)) - } + (Method::POST, ["snapshots", _, "split-drafts"]) => Err(ApiError::unsupported()), (_, ["split-drafts", ..]) => self.v2_split_draft(identity, tail, request).await, (Method::GET, ["snapshots", owner, "split-head"]) => { read_body(request, false).await?; - let head = authority.inspect_split_head(identity, snapshot(owner)?).await?; - json( - &serde_json::json!({"snapshot":owner, "revision":head.revision.map(|r|r.to_string()), "generation":head.generation.map(|h|hex::encode(h.as_bytes()))}), - ) + let snapshot = snapshot(owner)?; + let head = authority.inspect_split_head(identity, snapshot).await?; + json(&output::head(snapshot, &head)) + } + (Method::PUT, ["snapshots", owner, "split-head"]) => { + self.v2_update_split_head(identity, owner, request).await + } + (Method::PUT, ["snapshots", owner, "split-revisions", revision]) => { + self.v2_prepare_split_revision(identity, owner, revision, request) + .await } (Method::GET, ["snapshots", owner, "split-revisions"]) => { read_body(request, false).await?; @@ -96,6 +81,25 @@ impl DatasetHttpService { .await?, )) } + (Method::DELETE, ["snapshots", owner, "split-revisions", rev]) => { + if !read_body(request, false).await?.is_empty() { + return Err(ApiError::invalid()); + } + json(&output::deletion( + &authority + .delete_split_revision( + identity, + snapshot(owner)?, + revision(rev)?, + &ReadCancellation::new(), + ) + .await + .map_err(|error| match error { + crowdb_access_dataset::split::SplitLifecycleError::Frozen => ApiError::conflict(), + error => error.into(), + })?, + )) + } (Method::GET, ["snapshots", owner, "split-revisions", rev, "splits", name, "members"]) => { read_body(request, false).await?; let name = SplitName::new(*name).map_err(|_| ApiError::invalid())?; @@ -115,6 +119,27 @@ impl DatasetHttpService { _ => Err(ApiError::not_found()), } } + async fn v2_update_split_head( + &self, + identity: &DatasetIdentity, + owner: &str, + request: Request, + ) -> ApiResult { + let input: input::UpdateHead = read_json(request).await?; + let snapshot = snapshot(owner)?; + let update = crowdb_access_dataset::split::SplitHeadUpdate { + revision: revision(&input.revision)?, + expected_revision: input.expected_revision.as_deref().map(revision).transpose()?, + expected_head: input::generation(input.expected_head.as_deref())?, + }; + let head = self + .reads + .authority() + .update_split_head(identity, snapshot, &update) + .await?; + json(&output::head(snapshot, &head)) + } + async fn v2_split_draft( &self, identity: &DatasetIdentity, @@ -141,62 +166,7 @@ impl DatasetHttpService { .await?, )) } - (Method::PUT, []) => { - let input: input::SplitEdit = read_json(request).await?; - let edit = SplitDraftEdit { - id: super::super::operation(&input.edit)?, - expected_revision: decimal(&input.expected_revision)?, - policy: input.policy.into(), - splits: input - .splits - .into_iter() - .map(DraftSplit::native) - .collect::>()?, - }; - json(&output::draft( - authority.edit_split_draft(identity, operation, &edit).await?, - )) - } - (Method::POST, ["evaluate"]) => { - let input: input::Evaluate = read_json(request).await?; - json(&output::preview( - authority - .evaluate_split_draft( - identity, - operation, - decimal(&input.expected_revision)?, - decimal(&input.expected_page)?, - &ReadCancellation::new(), - ) - .await?, - )) - } - (Method::POST, ["prepare"]) => { - let input: input::Prepare = read_json(request).await?; - json(&output::revision( - authority - .prepare_split_draft(identity, operation, decimal(&input.expected_revision)?) - .await?, - )) - } - (Method::POST, ["publish"]) => { - let input: input::Publish = read_json(request).await?; - let draft = authority.split_draft_status(identity, operation).await?; - let attempt = CommitAttempt { - version: 1, - id: super::super::operation(&input.attempt)?, - target: CommitTarget::SplitRevision { - snapshot: draft.snapshot, - revision: revision(&input.revision)?, - }, - expected_head: input::generation(input.expected_head.as_deref())?, - }; - json(&output::receipt( - &authority - .publish_split_draft(identity, operation, &attempt) - .await?, - )) - } + (Method::PUT | Method::POST, _) => Err(ApiError::unsupported()), _ => Err(ApiError::not_found()), } } diff --git a/app/crowdb-access-server/src/dataset/api/v2/uploads.rs b/app/crowdb-access-server/src/dataset/api/v2/uploads.rs index 957244542..b75af7594 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/uploads.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/uploads.rs @@ -85,7 +85,7 @@ impl DatasetHttpService { } } } -fn digest(value: &str) -> Result<[u8; 16], ApiError> { +pub(super) fn digest(value: &str) -> Result<[u8; 16], ApiError> { if value.len() != 32 || !value .bytes() diff --git a/app/crowdb-access-server/src/dataset/legacy.rs b/app/crowdb-access-server/src/dataset/legacy.rs index 3fab49717..c31836801 100644 --- a/app/crowdb-access-server/src/dataset/legacy.rs +++ b/app/crowdb-access-server/src/dataset/legacy.rs @@ -4,8 +4,8 @@ //! Compatibility routes for the original bincode client. use super::{ - cancelled_status, decode_snapshot_request, encode, response, status_for_authority, unix_seconds, - DatasetHttpService, MAX_REQUEST_BYTES, + cancelled_status, decode_snapshot_request, encode, response, status_for_authority, DatasetHttpService, + MAX_REQUEST_BYTES, }; use crowdb_access_dataset::{ DatasetManifestResponse, DatasetPublishRequest, DatasetPublishResponse, DatasetReadRequest, @@ -29,17 +29,6 @@ impl DatasetHttpService { if request.method() == Method::POST && request.uri().path() == "/v1/dataset/scan" { return self.scan(request).await; } - if request.method() == Method::POST - && matches!( - request.uri().path(), - "/v1/dataset/start-read-plan" - | "/v1/dataset/next-batch" - | "/v1/dataset/save-progress" - | "/v1/dataset/resume" - ) - { - return self.scan(request).await; - } if request.method() == Method::POST && request.uri().path() == "/v1/dataset/create" { return self.create().await; } @@ -208,7 +197,7 @@ impl DatasetHttpService { match self .reads .authority() - .get_manifest(self.reads.identity(), request.snapshot) + .read_manifest(self.reads.identity(), request.snapshot) .await { Ok(manifest) => encode(StatusCode::OK, &DatasetManifestResponse { manifest }), @@ -244,17 +233,20 @@ impl DatasetHttpService { match self .reads .authority() - .reclaim_snapshot_metadata(self.reads.identity(), request.snapshot) + .mark_snapshot_reclaim(self.reads.identity(), request.snapshot) .await { - Ok(()) => encode( + Ok(progress) => encode( StatusCode::OK, &DatasetReclaimResponse { snapshot: request.snapshot, - reclaimed: true, + reclaimed: progress.complete, }, ), - Err(error) => response(status_for_authority(&error), error.to_string().into_bytes()), + Err(crowdb_access_dataset::SnapshotReclaimError::Authority(error)) => { + response(status_for_authority(&error), error.to_string().into_bytes()) + } + Err(error) => response(StatusCode::SERVICE_UNAVAILABLE, error.to_string().into_bytes()), } } @@ -295,7 +287,7 @@ impl DatasetHttpService { Ok(request) => request, Err(_) => return response(StatusCode::BAD_REQUEST, b"invalid dataset request".to_vec()), }; - match self.reads.read(request, unix_seconds()).await { + match self.reads.read(request).await { Ok(result) => encode(StatusCode::OK, &result), Err(ReadTransportError::WindowFull) => { response(StatusCode::TOO_MANY_REQUESTS, b"read window full".to_vec()) @@ -324,7 +316,7 @@ impl DatasetHttpService { Ok(request) => request, Err(_) => return response(StatusCode::BAD_REQUEST, b"invalid scan request".to_vec()), }; - match self.reads.scan(request, unix_seconds()).await { + match self.reads.scan(request).await { Ok(result) => encode(StatusCode::OK, &result), Err(ReadTransportError::WindowFull) => { response(StatusCode::TOO_MANY_REQUESTS, b"read window full".to_vec()) diff --git a/app/crowdb-access-server/src/iceberg.rs b/app/crowdb-access-server/src/iceberg.rs index 1a33f11cd..2a3a9c509 100644 --- a/app/crowdb-access-server/src/iceberg.rs +++ b/app/crowdb-access-server/src/iceberg.rs @@ -52,3 +52,9 @@ pub use runtime::{run, run_with_shutdown, IcebergRuntimeConfig}; #[cfg(feature = "test-util")] pub use connection::active_io_for_tests; +#[cfg(feature = "test-util")] +pub use file_http::mark_unreferenced_file_for_tests; +#[cfg(feature = "test-util")] +pub use file_http::write_iceberg_fallback_body_for_tests; +#[cfg(feature = "test-util")] +pub use gc_runtime::TestGcSweep; diff --git a/app/crowdb-access-server/src/iceberg/file_admission.rs b/app/crowdb-access-server/src/iceberg/file_admission.rs index 82413c3f7..1bc0c9a26 100644 --- a/app/crowdb-access-server/src/iceberg/file_admission.rs +++ b/app/crowdb-access-server/src/iceberg/file_admission.rs @@ -11,10 +11,6 @@ use super::file_body::{FileBodyError, FileReadBody, FileResponseBudget}; use super::file_request::{FileRequest, MultipartRequest}; use super::file_upload::{FileUploadBudget, FileUploadConstraints, FileUploadError}; -pub(super) const MULTIPART_COPY_BYTES: usize = 1024 * 1024 - / crowdb_access_iceberg::file::NATIVE_FILE_BLOCK_BYTES - * crowdb_access_iceberg::file::NATIVE_FILE_BLOCK_BYTES; - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct FileServiceLimits { pub max_request_bytes: u64, @@ -211,6 +207,10 @@ impl FileTransferAdmission { Ok(()) } + pub(super) fn copy_byte_limit(&self) -> u64 { + self.request_bytes.min(self.file_bytes) + } + pub(super) const fn request_byte_limit(&self) -> u64 { self.request_bytes } diff --git a/app/crowdb-access-server/src/iceberg/file_http.rs b/app/crowdb-access-server/src/iceberg/file_http.rs index d43c524e1..7f06007c7 100644 --- a/app/crowdb-access-server/src/iceberg/file_http.rs +++ b/app/crowdb-access-server/src/iceberg/file_http.rs @@ -9,7 +9,6 @@ use crowdb_access_iceberg::file::{ DirectFileLocations, FileBlockStore, FileGrantError, FileGrantIssuer, FileOperation, FileRecord, FileRepository, FileSealer, MultipartLister, MultipartPartStore, MultipartRepository, RangeError, }; -use crowdb_access_iceberg::gc::{GcRepository, GcStore}; use crowdb_access_iceberg::key::OperationId; use crowdb_access_iceberg::storage::{default_large_write, own_large_write}; use crowdb_access_s3::auth::{RawAuthRequest, StreamingPayloadVerifier}; @@ -30,12 +29,16 @@ use crate::upload_flow::body_encoding::UploadBody; mod bucket; mod delete; +#[cfg(feature = "test-util")] +pub use delete::mark_unreferenced_file_for_tests; mod injection; mod listing; mod metrics; mod multipart; mod read; mod stream; +#[cfg(feature = "test-util")] +pub use stream::write_iceberg_fallback_body_for_tests; use metrics::UploadFlowMetrics; pub use metrics::UploadFlowSnapshot; @@ -49,7 +52,6 @@ struct CatalogLocators { pub(super) struct FileHttp { repository: FileRepository, store: Arc, - gc: GcRepository, multipart: MultipartRepository, lister: MultipartLister, blocks: Arc, @@ -120,7 +122,7 @@ impl FileHttp { self.blocks.chunk_metrics() } - pub(super) fn new( + pub(super) fn new( store: Arc, blocks: Arc, secret: [u8; 32], @@ -144,7 +146,6 @@ impl FileHttp { .map_err(|_| FileGrantError::Invalid)?, repository: FileRepository::new(store.clone()), store: store.clone(), - gc: GcRepository::new(store.clone()), multipart: MultipartRepository::new(store.clone()), lister: MultipartLister::new(store), blocks, diff --git a/app/crowdb-access-server/src/iceberg/file_http/delete.rs b/app/crowdb-access-server/src/iceberg/file_http/delete.rs index 26bca9b71..080eb3caf 100644 --- a/app/crowdb-access-server/src/iceberg/file_http/delete.rs +++ b/app/crowdb-access-server/src/iceberg/file_http/delete.rs @@ -4,9 +4,9 @@ use std::sync::Arc; use base64::{engine::general_purpose::STANDARD, Engine}; use crowdb_access_iceberg::catalog::CatalogContext; use crowdb_access_iceberg::catalog::{CatalogLifecycle, CatalogRepository, RootState}; -use crowdb_access_iceberg::file::{FileLocation, FileOperation, FileReader, TableLocation}; +use crowdb_access_iceberg::file::{FileLocation, FileOperation, FileReader, FileRepository, TableLocation}; use crowdb_access_iceberg::gc::{ - avro_links, metadata_links, AvroMarkCursor, AvroMarkLimits, GcLimits, ReachableFile, ReachableKind, + avro_links, metadata_links, AvroMarkCursor, AvroMarkLimits, ReachableFile, ReachableKind, }; use crowdb_access_iceberg::record::StorageRecord; use crowdb_access_iceberg::table::storage_head_key; @@ -40,6 +40,22 @@ struct DeleteEntry { version_id: Option, } +/// Executes the file deletion path with a caller-supplied reference inventory. +/// +/// # Errors +/// Rejects retained paths, unavailable reference inventories and unconfirmed marks. +#[cfg(feature = "test-util")] +pub async fn mark_unreferenced_file_for_tests( + repository: &FileRepository, + context: CatalogContext, + location: &FileLocation, + now_ms: u64, + references: Option<&[FileLocation]>, +) -> Result<(), FileS3ErrorCode> { + let references = references.map(|locations| locations.iter().cloned().collect()); + FileHttp::delete_location(repository, context, location, now_ms, references.as_ref()).await +} + impl FileHttp { #[allow(clippy::too_many_lines)] pub(super) async fn delete_objects( @@ -126,9 +142,14 @@ impl FileHttp { let result = if entry.version_id.is_some() { Err(("InvalidArgument", "Versioned deletion is not supported")) } else if let Some(location) = location { - match self - .delete_location(root.context, &location, now, references.as_ref().ok()) - .await + match Self::delete_location( + &self.repository, + root.context, + &location, + now, + references.as_ref().ok(), + ) + .await { Ok(()) => Ok(()), Err(FileS3ErrorCode::Conflict) => { @@ -172,8 +193,7 @@ impl FileHttp { now_ms: u64, ) -> Result, FileS3ErrorCode> { let references = self.retained_references(context, location.table()).await?; - self.delete_location(context, location, now_ms, Some(&references)) - .await?; + Self::delete_location(&self.repository, context, location, now_ms, Some(&references)).await?; Response::builder() .status(StatusCode::NO_CONTENT) .body(IcebergBody::new(Vec::new())) @@ -181,41 +201,21 @@ impl FileHttp { } async fn delete_location( - &self, + repository: &FileRepository, context: CatalogContext, location: &FileLocation, now_ms: u64, references: Option<&HashSet>, ) -> Result<(), FileS3ErrorCode> { - if let Some(file) = self - .repository - .load(context, location) - .await - .map_err(catalog_error)? - { + if let Some(file) = repository.load(context, location).await.map_err(catalog_error)? { let references = references.ok_or(FileS3ErrorCode::SlowDown)?; if references.contains(location) { return Err(FileS3ErrorCode::Conflict); } - let deleted = self - .repository + repository .mark_deleted(context, &file, now_ms) .await .map_err(catalog_error)?; - self.gc - .claim_deleted_file(context, &deleted, now_ms, GcLimits::default()) - .await - .map_err(catalog_error)?; - } else if let Some(deleted) = self - .repository - .deleted(context, location) - .await - .map_err(catalog_error)? - { - self.gc - .claim_deleted_file(context, &deleted, now_ms, GcLimits::default()) - .await - .map_err(catalog_error)?; } Ok(()) } diff --git a/app/crowdb-access-server/src/iceberg/file_http/injection.rs b/app/crowdb-access-server/src/iceberg/file_http/injection.rs index 18b81f2d7..08f122ee2 100644 --- a/app/crowdb-access-server/src/iceberg/file_http/injection.rs +++ b/app/crowdb-access-server/src/iceberg/file_http/injection.rs @@ -1,5 +1,5 @@ use crowdb_access_iceberg::{ - file::{FileIdentity, NativeInjectionRepository, NativeInjectionRequest}, + file::{FileIdentity, FileRepository}, key::FileId, }; use hyper::{ @@ -9,8 +9,8 @@ use hyper::{ }; use super::{ - admission_error, catalog_error, etag, multipart, now_ms, set_header, FileHttp, FileRequest, - FileS3ErrorCode, FileTransferAdmission, IcebergBody, UploadBody, + admission_error, catalog_error, etag, multipart, set_header, FileHttp, FileRequest, FileS3ErrorCode, + FileTransferAdmission, IcebergBody, UploadBody, }; impl FileHttp { @@ -32,56 +32,43 @@ impl FileHttp { .and_then(|filename| filename.strip_suffix(".parquet")) .ok_or(FileS3ErrorCode::InvalidRequest)? .to_owned(); - let injection = NativeInjectionRequest { - context: admission.context(), + let identity = file_request.injection.ok_or(FileS3ErrorCode::InvalidRequest)?; + identity + .validate(current_ms) + .map_err(|_| FileS3ErrorCode::InvalidRequest)?; + crowdb_access_iceberg::file::DirectFileLocations::validate_stem(&stem) + .map_err(|_| FileS3ErrorCode::InvalidRequest)?; + let (parts, incoming) = request.into_parts(); + let mut body = UploadBody::new(incoming, &parts.headers, None, admission.request_byte_limit()) + .map_err(multipart::encoding_error)?; + let owner = FileIdentity { table: file_request.location.table(), - identity: file_request.injection.ok_or(FileS3ErrorCode::InvalidRequest)?, - principal: admission.principal(), - stem, - digest, + file: FileId::random(), }; - let repository = NativeInjectionRepository::new(self.store.clone()); - crowdb_access_iceberg::file::DirectFileLocations::validate_stem(&injection.stem) - .map_err(|_| FileS3ErrorCode::InvalidRequest)?; - let file = if let Some(file) = repository - .replay(&injection, current_ms) + let length = body.decoded_length(); + let tree = admission + .receive( + &self.uploads, + &mut body, + self.blocks.clone(), + owner, + length, + Some(digest), + ) .await - .map_err(catalog_error)? - { - file - } else { - let (parts, incoming) = request.into_parts(); - let mut body = UploadBody::new(incoming, &parts.headers, None, admission.request_byte_limit()) - .map_err(multipart::encoding_error)?; - let owner = FileIdentity { - table: injection.table, - file: FileId::random(), - }; - let length = body.decoded_length(); - let tree = admission - .receive( - &self.uploads, - &mut body, - self.blocks.clone(), - owner, - length, - Some(digest), - ) - .await - .map_err(|error| { - body.failure() - .map_or_else(|| admission_error(error), multipart::encoding_error) - })?; - crowdb_access_iceberg::file::FileSealer::new(self.blocks.clone(), self.limits.max_file_bytes) - .map_err(|_| FileS3ErrorCode::InternalError)? - .seal_uploaded(owner, file_request.location.clone(), tree.clone()) - .await - .map_err(multipart::seal_error)?; - repository - .finish(&injection, owner, tree, locations, now_ms()?) - .await - .map_err(catalog_error)? - }; + .map_err(|error| { + body.failure() + .map_or_else(|| admission_error(error), multipart::encoding_error) + })?; + crowdb_access_iceberg::file::FileSealer::new(self.blocks.clone(), self.limits.max_file_bytes) + .map_err(|_| FileS3ErrorCode::InternalError)? + .seal_uploaded(owner, file_request.location.clone(), tree.clone()) + .await + .map_err(multipart::seal_error)?; + let file = FileRepository::new(self.store.clone()) + .finalize_native_parquet(admission.context(), owner, &stem, tree, locations) + .await + .map_err(catalog_error)?; let bytes = serde_json::to_vec(&serde_json::json!({"location": file.location.to_string()})) .map_err(|_| FileS3ErrorCode::InternalError)?; let length = bytes.len(); diff --git a/app/crowdb-access-server/src/iceberg/file_http/multipart.rs b/app/crowdb-access-server/src/iceberg/file_http/multipart.rs index 97167376c..f3d752a37 100644 --- a/app/crowdb-access-server/src/iceberg/file_http/multipart.rs +++ b/app/crowdb-access-server/src/iceberg/file_http/multipart.rs @@ -3,8 +3,8 @@ use std::time::Instant; use crowdb_access_iceberg::catalog::CatalogContext; use crowdb_access_iceberg::file::{ - FileIdentity, FileOperation, FileSealError, FileSealer, MultipartPart, MultipartPhase, MultipartSession, - MultipartWorkError, + FileIdentity, FileOperation, FileSealError, FileSealer, MultipartMutation, MultipartPart, MultipartPhase, + MultipartSelection, MultipartSession, MultipartWorkError, }; use crowdb_access_iceberg::key::{FileId, OperationId}; use crowdb_access_s3::auth::StreamingPayloadVerifier; @@ -94,8 +94,13 @@ impl FileHttp { self.abort(session.ok_or(FileS3ErrorCode::NoSuchUpload)?).await } (Some(MultipartRequest::Complete { .. }), FileOperation::CompleteMultipart) => { - self.complete(session.ok_or(FileS3ErrorCode::NoSuchUpload)?, request, now_ms) - .await + self.complete( + session.ok_or(FileS3ErrorCode::NoSuchUpload)?, + request, + admission, + now_ms, + ) + .await } _ => Err(FileS3ErrorCode::InvalidRequest), } @@ -258,6 +263,7 @@ impl FileHttp { self: &Arc, mut session: MultipartSession, request: Request, + admission: &FileTransferAdmission, now_ms: u64, ) -> Result, FileS3ErrorCode> { let host = request @@ -284,23 +290,25 @@ impl FileHttp { crate::iceberg::CompleteResolveError::Catalog(error) => catalog_error(error), })?; if session.phase == MultipartPhase::Open { - if !self + match self .multipart .freeze_completion(&session, &selection, now_ms) .await .map_err(catalog_error)? { - return Err(FileS3ErrorCode::SlowDown); + MultipartMutation::Applied { session: next, .. } => session = next, + MultipartMutation::Conflict(_) => return Err(FileS3ErrorCode::SlowDown), } - session = self.current(&session).await?; } - let expected: [u8; 32] = Sha256::digest(selection.encode()).into(); + let max_copy_bytes = admission.copy_byte_limit(); let service = Arc::clone(self); let resource = session.location.object_key(); let body = crate::iceberg::FileCompleteBody::new( async move { let started = Instant::now(); - let result = service.drive_complete(session, expected, now_ms, &url).await; + let result = service + .drive_complete(session, &selection, max_copy_bytes, now_ms, &url) + .await; service.upload_metrics.multipart_complete(started.elapsed()); result.map(Response::into_body) }, @@ -320,15 +328,14 @@ impl FileHttp { async fn drive_complete( &self, mut session: MultipartSession, - expected: [u8; 32], + selection: &MultipartSelection, + max_copy_bytes: u64, now_ms: u64, url: &str, ) -> Result>, FileS3ErrorCode> { - if session - .completion - .as_ref() - .map_or(true, |completion| completion.selection.digest != expected) - { + if session.completion.as_ref().map_or(true, |completion| { + completion.selection.digest != <[u8; 32]>::from(Sha256::digest(selection.encode())) + }) { return Err(FileS3ErrorCode::InvalidPart); } loop { @@ -338,69 +345,63 @@ impl FileHttp { .completion .as_ref() .ok_or(FileS3ErrorCode::InternalError)?; - if completion.progress.next_part == 0 - && self + if completion.progress.next_part == 0 { + if let Some(update) = self .multipart - .prepare_stream_publication(&session, now_ms) + .prepare_stream_publication(&session, selection, now_ms) .await .map_err(catalog_error)? - .is_some() - { - session = self.current(&session).await?; - continue; + { + session = completion_state(update)?; + continue; + } } - if completion.progress.next_part < completion.selected_parts { - self.multipart - .advance_completion( - &session, - self.blocks.clone(), - crate::iceberg::file_admission::MULTIPART_COPY_BYTES, - crowdb_access_iceberg::file::NATIVE_FILE_BLOCK_BYTES, - ) - .await - .map_err(work_error)?; - } else { - let tree = self - .multipart - .assembled_tree( - &session, - self.blocks.clone(), - crowdb_access_iceberg::file::NATIVE_FILE_BLOCK_BYTES, - ) - .await - .map_err(work_error)?; - let sealed = FileSealer::new(self.blocks.clone(), self.limits.max_file_bytes) - .map_err(seal_error)? - .seal_uploaded(session.owner, session.location.clone(), tree.clone()) - .await - .map_err(seal_error)?; + let _permit = self.uploads.acquire().map_err(|_| FileS3ErrorCode::SlowDown)?; + let tree = self + .multipart + .assemble( + &session, + selection, + self.blocks.clone(), + max_copy_bytes, + crowdb_access_iceberg::file::NATIVE_FILE_BLOCK_BYTES, + ) + .await + .map_err(work_error)?; + let sealed = FileSealer::new(self.blocks.clone(), max_copy_bytes) + .map_err(seal_error)? + .seal_uploaded(session.owner, session.location.clone(), tree.clone()) + .await + .map_err(seal_error)?; + session = completion_state( self.multipart .prepare_publication(&session, &tree, &sealed, now_ms) .await - .map_err(catalog_error)?; - } + .map_err(catalog_error)?, + )?; } MultipartPhase::Publishing | MultipartPhase::Published => { - let Some(record) = self.multipart.publish(&session).await.map_err(catalog_error)? else { - session = self.current(&session).await?; - continue; - }; - session = self.current(&session).await?; - return MultipartResponses::complete(&session, &record, url) - .map_err(|_| FileS3ErrorCode::InternalError); + match self.multipart.publish(&session).await.map_err(catalog_error)? { + MultipartMutation::Applied { session, value } => { + return MultipartResponses::complete(&session, &value, url) + .map_err(|_| FileS3ErrorCode::InternalError); + } + MultipartMutation::Conflict(Some(observed)) => session = observed, + MultipartMutation::Conflict(None) => return Err(FileS3ErrorCode::NoSuchUpload), + } } _ => return Err(FileS3ErrorCode::Conflict), } - session = self.current(&session).await?; } } +} - async fn current(&self, session: &MultipartSession) -> Result { - self.multipart - .load(session.context, session.upload) - .await - .map_err(catalog_error)? - .ok_or(FileS3ErrorCode::NoSuchUpload) +fn completion_state(update: MultipartMutation<()>) -> Result { + match update { + MultipartMutation::Applied { session, .. } | MultipartMutation::Conflict(Some(session)) => { + Ok(session) + } + MultipartMutation::Conflict(None) => Err(FileS3ErrorCode::NoSuchUpload), } } @@ -426,7 +427,9 @@ async fn read_complete_body(mut body: Incoming) -> Result, FileS3ErrorCo fn work_error(error: MultipartWorkError) -> FileS3ErrorCode { match error { + MultipartWorkError::LegacyProgress => FileS3ErrorCode::RestartMultipart, MultipartWorkError::Catalog(error) => catalog_error(error), + MultipartWorkError::CopyLimit => FileS3ErrorCode::EntityTooLarge, MultipartWorkError::File(_) | MultipartWorkError::Invalid(_) => FileS3ErrorCode::InternalError, } } diff --git a/app/crowdb-access-server/src/iceberg/file_http/stream.rs b/app/crowdb-access-server/src/iceberg/file_http/stream.rs index 854368f5b..a4ab48a6d 100644 --- a/app/crowdb-access-server/src/iceberg/file_http/stream.rs +++ b/app/crowdb-access-server/src/iceberg/file_http/stream.rs @@ -15,7 +15,7 @@ use crowdb_access_s3::native_buffer::NativeBodyReceiver; use crowdb_chunk_client::{FramedWriteBuffer, LargeWritePolicy}; use crowdb_protocol::frame::{FrameMagic, MAX_FRAME_V2_PAYLOAD_BYTES}; use http_body_util::BodyExt; -use hyper::body::{Bytes, Incoming}; +use hyper::body::{Body, Bytes, Incoming}; use tokio::sync::mpsc; use super::metrics::{UploadFlowMetrics, UploadObservation}; @@ -127,6 +127,10 @@ pub(super) async fn upload( mod object; mod small_receive; +#[cfg(feature = "test-util")] +mod test_util; +#[cfg(feature = "test-util")] +pub use test_util::write_iceberg_fallback_body_for_tests; impl WriteObject<'_> { async fn run(mut self) -> Result { @@ -299,8 +303,8 @@ impl WriteObject<'_> { } #[allow(clippy::too_many_arguments, clippy::too_many_lines)] -async fn receive_body( - body: &mut UploadBody, +async fn receive_body + Unpin>( + body: &mut UploadBody, digest: &mut DigestPipe, admission: &FileTransferAdmission, native_receiver: Option<&NativeBodyReceiver>, @@ -311,11 +315,7 @@ async fn receive_body( observation: &mut UploadObservation, ) -> Result { let mut length = 0u64; - let mut pending = Vec::with_capacity(if native_receiver.is_some() { - 0 - } else { - target_buffer - }); + let mut pending = Vec::new(); let mut digest_pending = Vec::with_capacity(16); loop { let started = Instant::now(); @@ -368,6 +368,22 @@ async fn receive_body( } } else { while !bytes.is_empty() { + if pending.is_empty() && bytes.len() >= target_buffer { + let owner = bytes.split_to(target_buffer); + digest_pending.push(owner.clone()); + handoff( + digest, + &flow, + UploadBuffer::Data(owner), + std::mem::take(&mut digest_pending), + observation, + ) + .await?; + continue; + } + if pending.capacity() == 0 { + pending.reserve_exact(target_buffer); + } let count = (target_buffer - pending.len()).min(bytes.len()); let piece = bytes.split_to(count); pending.extend_from_slice(&piece); @@ -376,10 +392,7 @@ async fn receive_body( handoff( digest, &flow, - UploadBuffer::Data(Bytes::from(std::mem::replace( - &mut pending, - Vec::with_capacity(target_buffer), - ))), + UploadBuffer::Data(Bytes::from(std::mem::take(&mut pending))), std::mem::take(&mut digest_pending), observation, ) diff --git a/app/crowdb-access-server/src/iceberg/file_http/stream/test_util.rs b/app/crowdb-access-server/src/iceberg/file_http/stream/test_util.rs new file mode 100644 index 000000000..9429e1dc1 --- /dev/null +++ b/app/crowdb-access-server/src/iceberg/file_http/stream/test_util.rs @@ -0,0 +1,79 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::{ + drive_transfer, multipart, receive_body, write_buffers, DigestPipe, FileS3ErrorCode, + FileTransferAdmission, FrameMagic, UploadBody, UploadFlowMetrics, WriteFlow, TARGET_BUFFER_BYTES, +}; +use crate::iceberg::UploadFlowSnapshot; +use crowdb_chunk_client::ChunkIoWriter; +use hyper::body::{Body, Bytes}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; +use tokio::sync::mpsc; + +/// Drives the real bounded Iceberg fallback receive and write flow without a listener. +/// Storage completion and publication remain the caller's responsibility. +/// # Errors +/// Returns production admission, body, integrity, writer or flow failures. +pub async fn write_iceberg_fallback_body_for_tests + Unpin>( + body: &mut UploadBody, + writer: &mut impl ChunkIoWriter, + admission: &FileTransferAdmission, + held_buffers: usize, + expected_sha256: Option<[u8; 32]>, +) -> Result<(u64, [u8; 16], UploadFlowSnapshot), FileS3ErrorCode> { + if held_buffers == 0 { + return Err(FileS3ErrorCode::InvalidRequest); + } + body.defer_md5(); + let length = body.decoded_length(); + let target = usize::try_from(length.unwrap_or(TARGET_BUFFER_BYTES as u64)) + .unwrap_or(TARGET_BUFFER_BYTES) + .clamp(1, TARGET_BUFFER_BYTES); + let (sender, receiver) = mpsc::channel(held_buffers); + let progress = AtomicU64::new(0); + let peak = AtomicU64::new(0); + let metrics = Arc::new(UploadFlowMetrics::default()); + let mut observation = metrics.start(); + let mut digest = DigestPipe::start(expected_sha256.is_some()); + let receive = receive_body( + body, + &mut digest, + admission, + None, + length, + target, + FrameMagic::RepoLargeV2, + WriteFlow::new(sender, &progress, &peak), + &mut observation, + ); + let write = write_buffers(writer, receiver, &progress, |_| FileS3ErrorCode::SlowDown); + let result = drive_transfer(receive, write, &progress).await; + observation.queued_buffers_peak(peak.load(Ordering::Relaxed)); + let (length, written) = match result { + Ok(value) => value, + Err(error) => { + observation.complete(false); + let _ = digest.finish().await; + return Err(error); + } + }; + observation.writer_feeds(written.feeds, written.feed_time); + observation.writer_capacity_waits(written.capacity_waits, written.capacity_wait_time); + let digests = digest + .finish() + .await + .map_err(|()| FileS3ErrorCode::InternalError)?; + observation.digest_process(digests.process_time); + body.verify_deferred_md5(digests.md5) + .map_err(multipart::encoding_error)?; + if expected_sha256.is_some_and(|expected| digests.sha256 != Some(expected)) { + return Err(FileS3ErrorCode::InvalidRequest); + } + observation.complete(true); + drop(observation); + Ok((length, digests.md5, metrics.snapshot())) +} diff --git a/app/crowdb-access-server/src/iceberg/file_recovery.rs b/app/crowdb-access-server/src/iceberg/file_recovery.rs index fcbde06c0..38944776e 100644 --- a/app/crowdb-access-server/src/iceberg/file_recovery.rs +++ b/app/crowdb-access-server/src/iceberg/file_recovery.rs @@ -2,24 +2,18 @@ use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crowdb_access_iceberg::catalog::{CatalogError, CatalogRepository, RootState, RoutedCatalogStore}; -use crowdb_access_iceberg::file::{FileBlockStore, MultipartRecovery, NATIVE_FILE_BLOCK_BYTES}; +use crowdb_access_iceberg::file::MultipartRecovery; -pub(super) async fn run( - catalog: Arc, - store: Arc, - blocks: Arc, -) { +pub(super) async fn run(catalog: Arc, store: Arc) { let mut interval = tokio::time::interval(Duration::from_secs(1)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut context = None; let mut continuation = None; - let mut observation = None; loop { interval.tick().await; let status = tokio::time::timeout(Duration::from_secs(1), catalog.status()).await; let Ok(Ok((root, authority))) = status else { continuation = None; - observation = None; tracing::warn!( ?status, "multipart catalog status unavailable; deferring recovery" @@ -29,36 +23,17 @@ pub(super) async fn run( if root.state != RootState::Ready || context != Some(root.context) { context = Some(root.context); continuation = None; - observation = None; } if root.state != RootState::Ready { continue; } let budget = Duration::from_millis(authority.admission_bounds.request_ms).min(Duration::from_secs(60)); - let recovery = MultipartRecovery::new( - store.clone(), - blocks.clone(), - super::file_admission::MULTIPART_COPY_BYTES, - NATIVE_FILE_BLOCK_BYTES, - ) - .and_then(|recovery| recovery.with_session_timeout(budget)); + let recovery = MultipartRecovery::new(store.clone()).with_session_timeout(budget); let Ok(recovery) = recovery else { tracing::error!("multipart recovery bounds invalid; deferring page until catalog is corrected"); continue; }; - let Some(observed) = observation.take() else { - match tokio::time::timeout(budget, recovery.observe_page(root.context, continuation.clone())) - .await - { - Ok(Ok(observed)) => observation = Some(observed), - result => { - continuation = None; - tracing::warn!(?result, "multipart observation failed; restarting sweep"); - } - } - continue; - }; let Some(now_ms) = SystemTime::now() .duration_since(UNIX_EPOCH) .ok() @@ -68,8 +43,11 @@ pub(super) async fn run( continue; }; let page_budget = budget.saturating_mul(5).saturating_add(Duration::from_secs(2)); - let result = - tokio::time::timeout(page_budget, recovery.recover_observed_page(observed, now_ms)).await; + let result = tokio::time::timeout( + page_budget, + recovery.recover_page(root.context, continuation.clone(), now_ms), + ) + .await; match result { Ok(Ok(page)) => { continuation = page.continuation; diff --git a/app/crowdb-access-server/src/iceberg/file_response.rs b/app/crowdb-access-server/src/iceberg/file_response.rs index 77d7d344f..c4e38ee09 100644 --- a/app/crowdb-access-server/src/iceberg/file_response.rs +++ b/app/crowdb-access-server/src/iceberg/file_response.rs @@ -26,6 +26,7 @@ pub enum FileS3ErrorCode { InvalidPart, EntityTooLarge, InvalidRequest, + RestartMultipart, InternalError, NoSuchKey, InvalidRange, @@ -41,9 +42,11 @@ impl FileS3ErrorCode { match self { Self::AccessDenied => StatusCode::FORBIDDEN, Self::NoSuchUpload | Self::NoSuchKey => StatusCode::NOT_FOUND, - Self::InvalidPart | Self::InvalidRequest | Self::BadDigest | Self::EntityTooSmall => { - StatusCode::BAD_REQUEST - } + Self::InvalidPart + | Self::InvalidRequest + | Self::RestartMultipart + | Self::BadDigest + | Self::EntityTooSmall => StatusCode::BAD_REQUEST, Self::EntityTooLarge => StatusCode::PAYLOAD_TOO_LARGE, Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR, Self::InvalidRange => StatusCode::RANGE_NOT_SATISFIABLE, @@ -60,6 +63,9 @@ impl FileS3ErrorCode { Self::InvalidPart => "One or more parts are invalid", Self::EntityTooLarge => "The request exceeds the allowed size", Self::InvalidRequest => "The request is invalid", + Self::RestartMultipart => { + "Legacy byte progress cannot be resumed; abort and create a new multipart upload" + } Self::InternalError => "The service could not complete the request", Self::NoSuchKey => "The specified key does not exist", Self::InvalidRange => "The requested range cannot be satisfied", @@ -77,7 +83,7 @@ impl FileS3ErrorCode { Self::NoSuchUpload => "NoSuchUpload", Self::InvalidPart => "InvalidPart", Self::EntityTooLarge => "EntityTooLarge", - Self::InvalidRequest => "InvalidRequest", + Self::InvalidRequest | Self::RestartMultipart => "InvalidRequest", Self::InternalError => "InternalError", Self::NoSuchKey => "NoSuchKey", Self::InvalidRange => "InvalidRange", diff --git a/app/crowdb-access-server/src/iceberg/gc_runtime.rs b/app/crowdb-access-server/src/iceberg/gc_runtime.rs index 1c32bb617..e5833dd71 100644 --- a/app/crowdb-access-server/src/iceberg/gc_runtime.rs +++ b/app/crowdb-access-server/src/iceberg/gc_runtime.rs @@ -2,25 +2,23 @@ use std::{sync::Arc, time::Duration}; use crate::config::IcebergGcConfig; use crowdb_access_iceberg::{ - catalog::{CatalogContext, CatalogRepository, RootState, RoutedCatalogStore}, + catalog::{CatalogRepository, RootState, RoutedCatalogStore}, file::FileBlockStore, - gc::{ - BudgetedGcBlocks, BudgetedGcStore, GcIoBudget, GcLimits, GcPhase, GcRepository, GcScan, GcStore, - GcSystemScan, GcTaskKind, GcWorker, - }, - key::{CatalogId, CatalogScope, IcebergKey, SystemScope}, - operation::{ManagementAction, ManagementPhase}, - record::StorageRecord, + gc::{BudgetedGcBlocks, BudgetedGcStore, GcIoBudget, GcLimits, GcRepository, GcWorker}, + key::CatalogId, }; use crowdb_chunk_client::ChunkIoClient; -#[derive(Clone, Default)] -struct ScanPosition { - task: Vec, - purge: Vec, - system: Vec, - retired_turn: bool, -} +mod schedule; +mod sweep; +use schedule::Schedule; +use sweep::scan_and_advance; + +#[cfg(feature = "test-util")] +mod test_util; +use sweep::ScanPosition; +#[cfg(feature = "test-util")] +pub use test_util::TestGcSweep; pub(super) struct GcRuntimeConfig { pub limits: GcLimits, @@ -189,37 +187,36 @@ pub(super) async fn run( return; } }; - let mut interval = tokio::time::interval(Duration::from_millis(config.interval_ms)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut cursors = Vec::<(CatalogId, ScanPosition)>::new(); - let mut index = 0_usize; + let mut schedule = Schedule::default(); + let mut status_failures = 0_u32; loop { - interval.tick().await; + tokio::time::sleep_until(schedule.next_due()).await; let Ok(Ok((root, _))) = tokio::time::timeout( Duration::from_millis(config.interval_ms.min(60_000)), catalog.status(), ) .await else { + status_failures = status_failures.saturating_add(1); tracing::warn!("GC catalog status unavailable; retrying later"); + tokio::time::sleep(Duration::from_millis( + config.limits.retry_delay_ms(status_failures), + )) + .await; continue; }; + status_failures = 0; if root.state != RootState::Ready { + tokio::time::sleep(Duration::from_millis(config.interval_ms)).await; continue; } - let mut catalogs = config.catalogs.clone(); - if !catalogs.contains(&root.context.catalog) { - catalogs.push(root.context.catalog); - } - if catalogs.is_empty() { + schedule.retain_scopes(&config.catalogs, root.context.catalog); + let Some(slot) = schedule.ready() else { continue; - } - let selected = catalogs[index % catalogs.len()]; - index = index.wrapping_add(1); - let cursor = cursors.iter_mut().find(|(catalog, _)| *catalog == selected); - let after = cursor - .as_ref() - .map_or_else(ScanPosition::default, |(_, after)| after.clone()); + }; + let selected = schedule.catalog(slot); + let cursor = schedule.position(slot); + let before = cursor.progress; let result = tokio::time::timeout( Duration::from_millis(u64::from(config.limits.step_ms) * 2 + 1000), scan_and_advance( @@ -227,187 +224,21 @@ pub(super) async fn run( &worker, budget.as_ref(), selected, - after, + cursor, (selected == root.context.catalog).then_some(root.context), config.limits, ), ) .await; - match result { - Ok(Ok(next)) => { - if let Some((_, cursor)) = cursor { - *cursor = next; - } else { - cursors.push((selected, next)); - } - } - Ok(Err(error)) => { - tracing::error!(catalog = %selected, %error, "GC scheduler scan failed; retrying catalog"); - } - Err(_) => { - tracing::warn!(catalog = %selected, "GC scheduler scan exceeded its budget"); - } - } - } -} - -async fn scan_and_advance( - store: Arc, - worker: &GcWorker, - budget: &GcIoBudget, - catalog: CatalogId, - after: ScanPosition, - active: Option, - limits: GcLimits, -) -> Result> { - budget.reset(); - let next_purge = if let Some(context) = active { - scan_purge(store.clone(), context, after.purge, limits).await? - } else { - Vec::new() - }; - if let Some(context) = active { - GcRepository::new(store.clone()) - .admit_multipart(context, super::runtime::now_ms()?, limits) - .await?; - } - let (next_system, advanced_retired) = if active.is_some() && after.retired_turn { - scan_retired(store.clone(), worker, after.system, limits).await? - } else { - (after.system, false) - }; - let retired_turn = !after.retired_turn; - if advanced_retired { - return Ok(ScanPosition { - task: after.task, - purge: next_purge, - system: next_system, - retired_turn, - }); - } - let scan = GcScan { - native: false, - catalog, - scope: Some(CatalogScope::GcTask), - prefix: Vec::new(), - after: after.task, - items: usize::from(limits.page_items), - bytes: limits.page_bytes as usize, - }; - let page = store.scan_gc(scan.clone()).await?; - scan.validate_page(&page)?; - let mut next = Vec::new(); - for item in page.items { - next = item.key.clone(); - let key = IcebergKey::decode(&item.key)?; - let StorageRecord::GcTask(task) = StorageRecord::decode(&key, &item.value)? else { - return Err("GC task scan encountered a non-task record".into()); - }; - if task.kind == GcTaskKind::LiveTable && task.phase != GcPhase::Complete { - match GcRepository::new(store.clone()).retire_live(&task).await { - Ok(progress) => { - tracing::info!(catalog = %catalog, task = %task.identity, phase = ?progress.phase, "retired legacy live GC task"); - } - Err(error) => { - tracing::error!(catalog = %catalog, task = %task.identity, %error, "legacy live GC task still requires fence recovery"); - } - } - break; - } - if !matches!(task.phase, GcPhase::Complete | GcPhase::Quarantined) && !task.paused { - let now_ms = super::runtime::now_ms()?; - if now_ms >= task.retry_at_ms { - match worker.run(&task, now_ms).await { - Ok(progress) => { - tracing::debug!(catalog = %catalog, task = %task.identity, phase = ?progress.phase, "GC task advanced"); - } - Err(error) => { - tracing::error!(catalog = %catalog, task = %task.identity, %error, "GC task retained for retry"); - } - } - break; - } - } - } - Ok(ScanPosition { - task: next, - purge: next_purge, - system: next_system, - retired_turn, - }) -} - -async fn scan_retired( - store: Arc, - worker: &GcWorker, - after: Vec, - limits: GcLimits, -) -> Result<(Vec, bool), Box> { - let scan = GcSystemScan { - after, - items: 1, - bytes: limits.page_bytes as usize, - }; - let page = store.scan_gc_system(scan.clone()).await?; - scan.validate_page(&page)?; - let Some(item) = page.items.first() else { - return Ok((Vec::new(), false)); - }; - let mut advanced = false; - let key = IcebergKey::decode(&item.key)?; - if matches!( - key, - IcebergKey::System { - scope: SystemScope::ManagementOperation, - .. - } - ) { - let StorageRecord::Management(operation) = StorageRecord::decode(&key, &item.value)? else { - return Err("management scan encountered a non-management record".into()); + let error = if let Ok(error) = result { + error + } else { + tracing::warn!(catalog = %selected, "GC scheduler window exceeded its budget; observed local positions retained"); + Some("GC scheduler window timed out".into()) }; - if operation.request.action == ManagementAction::Clear && operation.phase == ManagementPhase::Complete - { - let task = GcRepository::new(store) - .admit_retired(&operation, super::runtime::now_ms()?, limits) - .await?; - if !matches!(task.phase, GcPhase::Complete | GcPhase::Quarantined) && !task.paused { - let now_ms = super::runtime::now_ms()?; - if now_ms >= task.retry_at_ms { - worker.run(&task, now_ms).await?; - advanced = true; - } - } - } + let report = schedule.position(slot).report(before, error.is_some()); + tracing::debug!(catalog = %selected, busy = report.busy, progressed = report.progressed, + failed = report.failed, retry_at_ms = report.retry_at_ms, "GC scheduler window completed"); + schedule.finish(slot, report, config.interval_ms, config.limits); } - Ok((item.key.clone(), advanced)) -} - -async fn scan_purge( - store: Arc, - context: CatalogContext, - after: Vec, - limits: GcLimits, -) -> Result, Box> { - let scan = GcScan { - native: false, - catalog: context.catalog, - scope: Some(CatalogScope::Reclamation), - prefix: Vec::new(), - after, - items: 1, - bytes: limits.page_bytes as usize, - }; - let page = store.scan_gc(scan.clone()).await?; - scan.validate_page(&page)?; - let Some(item) = page.items.first() else { - return Ok(Vec::new()); - }; - let key = IcebergKey::decode(&item.key)?; - let StorageRecord::TablePurgeTask(marker) = StorageRecord::decode(&key, &item.value)? else { - return Err("purge scan encountered a non-purge record".into()); - }; - GcRepository::new(store) - .admit_purge(context, &marker, super::runtime::now_ms()?, limits) - .await?; - Ok(item.key.clone()) } diff --git a/app/crowdb-access-server/src/iceberg/gc_runtime/schedule.rs b/app/crowdb-access-server/src/iceberg/gc_runtime/schedule.rs new file mode 100644 index 000000000..1aef73723 --- /dev/null +++ b/app/crowdb-access-server/src/iceberg/gc_runtime/schedule.rs @@ -0,0 +1,96 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::{sweep::WindowReport, CatalogId, GcLimits, ScanPosition}; +use std::time::Duration; +use tokio::time::Instant; + +struct Scope { + catalog: CatalogId, + cursor: ScanPosition, + due: Instant, + failures: u32, +} + +#[derive(Default)] +pub(super) struct Schedule { + scopes: Vec, + next: usize, +} + +impl Schedule { + pub fn retain_scopes(&mut self, configured: &[CatalogId], active: CatalogId) { + self.scopes + .retain(|scope| scope.catalog == active || configured.contains(&scope.catalog)); + for catalog in configured.iter().copied().chain(std::iter::once(active)) { + if !self.scopes.iter().any(|scope| scope.catalog == catalog) { + self.scopes.push(Scope { + catalog, + cursor: ScanPosition::default(), + due: Instant::now(), + failures: 0, + }); + } + } + } + + pub fn next_due(&self) -> Instant { + self.scopes + .iter() + .map(|scope| scope.due) + .min() + .unwrap_or_else(Instant::now) + } + + pub fn ready(&mut self) -> Option { + let now = Instant::now(); + for offset in 0..self.scopes.len() { + let slot = self.next.wrapping_add(offset) % self.scopes.len(); + if self.scopes[slot].due <= now { + self.next = slot + 1; + return Some(slot); + } + } + None + } + + pub fn catalog(&self, slot: usize) -> CatalogId { + self.scopes[slot].catalog + } + + pub fn position(&mut self, slot: usize) -> &mut ScanPosition { + &mut self.scopes[slot].cursor + } + + pub fn finish(&mut self, slot: usize, report: WindowReport, idle_ms: u64, limits: GcLimits) { + let scope = &mut self.scopes[slot]; + let now_ms = super::super::runtime::now_ms().unwrap_or(0); + let delay = next_delay(report, &mut scope.failures, idle_ms, limits, now_ms); + scope.due = Instant::now() + Duration::from_millis(delay); + } + + #[cfg(feature = "test-util")] + pub fn len(&self) -> usize { + self.scopes.len() + } +} + +pub(super) fn next_delay( + report: WindowReport, + failures: &mut u32, + idle_ms: u64, + limits: GcLimits, + now_ms: u64, +) -> u64 { + if report.failed { + *failures = failures.saturating_add(1); + return limits.retry_delay_ms(*failures).max(100); + } + *failures = 0; + if report.busy { + return u64::from(limits.retry_base_ms).max(100).min(idle_ms); + } + report + .retry_at_ms + .map_or(idle_ms, |at| idle_ms.min(at.saturating_sub(now_ms).max(100))) +} diff --git a/app/crowdb-access-server/src/iceberg/gc_runtime/sweep.rs b/app/crowdb-access-server/src/iceberg/gc_runtime/sweep.rs new file mode 100644 index 000000000..c3b1173c3 --- /dev/null +++ b/app/crowdb-access-server/src/iceberg/gc_runtime/sweep.rs @@ -0,0 +1,217 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use crowdb_access_iceberg::{ + catalog::CatalogContext, + gc::{BudgetedGcStore, GcIoBudget, GcLimits, GcPhase, GcRepository, GcTask, GcWorker}, + key::CatalogId, +}; +use std::sync::Arc; + +mod lanes; + +pub(super) type ScanError = Box; +type ScanResult = Result<(), ScanError>; + +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct WindowReport { + pub busy: bool, + pub progressed: bool, + pub retry_at_ms: Option, + pub failed: bool, +} + +#[derive(Clone, Default)] +pub(super) struct ScanPosition { + pub task: Vec, + pub purge: Vec, + pub system: Vec, + pub progress: u64, + next_lane: u8, + // A lane closes only after a validated empty page. Repeats belong to this + // discovery round, so terminal rows never start another round by themselves. + pending: u8, + repeat: u8, + retry_at_ms: Option, + failed_lanes: u8, + pass_failed: u8, + // Dropping a window retains its source lane for a fair, discardable retry. + in_flight: Option, +} + +impl ScanPosition { + fn begin(&mut self, active: bool) { + if let Some(lane) = self.in_flight.take() { + self.record_error(lane); + } + if self.pending == 0 { + self.pending = if active { 15 } else { 8 }; + self.repeat = 0; + self.retry_at_ms = None; + self.failed_lanes = 0; + self.pass_failed = 0; + } else if !active { + self.pending &= 8; + self.repeat &= 8; + self.failed_lanes &= 8; + self.pass_failed &= 8; + } + } + + pub fn report(&mut self, before: u64, failed: bool) -> WindowReport { + if failed { + if let Some(lane) = self.in_flight.take() { + self.record_error(lane); + } + } + WindowReport { + busy: self.pending != 0, + progressed: self.progress != before, + retry_at_ms: self.retry_at_ms, + failed: failed || self.failed_lanes != 0, + } + } + + fn after(&mut self, lane: u8) -> &mut Vec { + match lane { + 0 => &mut self.purge, + 2 => &mut self.system, + _ => &mut self.task, + } + } + + fn observed(&mut self, lane: u8, key: &[u8]) { + if self.after(lane).as_slice() != key { + self.progress = self.progress.wrapping_add(1); + self.after(lane).clear(); + self.after(lane).extend_from_slice(key); + } + } + + fn exhausted(&mut self, lane: u8) { + self.after(lane).clear(); + // An empty page after a failed row is not a successful verification pass. + if self.pass_failed & (1 << lane) == 0 { + self.failed_lanes &= !(1 << lane); + } + self.pass_failed &= !(1 << lane); + if self.repeat & (1 << lane) == 0 { + self.pending &= !(1 << lane); + } + self.repeat &= !(1 << lane); + self.progress = self.progress.wrapping_add(1); + } + + fn queued(&mut self, lane: u8) { + if self.pending & (1 << lane) != 0 { + self.repeat |= 1 << lane; + } + self.pending |= 1 << lane; + } + + fn record_error(&mut self, lane: u8) { + self.failed_lanes |= 1 << lane; + self.pass_failed |= 1 << lane; + self.queued(lane); + } + + fn runnable(&mut self, task: &GcTask, now_ms: u64) -> bool { + if matches!(task.phase, GcPhase::Complete | GcPhase::Quarantined) || task.paused { + return false; + } + if now_ms < task.retry_at_ms { + self.retry_at_ms = Some( + self.retry_at_ms + .map_or(task.retry_at_ms, |at| at.min(task.retry_at_ms)), + ); + return false; + } + true + } + + fn advanced(&mut self, lane: u8, before: &GcTask, next: &GcTask, now_ms: u64) { + if next != before { + self.progress = self.progress.wrapping_add(1); + if self.runnable(next, now_ms) { + self.queued(lane); + } + } + } +} + +pub(super) async fn scan_and_advance( + store: Arc, + worker: &GcWorker, + budget: &GcIoBudget, + catalog: CatalogId, + cursor: &mut ScanPosition, + active: Option, + limits: GcLimits, +) -> Option { + budget.reset(); + cursor.begin(active.is_some()); + let first = cursor.next_lane; + cursor.next_lane = (first + 1) % 4; + let mut attempted = false; + let mut first_error = None; + for offset in 0..4 { + let lane = (first + offset) % 4; + if cursor.pending & (1 << lane) == 0 { + continue; + } + cursor.in_flight = Some(lane); + let result = match (lane, active) { + (0, Some(context)) => lanes::scan_purge(store.clone(), context, cursor, limits).await, + (1, Some(context)) => admit_multipart(store.clone(), context, cursor, limits).await, + (2, Some(_)) if !attempted => { + lanes::scan_retired(store.clone(), worker, cursor, limits, &mut attempted).await + } + (3, _) if !attempted => { + lanes::scan_tasks(store.clone(), worker, catalog, cursor, limits, &mut attempted).await + } + _ => Ok(()), + }; + cursor.in_flight = None; + if let Err(error) = result { + cursor.record_error(lane); + tracing::error!(catalog = %catalog, lane = lane_name(lane), %error, + "GC discovery lane failed; observed local positions and durable facts retained"); + if first_error.is_none() { + first_error = Some(error); + } + } + } + first_error +} + +async fn admit_multipart( + store: Arc, + context: CatalogContext, + cursor: &mut ScanPosition, + limits: GcLimits, +) -> ScanResult { + let now_ms = super::super::runtime::now_ms()?; + let task = GcRepository::new(store) + .admit_multipart(context, now_ms, limits) + .await?; + // Admission reopens Complete tasks; run it once per periodic round, never + // once per short backlog window. + cursor.pending &= !2; + cursor.repeat &= !2; + cursor.failed_lanes &= !2; + cursor.pass_failed &= !2; + cursor.progress = cursor.progress.wrapping_add(1); + if cursor.runnable(&task, now_ms) { + cursor.queued(3); + } + Ok(()) +} + +fn lane_name(lane: u8) -> &'static str { + match lane { + 0 => "purge", + 1 => "multipart", + 2 => "retired", + _ => "tasks", + } +} diff --git a/app/crowdb-access-server/src/iceberg/gc_runtime/sweep/lanes.rs b/app/crowdb-access-server/src/iceberg/gc_runtime/sweep/lanes.rs new file mode 100644 index 000000000..2ab6c21f0 --- /dev/null +++ b/app/crowdb-access-server/src/iceberg/gc_runtime/sweep/lanes.rs @@ -0,0 +1,178 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::{ScanPosition, ScanResult}; +use crowdb_access_iceberg::{ + catalog::CatalogContext, + gc::{ + BudgetedGcStore, GcLimits, GcLocalScan, GcPhase, GcRepository, GcScan, GcStore, GcSystemScan, + GcTaskKind, GcWorker, PurgeAdmission, + }, + key::{CatalogId, CatalogScope, IcebergKey, SystemScope}, + operation::{ManagementAction, ManagementPhase}, + record::StorageRecord, +}; +use std::sync::Arc; + +pub(super) async fn scan_tasks( + store: Arc, + worker: &GcWorker, + catalog: CatalogId, + cursor: &mut ScanPosition, + limits: GcLimits, + attempted: &mut bool, +) -> ScanResult { + let scan = GcLocalScan::catalog( + &GcScan { + native: false, + catalog, + scope: Some(CatalogScope::GcTask), + prefix: Vec::new(), + after: Vec::new(), + items: usize::from(limits.page_items), + bytes: limits.page_bytes as usize, + }, + cursor.task.clone(), + )?; + let page = store.scan_gc_local(scan.clone()).await?; + scan.validate_page(&page)?; + if page.items.is_empty() { + cursor.exhausted(3); + } + let mut first_error = None; + for item in page.items { + cursor.observed(3, &item.key); + if let Err(error) = + advance_task(store.clone(), worker, &item.key, &item.value, cursor, attempted).await + { + tracing::error!(catalog = %catalog, raw_key_bytes = item.key.len(), %error, + "GC task row retained; local discovery continues after observed key"); + if first_error.is_none() { + first_error = Some(error); + } + } + if *attempted { + break; + } + } + first_error.map_or(Ok(()), Err) +} + +async fn advance_task( + store: Arc, + worker: &GcWorker, + key: &[u8], + bytes: &[u8], + cursor: &mut ScanPosition, + attempted: &mut bool, +) -> ScanResult { + let key = IcebergKey::decode(key)?; + let StorageRecord::GcTask(task) = StorageRecord::decode(&key, bytes)? else { + return Err("GC task scan encountered a non-task record".into()); + }; + let now_ms = super::super::super::runtime::now_ms()?; + if task.kind == GcTaskKind::LiveTable && task.phase != GcPhase::Complete { + *attempted = true; + let progress = GcRepository::new(store).retire_live(&task).await?; + cursor.advanced(3, &task, &progress, now_ms); + tracing::info!(catalog = %task.context.catalog, task = %task.identity, phase = ?progress.phase, + "retired legacy live GC task"); + } else if cursor.runnable(&task, now_ms) { + *attempted = true; + let progress = worker.run(&task, now_ms).await?; + cursor.advanced(3, &task, &progress, now_ms); + tracing::debug!(catalog = %task.context.catalog, task = %task.identity, phase = ?progress.phase, + "GC task advanced"); + } + Ok(()) +} + +pub(super) async fn scan_retired( + store: Arc, + worker: &GcWorker, + cursor: &mut ScanPosition, + limits: GcLimits, + attempted: &mut bool, +) -> ScanResult { + let scan = GcLocalScan::system( + &GcSystemScan { + after: Vec::new(), + items: 1, + bytes: limits.page_bytes as usize, + }, + cursor.system.clone(), + )?; + let page = store.scan_gc_local(scan.clone()).await?; + scan.validate_page(&page)?; + let Some(item) = page.items.first() else { + cursor.exhausted(2); + return Ok(()); + }; + cursor.observed(2, &item.key); + let key = IcebergKey::decode(&item.key)?; + if !matches!( + key, + IcebergKey::System { + scope: SystemScope::ManagementOperation, + .. + } + ) { + return Ok(()); + } + let StorageRecord::Management(operation) = StorageRecord::decode(&key, &item.value)? else { + return Err("management scan encountered a non-management record".into()); + }; + if operation.request.action == ManagementAction::Clear && operation.phase == ManagementPhase::Complete { + let now_ms = super::super::super::runtime::now_ms()?; + let task = GcRepository::new(store) + .admit_retired(&operation, now_ms, limits) + .await?; + if cursor.runnable(&task, now_ms) { + *attempted = true; + let progress = worker.run(&task, now_ms).await?; + cursor.advanced(2, &task, &progress, now_ms); + } + } + Ok(()) +} + +pub(super) async fn scan_purge( + store: Arc, + context: CatalogContext, + cursor: &mut ScanPosition, + limits: GcLimits, +) -> ScanResult { + let scan = GcLocalScan::catalog( + &GcScan { + native: false, + catalog: context.catalog, + scope: Some(CatalogScope::Reclamation), + prefix: Vec::new(), + after: Vec::new(), + items: 1, + bytes: limits.page_bytes as usize, + }, + cursor.purge.clone(), + )?; + let page = store.scan_gc_local(scan.clone()).await?; + scan.validate_page(&page)?; + let Some(item) = page.items.first() else { + cursor.exhausted(0); + return Ok(()); + }; + cursor.observed(0, &item.key); + let key = IcebergKey::decode(&item.key)?; + let StorageRecord::TablePurgeTask(marker) = StorageRecord::decode(&key, &item.value)? else { + return Err("purge scan encountered a non-purge record".into()); + }; + let now_ms = super::super::super::runtime::now_ms()?; + if let PurgeAdmission::Ready(task) = GcRepository::new(store) + .discover_purge(context, &marker, now_ms, limits) + .await? + { + if cursor.runnable(&task, now_ms) { + cursor.queued(3); + } + } + Ok(()) +} diff --git a/app/crowdb-access-server/src/iceberg/gc_runtime/test_util.rs b/app/crowdb-access-server/src/iceberg/gc_runtime/test_util.rs new file mode 100644 index 000000000..d3fb8f371 --- /dev/null +++ b/app/crowdb-access-server/src/iceberg/gc_runtime/test_util.rs @@ -0,0 +1,177 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::{scan_and_advance, BudgetedGcStore, CatalogId, GcIoBudget, GcLimits, GcWorker, ScanPosition}; +use super::{ + schedule::{next_delay, Schedule}, + sweep::{ScanError, WindowReport}, +}; +use crowdb_access_iceberg::{ + catalog::CatalogContext, + file::FileBlockStore, + gc::{BudgetedGcBlocks, GcRepository, GcStore}, +}; +use std::{sync::Arc, time::Duration}; + +pub struct TestGcSweep { + store: Arc, + worker: GcWorker, + budget: Arc, + catalog: CatalogId, + active: Option, + limits: GcLimits, + cursor: ScanPosition, + failures: u32, +} + +#[derive(Clone, Copy, Debug)] +pub struct TestGcWindow { + pub busy: bool, + pub progressed: bool, + pub failed: bool, + pub retry_at_ms: Option, + pub delay_ms: u64, +} + +pub struct TestGcSchedule(Schedule); + +impl TestGcSweep { + /// Creates the production scheduler with in-process storage boundaries. + /// # Errors + /// Rejects invalid worker limits. + pub fn new( + store: Arc, + blocks: Arc, + catalog: CatalogId, + active: Option, + limits: GcLimits, + ) -> Result { + let budget = Arc::new(GcIoBudget::new(64 * 1024 * 1024, 128, 8 * 1024 * 1024, 128)); + let store = Arc::new(BudgetedGcStore::new(store, budget.clone())); + let blocks = Arc::new(BudgetedGcBlocks::new(blocks, budget.clone())); + let worker = GcWorker::new(GcRepository::new(store.clone()), blocks, limits)?; + Ok(Self { + store, + worker, + budget, + catalog, + active, + limits, + cursor: ScanPosition::default(), + failures: 0, + }) + } + + /// Returns a discardable discovery position for purge, retired ledger or tasks. + #[must_use] + pub fn position_for_tests(&self, lane: u8) -> Vec { + match lane { + 0 => self.cursor.purge.clone(), + 2 => self.cursor.system.clone(), + _ => self.cursor.task.clone(), + } + } + + /// Advances one production scheduler window with the production deadline. + /// # Errors + /// Returns the original scan or action error, retaining durable resource facts. + pub async fn advance_for_tests(&mut self) -> Result<(), Box> { + let (_, error) = self.window().await; + error.map_or(Ok(()), Err) + } + + /// Returns the real sweep's scheduling decision without hiding failed lanes. + pub async fn window_for_tests(&mut self, idle_ms: u64) -> TestGcWindow { + let (report, _) = self.window().await; + let delay_ms = next_delay( + report, + &mut self.failures, + idle_ms, + self.limits, + super::super::runtime::now_ms().unwrap_or(0), + ); + TestGcWindow { + busy: report.busy, + progressed: report.progressed, + failed: report.failed, + retry_at_ms: report.retry_at_ms, + delay_ms, + } + } + + /// Creates the production catalog scheduler without starting a background task. + #[must_use] + pub fn schedule_for_tests(configured: &[CatalogId], active: CatalogId) -> TestGcSchedule { + let mut schedule = Schedule::default(); + schedule.retain_scopes(configured, active); + TestGcSchedule(schedule) + } + + async fn window(&mut self) -> (WindowReport, Option) { + let before = self.cursor.progress; + let error = match tokio::time::timeout( + Duration::from_millis(u64::from(self.limits.step_ms) * 2 + 1000), + scan_and_advance( + self.store.clone(), + &self.worker, + self.budget.as_ref(), + self.catalog, + &mut self.cursor, + self.active, + self.limits, + ), + ) + .await + { + Ok(error) => error, + Err(error) => Some(Box::new(error) as ScanError), + }; + (self.cursor.report(before, error.is_some()), error) + } +} + +impl TestGcSchedule { + pub fn retain_scopes_for_tests(&mut self, configured: &[CatalogId], active: CatalogId) { + self.0.retain_scopes(configured, active); + } + + #[must_use] + pub fn scopes_for_tests(&self) -> usize { + self.0.len() + } + + pub fn ready_for_tests(&mut self) -> Option { + self.0.ready().map(|slot| self.0.catalog(slot)) + } + + /// Applies a production scheduling result to a configured scope. + /// # Panics + /// Panics when the catalog is outside the configured and active scopes. + pub fn finish_for_tests(&mut self, catalog: CatalogId, window: TestGcWindow, idle_ms: u64) { + let slot = (0..self.0.len()) + .find(|slot| self.0.catalog(*slot) == catalog) + .unwrap(); + self.0.finish( + slot, + WindowReport { + busy: window.busy, + progressed: window.progressed, + failed: window.failed, + retry_at_ms: window.retry_at_ms, + }, + idle_ms, + GcLimits::default(), + ); + } + + #[must_use] + pub fn wait_ms_for_tests(&self) -> u64 { + u64::try_from( + self.0 + .next_due() + .saturating_duration_since(tokio::time::Instant::now()) + .as_millis(), + ) + .unwrap_or(u64::MAX) + } +} diff --git a/app/crowdb-access-server/src/iceberg/http.rs b/app/crowdb-access-server/src/iceberg/http.rs index d855dcd58..f58ecfb79 100644 --- a/app/crowdb-access-server/src/iceberg/http.rs +++ b/app/crowdb-access-server/src/iceberg/http.rs @@ -387,9 +387,6 @@ impl IcebergHttpService { let warehouse = warehouse(query)?; let mut config = CatalogConfig::for_capabilities(warehouse.as_deref(), capabilities)?; config.endpoints = Route::endpoints(&self.installed_routes(), capabilities); - if self.namespaces.is_some() { - config.idempotency_key_lifetime = Some("PT24H".into()); - } Ok(response( 200, serde_json::to_vec(&config).map_err(|_| service_unavailable())?, diff --git a/app/crowdb-access-server/src/iceberg/metrics.rs b/app/crowdb-access-server/src/iceberg/metrics.rs index a863d90db..10188c09e 100644 --- a/app/crowdb-access-server/src/iceberg/metrics.rs +++ b/app/crowdb-access-server/src/iceberg/metrics.rs @@ -47,9 +47,6 @@ pub struct MetricCounts { #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct IcebergMetricsSnapshot { pub routes: [[MetricCounts; OUTCOME_COUNT]; ROUTE_COUNT], - pub retry_new: u64, - pub retry_resume: u64, - pub retry_replay: u64, pub selected_versions: [u64; 3], pub chunk_read: Option, pub chunk_small_write: Option, @@ -104,7 +101,6 @@ impl Counters { pub(super) struct IcebergMetrics { routes: [[Counters; OUTCOME_COUNT]; ROUTE_COUNT], - retry: [AtomicU64; 3], selected_versions: [AtomicU64; 3], } @@ -112,7 +108,6 @@ impl Default for IcebergMetrics { fn default() -> Self { Self { routes: array::from_fn(|_| array::from_fn(|_| Counters::new())), - retry: array::from_fn(|_| AtomicU64::new(0)), selected_versions: array::from_fn(|_| AtomicU64::new(0)), } } @@ -122,9 +117,6 @@ impl IcebergMetrics { pub(super) fn snapshot(&self) -> IcebergMetricsSnapshot { IcebergMetricsSnapshot { routes: array::from_fn(|route| array::from_fn(|outcome| self.routes[route][outcome].snapshot())), - retry_new: self.retry[0].load(Ordering::Relaxed), - retry_resume: self.retry[1].load(Ordering::Relaxed), - retry_replay: self.retry[2].load(Ordering::Relaxed), selected_versions: array::from_fn(|index| self.selected_versions[index].load(Ordering::Relaxed)), chunk_read: None, chunk_small_write: None, @@ -143,7 +135,6 @@ pub(super) struct RequestObservation { response_bytes: AtomicU64, dispatch_ns: AtomicU64, status: AtomicU16, - retry: AtomicU8, version: AtomicU8, catalog: CatalogStoreOperationMeter, } @@ -158,7 +149,6 @@ impl RequestObservation { response_bytes: AtomicU64::new(0), dispatch_ns: AtomicU64::new(0), status: AtomicU16::new(0), - retry: AtomicU8::new(0), version: AtomicU8::new(0), catalog: CatalogStoreOperationMeter::default(), }) @@ -214,10 +204,6 @@ impl Drop for RequestObservation { counters .catalog_conditional_delete .fetch_add(catalog.conditional_delete, Ordering::Relaxed); - let retry = self.retry.load(Ordering::Relaxed); - if retry != 0 { - self.metrics.retry[usize::from(retry - 1)].fetch_add(1, Ordering::Relaxed); - } let version = self.version.load(Ordering::Relaxed); if (1..=3).contains(&version) { self.metrics.selected_versions[usize::from(version - 1)].fetch_add(1, Ordering::Relaxed); @@ -244,10 +230,6 @@ pub(super) fn record_request_bytes(length: usize) { }); } -pub(super) fn record_retry(kind: u8) { - let _ = REQUEST_OBSERVATION.try_with(|span| span.retry.store(kind, Ordering::Relaxed)); -} - pub(super) fn record_selected_version(version: u8) { let _ = REQUEST_OBSERVATION.try_with(|span| span.version.store(version, Ordering::Relaxed)); } diff --git a/app/crowdb-access-server/src/iceberg/namespace_write.rs b/app/crowdb-access-server/src/iceberg/namespace_write.rs index 26fee4a8e..fea10c171 100644 --- a/app/crowdb-access-server/src/iceberg/namespace_write.rs +++ b/app/crowdb-access-server/src/iceberg/namespace_write.rs @@ -7,23 +7,26 @@ use crowdb_access_iceberg::namespace::{ NamespaceCreateRequest, NamespaceCreator, NamespaceDropRequest, NamespaceDropper, NamespacePropertyRequest, NamespaceRepository, NamespaceStore, }; -use crowdb_access_iceberg::operation::{PayloadStore, RetryAdmission, RetryLedger, RetryRecord}; +use crowdb_access_iceberg::operation::RequestIdentity; use crowdb_access_iceberg::wire::{IcebergErrorResponse, Principal, RequestKey}; use http_body_util::BodyExt; use hyper::body::Incoming; use hyper::{Request, Response}; -use sha2::{Digest, Sha256}; use super::body::IcebergBody; use super::http::{bad_request, response, service_unavailable}; use super::namespace_request::{self, NamespaceMutation}; +struct NamespaceMutationRequest { + context: CatalogContext, + identity: RequestIdentity, + principal: String, +} + pub(super) struct NamespaceWrites { creator: NamespaceCreator, dropper: NamespaceDropper, repository: NamespaceRepository, - ledger: RetryLedger, - payloads: PayloadStore, } impl NamespaceWrites { @@ -31,9 +34,7 @@ impl NamespaceWrites { Self { creator: NamespaceCreator::new(store.clone()), dropper: NamespaceDropper::new(store.clone()), - repository: NamespaceRepository::new(store.clone()), - ledger: RetryLedger::new(store.clone()), - payloads: PayloadStore::new(store), + repository: NamespaceRepository::new(store), } } @@ -70,34 +71,19 @@ impl NamespaceWrites { let request_key = RequestKey::parse(header, now).map_err(|_| bad_request())?; let uri = request.uri().clone(); let bytes = read_body(request.into_body()).await?; - let mut digest = Sha256::new(); - for value in [route.as_bytes(), uri.to_string().as_bytes(), bytes.as_slice()] { - digest.update((value.len() as u64).to_be_bytes()); - digest.update(value); + if route == "POST /namespaces/{namespace}/properties" { + let NamespaceMutation::Update(identifier, changes) = + namespace_request::parse(route, &uri, &bytes)? + else { + return Err(bad_request()); + }; + let (status, body) = self.update_properties(context, identifier, changes).await?; + return Ok(response(status, body)); } - let retry = RetryRecord { + let retry = NamespaceMutationRequest { identity: request_key.identity(), principal: principal.name.into(), - route: route.into(), - digest: digest.finalize().into(), context, - retained_until_ms: 0, - status: 0, - body: Vec::new(), - }; - let retry = match self.admit(retry, request_key, now).await? { - RetryAdmission::Replay(record) => { - super::metrics::record_retry(3); - return Ok(response(record.status, record.body)); - } - RetryAdmission::New(record) => { - super::metrics::record_retry(1); - record - } - RetryAdmission::Resume(record) => { - super::metrics::record_retry(2); - record - } }; let result = match namespace_request::parse(route, &uri, &bytes) { Ok(mutation) => self.mutate(&retry, mutation).await, @@ -111,40 +97,18 @@ impl NamespaceWrites { ), Err(error) => return Err(error), }; - self.ledger - .finish(retry, status, body.clone(), now_ms()?) - .await - .map_err(|error| mutation_error(&error))?; Ok(response(status, body)) } - async fn admit( - &self, - mut retry: RetryRecord, - request_key: RequestKey, - now: u64, - ) -> Result { - for _ in 0..8 { - match self.ledger.begin(retry.clone(), now).await { - Err(CatalogError::Busy) if matches!(request_key, RequestKey::Internal(_)) => { - retry.identity = RequestKey::parse(None, now) - .map_err(|_| bad_request())? - .identity(); - } - result => return result.map_err(|error| mutation_error(&error)), - } - } - Err(service_unavailable()) - } - async fn mutate( &self, - retry: &RetryRecord, + retry: &NamespaceMutationRequest, mutation: NamespaceMutation, ) -> Result<(u16, Vec), IcebergErrorResponse> { let outcome = match mutation { - NamespaceMutation::Create(identifier, properties) => Some( - self.creator + NamespaceMutation::Create(identifier, properties) => { + let outcome = self + .creator .create(&NamespaceCreateRequest { context: retry.context, identity: retry.identity, @@ -153,19 +117,12 @@ impl NamespaceWrites { properties, }) .await - .map_err(|error| mutation_error(&error))?, - ), - NamespaceMutation::Update(identifier, changes) => self - .repository - .update_properties(&NamespacePropertyRequest { - context: retry.context, - identity: retry.identity, - principal: retry.principal.clone(), - identifier, - changes, - }) - .await - .map_err(|error| mutation_error(&error))?, + .map_err(|error| mutation_error(&error))?; + return Ok((outcome.status, outcome.body)); + } + NamespaceMutation::Update(identifier, changes) => { + return self.update_properties(retry.context, identifier, changes).await; + } NamespaceMutation::Drop(identifier) => self .dropper .drop_namespace(&NamespaceDropRequest { @@ -180,12 +137,34 @@ impl NamespaceWrites { .ok_or_else(|| { IcebergErrorResponse::new(404, "NoSuchNamespaceException", "Namespace does not exist") })?; - let bytes = self - .payloads - .get(&outcome.body) + Ok((outcome.status, outcome.body)) + } + + async fn update_properties( + &self, + context: CatalogContext, + identifier: crowdb_access_iceberg::namespace::NamespaceIdentifier, + changes: crowdb_access_iceberg::namespace::PropertyChanges, + ) -> Result<(u16, Vec), IcebergErrorResponse> { + let outcome = self + .repository + .update_properties(&NamespacePropertyRequest { + context, + identifier, + changes, + }) .await - .map_err(|_| service_unavailable())?; - Ok((outcome.status, bytes)) + .map_err(|error| mutation_error(&error))? + .ok_or_else(|| { + IcebergErrorResponse::new(404, "NoSuchNamespaceException", "Namespace does not exist") + })?; + let body = serde_json::to_vec(&serde_json::json!({ + "removed": outcome.removed, + "updated": outcome.updated, + "missing": outcome.missing, + })) + .map_err(|_| service_unavailable())?; + Ok((200, body)) } } diff --git a/app/crowdb-access-server/src/iceberg/runtime.rs b/app/crowdb-access-server/src/iceberg/runtime.rs index 60195ebdb..65acdceaa 100644 --- a/app/crowdb-access-server/src/iceberg/runtime.rs +++ b/app/crowdb-access-server/src/iceberg/runtime.rs @@ -6,7 +6,9 @@ use crowdb_access_iceberg::catalog::{ RoutedCatalogStore, }; use crowdb_access_iceberg::operation::{ManagementAction, ManagementRequest, RequestIdentity}; -use crowdb_access_iceberg::storage::{connect, foreground_blocks, IcebergLargeWriteSettings}; +use crowdb_access_iceberg::storage::{ + connect_with_chunk_kv_config, foreground_blocks, IcebergLargeWriteSettings, +}; use crowdb_access_iceberg::wire::BearerAuthenticator; use crowdb_access_s3::native_buffer::NativeBodyAllocator; use crowdb_chunk_client::ChunkIoClient; @@ -93,12 +95,13 @@ pub async fn run_with_shutdown( if arguments.len() > 7 { return Err("too many Iceberg command arguments".into()); } - let (repository, store, chunks) = connect( + let (repository, store, chunks) = connect_with_chunk_kv_config( config.management_seeds.clone(), access_config.read.policy(), access_config.iceberg_small_write().policy(), access_config.common.diskio_connections_per_endpoint, access_config.common.diskio_rpc_workers, + &access_config.common.chunk_kv_rpc, ) .await?; let result = if arguments.is_empty() || arguments == ["serve"] { @@ -199,11 +202,7 @@ async fn start_listener( let listener = TcpListener::bind(&address).await?; tracing::info!(%address, "Iceberg listener ready"); let serving = serve(listener, service, wait_for_shutdown(shutdown)); - let multipart = Box::pin(super::file_recovery::run( - repository.clone(), - store.clone(), - blocks.clone(), - )); + let multipart = Box::pin(super::file_recovery::run(repository.clone(), store.clone())); let tables = super::table_recovery::run(repository.clone(), store.clone(), blocks.clone()); let (gc_store, gc_chunks) = connect_gc_pool(&access_config, management_seeds, store.clone(), gc_config.enabled).await?; @@ -245,12 +244,13 @@ async fn connect_gc_pool( if !enabled { return Ok((store, None)); } - let (_, gc_store, gc_chunks) = connect( + let (_, gc_store, gc_chunks) = connect_with_chunk_kv_config( management_seeds, access_config.read.policy(), access_config.iceberg_small_write().policy(), access_config.common.diskio_connections_per_endpoint, access_config.common.diskio_rpc_workers, + &access_config.common.chunk_kv_rpc, ) .await?; Ok((gc_store, Some(gc_chunks))) diff --git a/app/crowdb-access-server/src/iceberg/table_credentials.rs b/app/crowdb-access-server/src/iceberg/table_credentials.rs index 11b3c434b..cb39a98ab 100644 --- a/app/crowdb-access-server/src/iceberg/table_credentials.rs +++ b/app/crowdb-access-server/src/iceberg/table_credentials.rs @@ -5,9 +5,8 @@ use crowdb_access_iceberg::{ CatalogAuthority, CatalogContext, CatalogLifecycle, CatalogRepository, CatalogStore, FormatAction, RootState, }, - commit::{TableCreateJournal, TableCreatePhase}, file::FileGrantIssuer, - key::{NamespaceId, OperationId, TableId}, + key::{NamespaceId, TableId}, namespace::{NamespaceIdentifier, NamespaceRepository, NamespaceStore}, table::{storage_head_key, TableLifecycle, TableRepository}, wire::{ @@ -77,6 +76,12 @@ impl TableFileConfig { write!(endpoint, "&storage-namespace-id={namespace}").expect("string writes do not fail"); } } + if target.staged { + let endpoint = properties + .get_mut("client.refresh-credentials-endpoint") + .ok_or_else(service_unavailable)?; + write!(endpoint, "&format-version={}", target.version).expect("string writes do not fail"); + } let credentials = FileDelegationLimits { ttl_ms: 900_000, max_request_bytes: 1024 * 1024 * 1024, @@ -197,7 +202,12 @@ impl TableCredentials { request: &Request, ) -> Result, IcebergErrorResponse> { let (namespace, name) = credential_target(request)?; - let (selector, storage_namespace, cleanup) = selector(request.uri().query())?; + let FileCredentialSelector { + table: selector, + namespace: storage_namespace, + cleanup, + version, + } = selector(request.uri().query())?; let now = now_ms()?; if let (Some(table), Some(namespace)) = (selector, storage_namespace) { if let Some(target) = self.native_target(context, table, namespace, cleanup).await? { @@ -217,7 +227,7 @@ impl TableCredentials { .select(context, parent.namespace, &name) .await .map_err(|_| service_unavailable())?; - let mut ttl_ms = 900_000; + let ttl_ms = 900_000; let (table, version, staged, storage_namespace) = if let Some(selected) = selected.filter(|selected| selector.map_or(true, |table| table == selected.head.table)) { @@ -233,49 +243,37 @@ impl TableCredentials { ) } else { let table = selector.ok_or_else(missing_table)?; - let identity = OperationId::from_bytes(table.as_bytes()).map_err(|_| bad_request())?; - let journal = TableCreateJournal::new(self.store.clone()); - let operation = journal - .load(context, identity) - .await - .map_err(|_| service_unavailable())? - .ok_or_else(missing_table)?; - let stage = operation.stage.as_ref().ok_or_else(missing_table)?; + let version = version.ok_or_else(bad_request)?; if !principal.namespace_write - || operation.principal != principal.name - || operation.namespace != namespace - || operation.candidate.namespace != parent.namespace - || operation.candidate.name != name - || operation.candidate.table != table - || operation.phase != TableCreatePhase::Staged - || storage_namespace.is_some_and(|namespace| { - operation.candidate.storage_location().storage_namespace != Some(namespace) - }) + || cleanup + || parent.lifecycle != crowdb_access_iceberg::namespace::NamespaceLifecycle::Ready + || storage_namespace.is_some_and(|identity| identity != parent.namespace) { return Err(missing_table()); } - let expires = u64::try_from(stage.expires_ms).map_err(|_| service_unavailable())?; - ttl_ms = ttl_ms.min( - expires - .checked_sub(now) - .filter(|remaining| *remaining > 0) - .ok_or_else(missing_table)?, - ); - if journal - .load(context, identity) + let location = crowdb_access_iceberg::file::TableLocation { + catalog: context.catalog, + storage_namespace, + table, + }; + let key = storage_head_key(location); + if let Some(value) = self + .store + .get(&key.encode().map_err(|_| bad_request())?) .await .map_err(|_| service_unavailable())? - .as_ref() - != Some(&operation) { - return Err(service_unavailable()); + match crowdb_access_iceberg::record::StorageRecord::decode(&key, &value.bytes) + .map_err(|_| service_unavailable())? + { + crowdb_access_iceberg::record::StorageRecord::TableHead(_) + | crowdb_access_iceberg::record::StorageRecord::RetiredTable(_) => { + return Err(missing_table()) + } + _ => return Err(service_unavailable()), + } } - ( - table, - operation.candidate.format_version, - true, - operation.candidate.metadata_location.table().storage_namespace, - ) + (table, version, true, storage_namespace) }; self.issue_response( repository, @@ -314,11 +312,12 @@ impl TableCredentials { else { return Ok(None); }; - let crowdb_access_iceberg::record::StorageRecord::TableHead(head) = - crowdb_access_iceberg::record::StorageRecord::decode(&key, &value.bytes) - .map_err(|_| service_unavailable())? - else { - return Err(service_unavailable()); + let head = match crowdb_access_iceberg::record::StorageRecord::decode(&key, &value.bytes) + .map_err(|_| service_unavailable())? + { + crowdb_access_iceberg::record::StorageRecord::TableHead(head) => head, + crowdb_access_iceberg::record::StorageRecord::RetiredTable(_) => return Err(missing_table()), + _ => return Err(service_unavailable()), }; if head.lifecycle != TableLifecycle::Ready && !(cleanup && head.lifecycle == TableLifecycle::Tombstone) @@ -351,6 +350,11 @@ impl TableCredentials { { return Err(service_unavailable()); } + if target.storage_namespace.is_some() + != (authority.storage_layout == crowdb_access_iceberg::catalog::CatalogStorageLayout::Native) + { + return Err(missing_table()); + } if !authority .capabilities .supports(target.format_version, FormatAction::Read) @@ -376,12 +380,18 @@ impl TableCredentials { } } -fn selector( - query: Option<&str>, -) -> Result<(Option, Option, bool), IcebergErrorResponse> { +struct FileCredentialSelector { + table: Option, + namespace: Option, + cleanup: bool, + version: Option, +} + +fn selector(query: Option<&str>) -> Result { let mut selector = None; let mut namespace = None; let mut cleanup = false; + let mut version = None; for pair in query .unwrap_or_default() .split('&') @@ -395,14 +405,26 @@ fn selector( "storage-namespace-id" if namespace.is_none() => { namespace = Some(decode_query(value)?.parse().map_err(|_| bad_request())?); } + "format-version" if version.is_none() => { + let value = decode_query(value)?.parse::().map_err(|_| bad_request())?; + if !(1..=3).contains(&value) { + return Err(bad_request()); + } + version = Some(value); + } "cleanup" if !cleanup && decode_query(value)? == "true" => cleanup = true, _ => return Err(bad_request()), } } - if namespace.is_some() && selector.is_none() { + if (namespace.is_some() || version.is_some()) && selector.is_none() { return Err(bad_request()); } - Ok((selector, namespace, cleanup)) + Ok(FileCredentialSelector { + table: selector, + namespace, + cleanup, + version, + }) } fn credential_target( diff --git a/app/crowdb-access-server/src/iceberg/table_write.rs b/app/crowdb-access-server/src/iceberg/table_write.rs index 717c50cc4..8e59fff9d 100644 --- a/app/crowdb-access-server/src/iceberg/table_write.rs +++ b/app/crowdb-access-server/src/iceberg/table_write.rs @@ -5,7 +5,7 @@ use crowdb_access_iceberg::{ commit::{CommitProofLimits, StagedCommitLimits, TableCreator}, file::FileBlockStore, namespace::{NamespaceRepository, NamespaceStore}, - operation::{PayloadStore, RetryAdmission, RetryLedger, RetryRecord}, + operation::RequestIdentity, table::TableRepository, wire::{IcebergErrorResponse, Principal, RequestKey}, }; @@ -15,7 +15,7 @@ use sha2::{Digest, Sha256}; use super::{ body::{IcebergBody, SpoolPermit}, http::{bad_request, response, service_unavailable}, - namespace_write::{mutation_error, now_ms, read_body}, + namespace_write::{now_ms, read_body}, table_limits, }; @@ -23,6 +23,13 @@ mod lifecycle; mod mutation; pub(super) mod request; +pub(super) struct TableMutation { + context: CatalogContext, + identity: RequestIdentity, + principal: String, + digest: [u8; 32], +} + pub(super) struct TableWrites { store: Arc, blocks: Arc, @@ -30,8 +37,6 @@ pub(super) struct TableWrites { namespaces: NamespaceRepository, tables: TableRepository, lifecycles: crowdb_access_iceberg::table::TableLifecycles, - ledger: RetryLedger, - payloads: PayloadStore, limits: CommitProofLimits, active: Arc, pub(super) file_config: Option, @@ -55,9 +60,7 @@ impl TableWrites { auxiliary: limits.auxiliary, }), namespaces: NamespaceRepository::new(store.clone()), - tables: TableRepository::new(store.clone()), - ledger: RetryLedger::new(store.clone()), - payloads: PayloadStore::new(store), + tables: TableRepository::new(store), limits, active: Arc::new(AtomicUsize::new(0)), file_config: None, @@ -114,15 +117,11 @@ impl TableWrites { digest.update((value.len() as u64).to_be_bytes()); digest.update(value); } - let mut record = RetryRecord { + let record = TableMutation { identity: key.identity(), principal: principal.name.into(), - route: route.into(), digest: digest.finalize().into(), context, - retained_until_ms: 0, - status: 0, - body: Vec::new(), }; if method == Method::POST && uri.path() != "/v1/tables/rename" { if let Some(body) = self @@ -141,51 +140,18 @@ impl TableWrites { return Ok(response(200, body)); } } - let admission = self.admit(&mut record, key, now).await?; - let (record, resuming) = match admission { - RetryAdmission::Replay(record) => { - super::metrics::record_retry(3); - let body = self.decorate_response( - record.status, - record.body, - configuration_target, - context, - authority, - principal, - request_host.as_deref(), - )?; - return Ok(response(record.status, body)); - } - RetryAdmission::New(record) => { - super::metrics::record_retry(1); - (record, false) - } - RetryAdmission::Resume(record) => { - super::metrics::record_retry(2); - (record, true) - } - }; if method == Method::DELETE || uri.path() == "/v1/tables/rename" { - let result = self - .mutate_lifecycle(&record, capabilities, resuming, &method, &uri, &bytes) - .await; - let (status, body) = self.outcome_response(result).await?; - self.ledger - .finish(record, status, body.clone(), now_ms()?) - .await - .map_err(|error| mutation_error(&error))?; - return Ok(response(status, body)); + let outcome = self + .mutate_lifecycle(&record, capabilities, &method, &uri, &bytes) + .await?; + return Ok(response(outcome.status, outcome.body)); } let target = request::parse(&uri); let result = match target { Ok(target) => Box::pin(self.mutate(&record, capabilities, target, bytes, now)).await, Err(error) => Err(error), }; - let (status, body) = self.outcome_response(result).await?; - self.ledger - .finish(record, status, body.clone(), now_ms()?) - .await - .map_err(|error| mutation_error(&error))?; + let (status, body) = Self::outcome_response(result)?; let body = self.decorate_response( status, body, @@ -198,18 +164,11 @@ impl TableWrites { Ok(response(status, body)) } - async fn outcome_response( - &self, - result: Result, + fn outcome_response( + result: Result, ) -> Result<(u16, Vec), IcebergErrorResponse> { let (status, body) = match result { - Ok(outcome) => ( - outcome.status, - self.payloads - .get(&outcome.body) - .await - .map_err(|_| service_unavailable())?, - ), + Ok(outcome) => (outcome.status, outcome.body), Err(error) if error.error.code < 500 => ( error.error.code, serde_json::to_vec(&error).map_err(|_| service_unavailable())?, @@ -303,30 +262,4 @@ impl TableWrites { }; Some((target.namespace, name, staged)) } - - async fn admit( - &self, - record: &mut RetryRecord, - key: RequestKey, - now: u64, - ) -> Result { - for _ in 0..8 { - match self.ledger.begin(record.clone(), now).await { - Err(crowdb_access_iceberg::catalog::CatalogError::Busy) - if matches!(key, RequestKey::Internal(_)) => - { - record.identity = RequestKey::parse(None, now) - .map_err(|_| bad_request())? - .identity(); - } - result => { - return result.map_err(|error| { - tracing::warn!(%error, "table retry ledger admission failed"); - mutation_error(&error) - }) - } - } - } - Err(service_unavailable()) - } } diff --git a/app/crowdb-access-server/src/iceberg/table_write/lifecycle.rs b/app/crowdb-access-server/src/iceberg/table_write/lifecycle.rs index 1a45c62df..f81301cbe 100644 --- a/app/crowdb-access-server/src/iceberg/table_write/lifecycle.rs +++ b/app/crowdb-access-server/src/iceberg/table_write/lifecycle.rs @@ -4,11 +4,9 @@ use super::{ }; use crowdb_access_iceberg::{ catalog::{Capabilities, FormatAction}, - commit::TableCommitOutcome, key::NameSuffix, namespace::NamespaceIdentifier, - operation::RetryRecord, - table::{TableLifecycleAction, TableLifecycleRequest}, + table::{TableLifecycleAction, TableLifecycleOutcome, TableLifecycleRequest}, wire::IcebergErrorResponse, }; use hyper::{Method, Uri}; @@ -43,13 +41,12 @@ impl Identifier { impl TableWrites { pub(super) async fn mutate_lifecycle( &self, - record: &RetryRecord, + record: &super::TableMutation, capabilities: Capabilities, - resuming: bool, method: &Method, uri: &Uri, bytes: &[u8], - ) -> Result { + ) -> Result { let (namespace, name, action) = if uri.path() == "/v1/tables/rename" { if *method != Method::POST { return Err(super::super::table_read::unsupported()); @@ -81,38 +78,44 @@ impl TableWrites { TableLifecycleAction::Drop { purge_requested }, ) }; - let existing = if resuming { - self.lifecycles - .load(record.context, record.identity.operation) - .await - .map_err(|_| service_unavailable())? - } else { - None - }; - let version = if let Some(operation) = existing { - operation.before.format_version - } else { - let parent = self - .namespaces - .load(record.context, &namespace) - .await - .map_err(|_| service_unavailable())? - .ok_or_else(super::super::table_read::missing_table)?; - self.tables - .select(record.context, parent.namespace, &name) - .await - .map_err(|_| service_unavailable())? - .ok_or_else(super::super::table_read::missing_table)? - .head - .format_version - }; + let parent = self + .namespaces + .load(record.context, &namespace) + .await + .map_err(|_| service_unavailable())? + .ok_or_else(super::super::table_read::missing_table)?; + let version = self + .tables + .select(record.context, parent.namespace, &name) + .await + .map_err(|_| service_unavailable())? + .ok_or_else(super::super::table_read::missing_table)? + .head + .format_version; if !capabilities.supports(version, FormatAction::Write) { return Err(super::super::table_read::unsupported()); } - self.lifecycles.execute(&TableLifecycleRequest { context: record.context, identity: record.identity, - principal: record.principal.clone(), namespace, name, action }).await.map_err(|error| { - tracing::error!(%error, "table lifecycle remains recoverable; retry with the same request key"); - service_unavailable() + self.lifecycles + .execute(&TableLifecycleRequest { + context: record.context, + identity: record.identity, + principal: record.principal.clone(), + namespace, + name, + action, + }) + .await + .map_err(|error| { + if matches!(error, crowdb_access_iceberg::catalog::CatalogError::Conflict) { + IcebergErrorResponse::new( + 409, + "CommitFailedException", + "Table lifecycle request conflicts", + ) + } else { + tracing::error!(%error, "table lifecycle request failed; retry against the current name"); + service_unavailable() + } }) } } diff --git a/app/crowdb-access-server/src/iceberg/table_write/mutation.rs b/app/crowdb-access-server/src/iceberg/table_write/mutation.rs index 7786c1fe8..db1dd2d97 100644 --- a/app/crowdb-access-server/src/iceberg/table_write/mutation.rs +++ b/app/crowdb-access-server/src/iceberg/table_write/mutation.rs @@ -6,23 +6,16 @@ use super::{request::Target, TableWrites}; use crowdb_access_iceberg::{ catalog::{Capabilities, CatalogError, FormatAction}, commit::{ - publish_direct_commit, recover_table_commit, CommitPublicationError, CommitRequest, - CreateTableRequest, DirectCommitError, StagedCommitRequest, TableCommitJournal, TableCommitOperation, - TableCommitOutcome, TableCommitPhase, TableCreationRequest, TableRequirement, + publish_direct_commit, CommitPublicationError, CommitRequest, CreateTableRequest, DirectCommitError, + StagedCommitRequest, TableCreationOutcome, TableCreationRequest, TableRequirement, }, - operation::RetryRecord, wire::IcebergErrorResponse, }; -struct CommitInput<'input> { - parsed: &'input CommitRequest, - body: &'input [u8], -} - impl TableWrites { pub(super) async fn try_direct_update( &self, - record: &RetryRecord, + record: &super::TableMutation, capabilities: Capabilities, uri: &hyper::Uri, body: &[u8], @@ -90,12 +83,12 @@ impl TableWrites { pub(super) async fn mutate( &self, - record: &RetryRecord, + record: &super::TableMutation, capabilities: Capabilities, target: Target, body: Vec, now: u64, - ) -> Result { + ) -> Result { let timestamp_ms = i64::try_from(now).map_err(|_| service_unavailable())?; let Some(name) = target.name else { let parsed = CreateTableRequest::decode(&body, self.limits.preparation.request.json) @@ -117,15 +110,10 @@ impl TableWrites { body, timestamp_ms, }; - return if staged { - let expiry = timestamp_ms - .checked_add(24 * 60 * 60 * 1000) - .ok_or_else(service_unavailable)?; - self.creator.stage(&request, expiry).await - } else { - self.creator.create(&request).await + if staged { + return self.creator.stage(&request).await.map_err(creation_error); } - .map_err(creation_error); + return self.creator.create(&request).await.map_err(creation_error); }; let parsed = CommitRequest::decode(&body, self.limits.preparation.request).map_err(|_| bad_request())?; @@ -154,118 +142,7 @@ impl TableWrites { .await .map_err(creation_error); } - Box::pin(self.update( - record, - capabilities, - &target.namespace, - &name, - CommitInput { - parsed: &parsed, - body: &body, - }, - timestamp_ms, - )) - .await - } - - async fn update( - &self, - record: &RetryRecord, - capabilities: Capabilities, - namespace: &crowdb_access_iceberg::namespace::NamespaceIdentifier, - name: &str, - input: CommitInput<'_>, - timestamp_ms: i64, - ) -> Result { - let journal = TableCommitJournal::new(self.store.clone()); - let operation = if let Some(operation) = journal - .load(record.context, record.identity.operation) - .await - .map_err(|error| storage_error(&error))? - { - operation - } else { - let namespace = self - .namespaces - .load(record.context, namespace) - .await - .map_err(|error| storage_error(&error))? - .ok_or_else(missing_table)?; - let selected = self - .tables - .select(record.context, namespace.namespace, name) - .await - .map_err(|error| storage_error(&error))? - .ok_or_else(missing_table)?; - if selected.head.pending_operation.is_some() { - return Err(service_unavailable()); - } - let mut version = selected.head.format_version; - let mut upgrades = input.parsed.upgrade_targets().peekable(); - if upgrades.peek().is_none() && !capabilities.supports(version, FormatAction::Write) { - return Err(super::super::table_read::unsupported()); - } - for target in upgrades { - if !capabilities.supports_upgrade(version, target) { - return Err(super::super::table_read::unsupported()); - } - version = target; - } - let input = self - .payloads - .put(record.context.catalog, record.identity.operation, input.body) - .await - .map_err(|error| storage_error(&error))?; - let initial = TableCommitOperation { - context: record.context, - identity: record.identity, - principal: record.principal.clone(), - revision: 1, - timestamp_ms, - phase: TableCommitPhase::Prepared, - input, - before: selected.head, - candidate: None, - outcome: None, - }; - match journal.begin(initial).await { - Ok(operation) => operation, - Err(CatalogError::Conflict) => journal - .load(record.context, record.identity.operation) - .await - .map_err(|error| storage_error(&error))? - .ok_or_else(service_unavailable)?, - Err(error) => return Err(storage_error(&error)), - } - }; - if operation.identity != record.identity - || operation.principal != record.principal - || operation.before.name != name - || self - .payloads - .get(&operation.input) - .await - .map_err(|error| storage_error(&error))? - != input.body - { - return Err(IcebergErrorResponse::new( - 409, - "CommitFailedException", - "Commit identity conflicts", - )); - } - recover_table_commit( - self.store.clone(), - self.blocks.clone(), - record.context, - record.identity.operation, - self.limits, - ) - .await - .map_err(|error| { - tracing::error!(%error, "table commit remains recoverable; retry with the same request key"); - service_unavailable() - }) + Err(bad_request()) } } @@ -300,6 +177,9 @@ fn creation_error(error: CommitPublicationError) -> IcebergErrorResponse { CommitPublicationError::NamespaceMissing => { IcebergErrorResponse::new(404, "NoSuchNamespaceException", "Namespace does not exist") } + CommitPublicationError::TableExists => { + IcebergErrorResponse::new(409, "AlreadyExistsException", "Table already exists") + } CommitPublicationError::Unsupported(_) => super::super::table_read::unsupported(), CommitPublicationError::Catalog(CatalogError::Conflict) => { IcebergErrorResponse::new(409, "CommitFailedException", "Table creation conflicts") @@ -314,7 +194,7 @@ fn creation_error(error: CommitPublicationError) -> IcebergErrorResponse { | crowdb_access_iceberg::table::TableMetadataError::Bounds, ) => bad_request(), error => { - tracing::error!(%error, "table creation remains recoverable; retry with the same request key"); + tracing::error!(%error, "table creation failed; retry complete input or reload the table"); service_unavailable() } } diff --git a/app/crowdb-access-server/src/main.rs b/app/crowdb-access-server/src/main.rs index 523b43849..82504ab04 100644 --- a/app/crowdb-access-server/src/main.rs +++ b/app/crowdb-access-server/src/main.rs @@ -165,11 +165,7 @@ async fn run_s3( .clone() .or_else(|| std::env::var("CROWDB_S3_LISTEN").ok()) { - let management_seeds = if access_config.common.management_seeds.is_empty() { - management_seeds()? - } else { - access_config.common.management_seeds.clone() - }; + let management_seeds = configured_management_seeds(access_config)?; let tenant_name = match &access_config.s3.tenant { Some(name) => name.clone(), None => required_env("CROWDB_S3_TENANT")?, @@ -179,12 +175,13 @@ async fn run_s3( let credential_cipher = Arc::new(CredentialCipher::new(&master_key)); let continuation_key = credential_cipher.continuation_key().to_vec(); let write_policies = s3_write_policies(access_config)?; - let storage = S3StorageClients::connect_with_read_policy( + let storage = S3StorageClients::connect_with_chunk_kv_config( management_seeds, access_config.common.diskio_connections_per_endpoint, access_config.common.diskio_rpc_workers, write_policies.small, access_config.read.policy(), + &access_config.common.chunk_kv_rpc, ) .await?; let chunks = Arc::clone(&storage.chunks); @@ -216,7 +213,11 @@ async fn run_s3( "CROWDB_S3_NATIVE_BUDGET_BYTES", )? .unwrap_or(256 * 1024 * 1024); - let body_allocator = Arc::new(NativeBodyAllocator::new(native_budget, 1024 * 1024)?); + let mut body_allocator = NativeBodyAllocator::new(native_budget, 1024 * 1024)?; + if let Some(producer) = chunks.producer_allocator() { + body_allocator = body_allocator.with_producer_allocator(producer)?; + } + let body_allocator = Arc::new(body_allocator); let handler = Arc::new( S3Dispatcher::new( authenticator, @@ -254,6 +255,15 @@ async fn run_s3( Ok(()) } +#[cfg(feature = "s3")] +fn configured_management_seeds(config: &AccessConfig) -> Result, Box> { + if config.common.management_seeds.is_empty() { + management_seeds() + } else { + Ok(config.common.management_seeds.clone()) + } +} + #[cfg(feature = "s3")] async fn bind_health_listener(access_config: &AccessConfig) -> std::io::Result { let address = access_config @@ -286,14 +296,25 @@ async fn wait_for_shutdown(mut shutdown: Option>) { #[cfg(feature = "s3")] fn start_multipart_expiry(operations: Arc) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(60 * 60)); + let mut sweep = operations.multipart_expiry_sweep(); loop { - interval.tick().await; - match operations.expire_multipart_uploads().await { - Ok(0) => {} - Ok(count) => tracing::debug!(count, "expired S3 multipart sessions"), - Err(error) => tracing::warn!(?error, "S3 multipart expiry sweep deferred"), - } + let delay = match operations.expire_multipart_uploads(&mut sweep).await { + Ok(step) => { + if step.expired > 0 { + tracing::debug!(count = step.expired, "expired S3 multipart sessions"); + } + if step.more { + Duration::from_millis(50) + } else { + Duration::from_secs(15) + } + } + Err(error) => { + tracing::warn!(%error, "S3 multipart expiry window deferred; durable evidence retained"); + Duration::from_millis(500) + } + }; + tokio::time::sleep(delay).await; } }) } diff --git a/app/crowdb-access-server/src/s3.rs b/app/crowdb-access-server/src/s3.rs index 816fcd151..bde4a31cd 100644 --- a/app/crowdb-access-server/src/s3.rs +++ b/app/crowdb-access-server/src/s3.rs @@ -27,6 +27,8 @@ mod inspection; pub use inspection::{ObjectInspector, OBJECT_LOCATIONS_PATH}; mod health; mod operations; +#[cfg(feature = "test-util")] +pub mod test_support; pub use health::{AccessHealthHandler, HealthObserver}; pub use crate::http_receive::install_body_receive_provider; diff --git a/app/crowdb-access-server/src/s3/operations.rs b/app/crowdb-access-server/src/s3/operations.rs index 846559786..284b5004c 100644 --- a/app/crowdb-access-server/src/s3/operations.rs +++ b/app/crowdb-access-server/src/s3/operations.rs @@ -7,19 +7,15 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use base64::Engine as _; -use crowdb_access_s3::bucket::{self, BucketError, DeleteBucketResult, RandomBucketIdGenerator}; use crowdb_access_s3::condition::ObjectConditions; use crowdb_access_s3::continuation::ContinuationTokenSigner; use crowdb_access_s3::error::{S3Error, S3ErrorCode}; +use crowdb_access_s3::execution::{ObjectListRequest, PutObjectInfo, S3Executor, S3WritePolicy}; use crowdb_access_s3::metadata::{ObjectRecord, TenantId}; -use crowdb_access_s3::object::{self, ListObjectsV2Request, ObjectMetadataError}; -use crowdb_access_s3::publication::PublicationRequest; -use crowdb_access_s3::retrieval::{self, ObjectHeaders, RetrievalError}; +use crowdb_access_s3::retrieval::{ObjectHeaders, RetrievalError}; use crowdb_access_s3::route::{S3Operation, S3Route}; -use crowdb_access_s3::streaming::{publish_completed_locations, PutErrorCode, PutOutcome}; -use crowdb_chunk_client::{ - ChunkClientConfig, ChunkIoWriter, IoError, LargeWritePolicy, PreparedLargeWrite, SharedObjectWriter, -}; +use crowdb_access_s3::streaming::{PutErrorCode, PutOutcome}; +use crowdb_chunk_client::{ChunkClientConfig, LargeWritePolicy}; use crowdb_common::ec::EcScheme; use futures::stream; use http_body_util::{BodyExt, StreamBody}; @@ -43,7 +39,7 @@ mod delete; mod multipart; mod upload; -use upload::write_object_body; +pub(crate) use upload::write_object_body; const DEFAULT_LIST_LIMIT: usize = 1_000; const DEFAULT_LIST_SCAN_BYTES: usize = 4 * 1024 * 1024; @@ -99,7 +95,7 @@ pub struct ProductionS3Operations { pub(super) storage: S3StorageClients, pub(super) config: S3ServiceConfig, signer: ContinuationTokenSigner, - bucket_ids: RandomBucketIdGenerator, + execution: S3Executor, metrics: Option>, } @@ -113,10 +109,10 @@ impl ProductionS3Operations { let signer = ContinuationTokenSigner::new(config.continuation_key.clone()) .map_err(|_| S3ErrorCode::InvalidRequest)?; Ok(Self { + execution: S3Executor::new(storage.clone(), config.tenant.clone()), storage, config, signer, - bucket_ids: RandomBucketIdGenerator, metrics: None, }) } @@ -165,50 +161,25 @@ impl ProductionS3Operations { async fn create_bucket(&self, route: S3Route) -> Result, S3ErrorCode> { let name = required_bucket(&route)?; - bucket::create_bucket( - &self.storage.metadata, - &self.bucket_ids, - &self.config.tenant, - name, - ) - .await - .map_err(|error| map_bucket_error(&error))?; + self.execution.create_bucket(name).await?; response(StatusCode::OK, Vec::new()) } async fn head_bucket(&self, route: S3Route) -> Result, S3ErrorCode> { let name = required_bucket(&route)?; - let exists = bucket::head_bucket(&self.storage.metadata, &self.config.tenant, name) - .await - .map_err(|error| map_bucket_error(&error))? - .is_some(); - if !exists { - return Err(S3ErrorCode::NoSuchBucket); - } + self.execution.resolve_bucket(name).await?; response(StatusCode::OK, Vec::new()) } async fn list_buckets(&self) -> Result, S3ErrorCode> { - let buckets = bucket::list_buckets(&self.storage.metadata, &self.config.tenant, DEFAULT_LIST_LIMIT) - .await - .map_err(|error| { - tracing::error!(%error, "S3 bucket listing failed; returning a service error"); - map_bucket_error(&error) - })?; + let buckets = self.execution.list_buckets(DEFAULT_LIST_LIMIT).await?; xml_response(wire::list_buckets(self.config.tenant.as_bytes(), &buckets)) } async fn delete_bucket(&self, route: S3Route) -> Result, S3ErrorCode> { let name = required_bucket(&route)?; - match bucket::delete_bucket(&self.storage.metadata, &self.config.tenant, name) - .await - .map_err(|error| map_bucket_error(&error))? - { - DeleteBucketResult::Deleted => response(StatusCode::NO_CONTENT, Vec::new()), - DeleteBucketResult::Missing => Err(S3ErrorCode::NoSuchBucket), - DeleteBucketResult::NotEmpty => Err(S3ErrorCode::BucketNotEmpty), - DeleteBucketResult::Conflict => Err(S3ErrorCode::ServiceUnavailable), - } + self.execution.delete_bucket(name).await?; + response(StatusCode::NO_CONTENT, Vec::new()) } async fn put_object( @@ -218,25 +189,33 @@ impl ProductionS3Operations { ) -> Result, S3ErrorCode> { let bucket_name = required_bucket(&route)?; let key = required_key(&route)?.to_vec(); - let bucket_id = self.resolve_bucket(bucket_name).await?; let streaming = take_streaming(&mut request)?; let content_length = upload_length(&request, streaming.is_some())?; let content_type = header(&request, CONTENT_TYPE) .unwrap_or("application/octet-stream") .to_owned(); - let attributes = - crowdb_access_s3::metadata::UserMetadata::from_headers(request.headers())?.encode()?; + let user_metadata = crowdb_access_s3::metadata::UserMetadata::from_headers(request.headers())?; let content_md5 = content_md5_header(&request)?.map(str::to_owned); let payload_sha256 = strict_header(&request, "x-amz-content-sha256", S3ErrorCode::InvalidRequest)? .filter(|value| *value != "UNSIGNED-PAYLOAD" && streaming.is_none()) .map(str::to_owned); - let mut route_key = - Vec::with_capacity(self.config.tenant.as_bytes().len() + bucket_id.as_bytes().len() + key.len()); - route_key.extend_from_slice(self.config.tenant.as_bytes()); - route_key.extend_from_slice(bucket_id.as_bytes()); - route_key.extend_from_slice(&key); - let mut writer = self.prepare_writer(content_length, &route_key).await?; - let native_receiver = if writer.is_large() && content_length.is_some() && streaming.is_none() { + let mut put = self + .execution + .prepare_put( + bucket_name, + &key, + PutObjectInfo { + content_length, + content_type, + user_metadata, + }, + &S3WritePolicy { + small_object_limit: self.config.small_object_limit, + large_write: self.config.large_write.clone(), + }, + ) + .await?; + let native_receiver = if put.is_large() && content_length.is_some() && streaming.is_none() { install_body_receive_provider(&mut request) } else { None @@ -250,7 +229,7 @@ impl ProductionS3Operations { body.defer_md5(); let write_result = write_object_body( &mut body, - &mut writer, + put.writer_mut(), native_receiver.as_deref(), content_length, content_md5.as_deref(), @@ -262,45 +241,20 @@ impl ProductionS3Operations { let (etag, checksum) = match write_result { Ok(result) => result, Err(outcome) => { - let _ = writer.on_error().await; + put.abort().await; return Err(body .failure() .map_or_else(|| map_put_outcome(&outcome), encoding_error)); } }; - let locations = writer.on_finish().await.map_err(|error| { - tracing::warn!(%error, "S3 PUT chunk finalization failed"); - S3ErrorCode::ServiceUnavailable - })?; - let logical_length = locations.iter().map(|location| location.logical_length).sum(); - if content_length.is_some_and(|expected| expected != logical_length) { - return Err(S3ErrorCode::InvalidRequest); - } - let now = unix_millis(); - let mut publication = PublicationRequest { - tenant: self.config.tenant.clone(), - object: ObjectRecord { - bucket_id, - key, - logical_length, - checksum, - etag: etag.clone(), - created_at_ms: now, - modified_at_ms: now, - content_type, - attributes, - data_reference: vec![0], - data_length: logical_length, - }, - }; - match publish_completed_locations(&self.storage.metadata, &mut publication, &locations).await { - PutOutcome::Success => Response::builder() - .status(StatusCode::OK) - .header(ETAG, format!("\"{etag}\"")) - .body(full_body(Vec::new().into())) - .map_err(|_| S3ErrorCode::InternalError), - outcome => Err(map_put_outcome(&outcome)), - } + put.finish(etag.clone(), checksum) + .await + .map_err(|outcome| map_put_outcome(&outcome))?; + Response::builder() + .status(StatusCode::OK) + .header(ETAG, format!("\"{etag}\"")) + .body(full_body(Vec::new().into())) + .map_err(|_| S3ErrorCode::InternalError) } async fn head_object( @@ -309,7 +263,7 @@ impl ProductionS3Operations { request: &Request, ) -> Result, S3ErrorCode> { let record = self.object(&route).await?; - let headers = match retrieval::prepare_head(&record, &conditions(request)) { + let headers = match self.execution.prepare_head(&record, &conditions(request)) { Ok(headers) => headers, Err(RetrievalError::NotModified) => return not_modified(&record.etag), Err(error) => return Err(map_retrieval(&error)), @@ -324,12 +278,10 @@ impl ProductionS3Operations { request_id: &str, ) -> Result, S3ErrorCode> { let record = self.object(&route).await?; - let prepared = match retrieval::prepare_get( - &self.storage.chunks, - &record, - header(request, RANGE), - &conditions(request), - ) { + let prepared = match self + .execution + .prepare_get(&record, header(request, RANGE), &conditions(request)) + { Ok(prepared) => prepared, Err(RetrievalError::NotModified) => return not_modified(&record.etag), Err(error) => return Err(map_retrieval(&error)), @@ -383,25 +335,24 @@ impl ProductionS3Operations { .map_or(Ok(DEFAULT_LIST_LIMIT), |value| value.parse::()) .map_err(|_| S3ErrorCode::InvalidRequest)? .min(DEFAULT_LIST_LIMIT); - let page = object::list_v2( - &self.storage.metadata, - &self.signer, - &ListObjectsV2Request { - tenant: &self.config.tenant, - bucket: bucket_id, - prefix: &prefix, - delimiter: delimiter.as_deref(), - start_after: start_after.as_deref(), - continuation_token: continuation.as_deref(), - max_keys, - max_scan_items: self.config.list_scan_items, - max_scan_bytes: self.config.list_scan_bytes, - now_unix_seconds: unix_seconds(), - token_ttl_seconds: self.config.continuation_ttl_seconds, - }, - ) - .await - .map_err(|error| map_object_error(&error))?; + let page = self + .execution + .list_objects( + &self.signer, + &ObjectListRequest { + bucket: bucket_id, + prefix: &prefix, + delimiter: delimiter.as_deref(), + start_after: start_after.as_deref(), + continuation_token: continuation.as_deref(), + max_keys, + max_scan_items: self.config.list_scan_items, + max_scan_bytes: self.config.list_scan_bytes, + now_unix_seconds: unix_seconds(), + token_ttl_seconds: self.config.continuation_ttl_seconds, + }, + ) + .await?; xml_response(wire::list_objects( bucket_name, &prefix, @@ -416,67 +367,18 @@ impl ProductionS3Operations { async fn delete_object(&self, route: S3Route) -> Result, S3ErrorCode> { let bucket_name = required_bucket(&route)?; let key = required_key(&route)?; - let bucket_id = self.resolve_bucket(bucket_name).await?; - object::delete(&self.storage.metadata, &self.config.tenant, bucket_id, key) - .await - .map_err(|error| map_object_error(&error))?; + self.execution.delete_object(bucket_name, key).await?; response(StatusCode::NO_CONTENT, Vec::new()) } async fn resolve_bucket(&self, name: &[u8]) -> Result { - bucket::head_bucket(&self.storage.metadata, &self.config.tenant, name) - .await - .map_err(|error| map_bucket_error(&error))? - .ok_or(S3ErrorCode::NoSuchBucket) + self.execution.resolve_bucket(name).await } async fn object(&self, route: &S3Route) -> Result { - let bucket = self.resolve_bucket(required_bucket(route)?).await?; - object::head( - &self.storage.metadata, - &self.config.tenant, - bucket, - required_key(route)?, - ) - .await - .map_err(|error| map_object_error(&error))? - .ok_or(S3ErrorCode::NoSuchKey) - } - - async fn prepare_writer( - &self, - content_length: Option, - key: &[u8], - ) -> Result { - if let Some(length) = content_length - .and_then(|length| usize::try_from(length).ok()) - .filter(|length| *length <= self.config.small_object_limit) - { - if length > crowdb_protocol::frame::MAX_FRAME_V2_PAYLOAD_BYTES { - return self - .storage - .chunks - .prepare_shared_object_write_for_key(length, key) - .await - .map(ObjectWriter::Small) - .map_err(|_| S3ErrorCode::SlowDown); - } - match self.storage.chunks.prepare_small_write_for_key(length, key).await { - Ok(writer) => return Ok(ObjectWriter::Small(writer)), - Err(IoError::ObjectTooLarge { .. }) => {} - Err(IoError::MemoryBudgetExhausted) => return Err(S3ErrorCode::SlowDown), - Err(_) => return Err(S3ErrorCode::ServiceUnavailable), - } - } - let mut prepared = self - .storage - .chunks - .prepare_large_write(content_length, self.config.large_write.clone()); - prepared.wait_until_prepared().await.map_err(|error| { - tracing::warn!(%error, "S3 large write preparation failed"); - S3ErrorCode::ServiceUnavailable - })?; - Ok(ObjectWriter::Large(Box::new(prepared))) + self.execution + .object(required_bucket(route)?, required_key(route)?) + .await } } @@ -496,77 +398,6 @@ impl S3Operations for ProductionS3Operations { } } -enum ObjectWriter { - Small(SharedObjectWriter), - Large(Box), -} - -impl ObjectWriter { - fn is_large(&self) -> bool { - matches!(self, Self::Large(_)) - } -} - -#[async_trait::async_trait] -impl ChunkIoWriter for ObjectWriter { - async fn on_data( - &mut self, - buffer: hyper::body::Bytes, - ) -> crowdb_chunk_client::Result { - match self { - Self::Small(writer) => writer.on_data(buffer).await, - Self::Large(writer) => writer.on_data(buffer).await, - } - } - - async fn on_framed_data( - &mut self, - buffer: Box, - ) -> crowdb_chunk_client::Result { - match self { - Self::Small(_) => Err(IoError::WriteFailed( - "small object writer does not accept framed owners".into(), - )), - Self::Large(writer) => writer.on_framed_data(buffer).await, - } - } - - async fn on_finish(&mut self) -> crowdb_chunk_client::Result> { - match self { - Self::Small(writer) => writer.finish_durable().await, - Self::Large(writer) => writer.on_finish().await, - } - } - - async fn on_error(&mut self) -> crowdb_chunk_client::Result> { - match self { - Self::Small(writer) => writer.on_error().await, - Self::Large(writer) => writer.on_error().await, - } - } - - fn require_data(&self) -> bool { - match self { - Self::Small(writer) => writer.require_data(), - Self::Large(writer) => writer.require_data(), - } - } - - fn input_complete(&self) -> bool { - match self { - Self::Small(writer) => writer.input_complete(), - Self::Large(writer) => writer.input_complete(), - } - } - - async fn wait_for_capacity(&mut self) { - match self { - Self::Small(writer) => writer.wait_for_capacity().await, - Self::Large(writer) => writer.wait_for_capacity().await, - } - } -} - fn required_bucket(route: &S3Route) -> Result<&[u8], S3ErrorCode> { route.bucket.as_deref().ok_or(S3ErrorCode::InvalidRequest) } @@ -668,24 +499,6 @@ fn map_retrieval(error: &RetrievalError) -> S3ErrorCode { } } -fn map_bucket_error(error: &BucketError) -> S3ErrorCode { - match error { - BucketError::Key(_) => S3ErrorCode::InvalidRequest, - BucketError::Record(_) => S3ErrorCode::InternalError, - BucketError::Store(_) => S3ErrorCode::ServiceUnavailable, - } -} - -fn map_object_error(error: &ObjectMetadataError) -> S3ErrorCode { - match error { - ObjectMetadataError::Key(_) - | ObjectMetadataError::Continuation(_) - | ObjectMetadataError::InvalidListLimit => S3ErrorCode::InvalidRequest, - ObjectMetadataError::Record(_) => S3ErrorCode::InternalError, - ObjectMetadataError::Store(_) => S3ErrorCode::ServiceUnavailable, - } -} - fn map_put_outcome(outcome: &PutOutcome) -> S3ErrorCode { if matches!( outcome, diff --git a/app/crowdb-access-server/src/s3/operations/copy.rs b/app/crowdb-access-server/src/s3/operations/copy.rs index a8e177752..910c28e15 100644 --- a/app/crowdb-access-server/src/s3/operations/copy.rs +++ b/app/crowdb-access-server/src/s3/operations/copy.rs @@ -5,20 +5,16 @@ use crowdb_access_s3::condition::ObjectConditions; use crowdb_access_s3::error::S3ErrorCode; -use crowdb_access_s3::integrity::SinglePartIntegrity; -use crowdb_access_s3::metadata::{MultipartPartRecord, ObjectRecord}; -use crowdb_access_s3::publication::PublicationRequest; +use crowdb_access_s3::execution::{CopyFailure, CopyMetadata, S3WritePolicy}; +use crowdb_access_s3::metadata::ObjectRecord; use crowdb_access_s3::route::{S3Operation, S3Route}; -use crowdb_access_s3::streaming::{publish_completed_locations, PutOutcome}; use crowdb_access_s3::{copy, retrieval, wire}; -use crowdb_chunk_client::{ChunkIoWriter, ProtoLocation}; use hyper::{body::Incoming, Request, Response}; use std::sync::Arc; -use super::multipart::{map_multipart_error, parse_md5}; use super::{ - map_put_outcome, map_retrieval, required_bucket, required_key, strict_header, unix_millis, ObjectWriter, - ProductionS3Operations, ResponseBody, + map_put_outcome, map_retrieval, required_bucket, required_key, strict_header, ProductionS3Operations, + ResponseBody, }; impl ProductionS3Operations { @@ -78,9 +74,8 @@ impl ProductionS3Operations { Some("REPLACE") => true, _ => return Err(S3ErrorCode::InvalidRequest), }; - let attributes = - crowdb_access_s3::metadata::UserMetadata::from_headers(request.headers())?.encode()?; - let mut record = self.copy_source(request, false).await?; + let attributes = crowdb_access_s3::metadata::UserMetadata::from_headers(request.headers())?; + let record = self.copy_source(request, false).await?; let destination = self.resolve_bucket(required_bucket(&route)?).await?; let key = required_key(&route)?.to_vec(); copy::validate_object(&record, destination, &key, replace)?; @@ -91,45 +86,40 @@ impl ProductionS3Operations { } else { record.content_type.clone() }; - let mut writer_key = self.config.tenant.as_bytes().to_vec(); - writer_key.extend_from_slice(destination.as_bytes()); - writer_key.extend_from_slice(&key); - let source = record.clone(); - record.bucket_id = destination; - record.key = key; - record.content_type = content_type; - if replace { - record.attributes = attributes; - } + let metadata = replace.then_some(CopyMetadata { + content_type, + user_metadata: attributes, + }); let operations = self.clone(); crate::s3::copy_body::response( - async move { operations.finish_object_copy(source, record, writer_key).await }, + async move { + let selected = operations + .execution + .copy_object_record( + &record, + destination, + &key, + metadata, + &S3WritePolicy { + small_object_limit: operations.config.small_object_limit, + large_write: operations.config.large_write.clone(), + }, + ) + .await + .map_err(|error| match error { + CopyFailure::S3(code) => code, + CopyFailure::Publication(outcome) => map_put_outcome(&outcome), + })?; + Ok(wire::copy_result( + &selected.record.etag, + selected.record.modified_at_ms, + false, + )) + }, request.uri().path().to_owned(), ) } - async fn finish_object_copy( - &self, - source: ObjectRecord, - mut record: ObjectRecord, - writer_key: Vec, - ) -> Result { - let (etag, checksum, locations) = self.stream_copy(&source, None, &writer_key).await?; - let now = unix_millis(); - record.etag = etag.clone(); - record.checksum = checksum; - record.created_at_ms = now; - record.modified_at_ms = now; - let mut publication = PublicationRequest { - tenant: self.config.tenant.clone(), - object: record, - }; - match publish_completed_locations(&self.storage.metadata, &mut publication, &locations).await { - PutOutcome::Success => Ok(wire::copy_result(&etag, now, false)), - outcome => Err(map_put_outcome(&outcome)), - } - } - pub(super) async fn upload_part_copy( self: &Arc, route: S3Route, @@ -149,95 +139,31 @@ impl ProductionS3Operations { if length > session.max_part_bytes { return Err(S3ErrorCode::InvalidRequest); } - let mut writer_key = self.config.tenant.as_bytes().to_vec(); - writer_key.extend_from_slice(session.bucket_id.as_bytes()); - writer_key.extend_from_slice(&session.upload_id); - writer_key.extend_from_slice(&number.to_be_bytes()); let range = range.map(str::to_owned); let operations = self.clone(); crate::s3::copy_body::response( async move { - let (etag, _, locations) = operations - .stream_copy(&record, range.as_deref(), &writer_key) - .await?; - let now = unix_millis(); - let part = MultipartPartRecord { - bucket_id: session.bucket_id, - upload_id: session.upload_id, - number, - revision: 1, - modified_ms: now, - length, - raw_md5: parse_md5(&etag)?, - locations, - }; - operations - .multipart() - .put_stream_part(&session, &part, now) + let part = operations + .execution + .copy_part_record( + &record, + &session, + number, + range.as_deref(), + &S3WritePolicy { + small_object_limit: operations.config.small_object_limit, + large_write: operations.config.large_write.clone(), + }, + ) .await - .map_err(|error| map_multipart_error(&error))? - .ok_or(S3ErrorCode::SlowDown)?; + .map_err(|error| error.code())?; + let etag = crowdb_access_s3::integrity::raw_md5_etag(part.raw_md5); + let now = part.modified_ms; Ok(wire::copy_result(&etag, now, true)) }, request.uri().path().to_owned(), ) } - - async fn stream_copy( - &self, - record: &ObjectRecord, - range: Option<&str>, - key: &[u8], - ) -> Result<(String, Vec, Vec), S3ErrorCode> { - let mut prepared = - retrieval::prepare_get(&self.storage.chunks, record, range, &ObjectConditions::default()) - .map_err(|error| map_retrieval(&error))?; - let length = prepared.headers.content_length; - let mut writer = self.prepare_writer(Some(length), key).await?; - let result = feed_copy(&mut prepared.stream, &mut writer, length).await; - match result { - Ok((etag, checksum)) => { - let Ok(locations) = writer.on_finish().await else { - let _ = writer.on_error().await; - return Err(S3ErrorCode::ServiceUnavailable); - }; - Ok((etag, checksum, locations)) - } - Err(error) => { - let _ = writer.on_error().await; - Err(error) - } - } - } -} - -async fn feed_copy( - stream: &mut Option, - writer: &mut ObjectWriter, - length: u64, -) -> Result<(String, Vec), S3ErrorCode> { - let mut integrity = SinglePartIntegrity::default(); - let mut actual = 0_u64; - if let Some(stream) = stream { - while let Some(bytes) = stream.next_chunk().await { - let bytes = bytes.map_err(|_| S3ErrorCode::ServiceUnavailable)?; - actual = actual - .checked_add(bytes.len() as u64) - .ok_or(S3ErrorCode::InternalError)?; - integrity.update(&bytes); - while !writer.require_data() { - writer.wait_for_capacity().await; - } - writer - .on_data(bytes) - .await - .map_err(|_| S3ErrorCode::ServiceUnavailable)?; - } - } - if actual != length { - return Err(S3ErrorCode::InternalError); - } - Ok(integrity.finish()) } fn source_conditions(request: &Request) -> Result, S3ErrorCode> { diff --git a/app/crowdb-access-server/src/s3/operations/delete.rs b/app/crowdb-access-server/src/s3/operations/delete.rs index df29c62be..fa1041b66 100644 --- a/app/crowdb-access-server/src/s3/operations/delete.rs +++ b/app/crowdb-access-server/src/s3/operations/delete.rs @@ -5,7 +5,7 @@ use crowdb_access_s3::delete::{DeleteSelection, MAX_DELETE_BODY}; use crowdb_access_s3::error::S3ErrorCode; -use crowdb_access_s3::{object, route::S3Route, wire}; +use crowdb_access_s3::{route::S3Route, wire}; use futures::{stream, StreamExt}; use http_body_util::BodyExt as _; use http_body_util::StreamBody; @@ -15,9 +15,7 @@ use hyper::{ Request, Response, StatusCode, }; -use super::{ - map_object_error, required_bucket, strict_header, BoxError, ProductionS3Operations, ResponseBody, -}; +use super::{required_bucket, strict_header, BoxError, ProductionS3Operations, ResponseBody}; impl ProductionS3Operations { pub(super) async fn delete_objects( @@ -58,21 +56,12 @@ impl ProductionS3Operations { let bytes = Bytes::from(bytes); crowdb_access_s3::delete::validate_integrity(&headers, &bytes)?; let selection = DeleteSelection::parse(&bytes)?; - let metadata = self.storage.metadata.clone(); - let tenant = self.config.tenant.clone(); let quiet = selection.quiet; - let results = selection.stream(move |key| { - let metadata = metadata.clone(); - let tenant = tenant.clone(); - async move { - object::delete(&metadata, &tenant, bucket, &key) - .await - .map_err(|error| map_object_error(&error)) - } - })?; + let results = self.execution.delete_objects(bucket, &selection)?; // Send progress while preserving one ordered mutation at a time. // Quiet successes emit XML whitespace so idle-read deadlines still see progress. let results = results.map(move |(key, result)| { + let result = result.map_err(|error| error.code()); let xml = wire::delete_object_result(&key, &result, quiet); let bytes = if xml.is_empty() { Bytes::from_static(b"\n") diff --git a/app/crowdb-access-server/src/s3/operations/multipart.rs b/app/crowdb-access-server/src/s3/operations/multipart.rs index 6c69195e0..d73b8c94f 100644 --- a/app/crowdb-access-server/src/s3/operations/multipart.rs +++ b/app/crowdb-access-server/src/s3/operations/multipart.rs @@ -3,16 +3,15 @@ //! Authenticated S3 multipart operations over durable session and part records. -use crowdb_access_s3::bucket; use crowdb_access_s3::error::S3ErrorCode; +use crowdb_access_s3::execution::{CopyMetadata, S3WritePolicy}; use crowdb_access_s3::integrity::{IntegrityError, SinglePartIntegrity}; use crowdb_access_s3::metadata::{ - new_upload_id, CompletionPart, MultipartPartRecord, MultipartPhase, MultipartRepository, + CompletionPart, MultipartExpiryError, MultipartExpiryStep, MultipartExpirySweep, MultipartRepository, MultipartRepositoryError, MultipartSessionRecord, }; use crowdb_access_s3::route::{S3Operation, S3Route}; use crowdb_access_s3::wire; -use crowdb_chunk_client::ChunkIoWriter; use http_body_util::BodyExt as _; use hyper::body::Bytes; use hyper::body::Incoming; @@ -26,75 +25,43 @@ use super::{ }; use crate::multipart_complete::{CompleteRequestError, CompleteSelection}; -const MAX_PARTS: u16 = 10_000; -const MAX_PART_BYTES: u64 = 5 * 1024 * 1024 * 1024; -const MAX_OBJECT_BYTES: u64 = 5 * 1024 * 1024 * 1024 * 1024; -const MAX_STAGED_BYTES: u64 = 10_000 * MAX_PART_BYTES; -const UPLOAD_LIFETIME_MS: u64 = 7 * 24 * 60 * 60 * 1000; const MAX_COMPLETE_BODY: usize = 2 * 1024 * 1024; impl ProductionS3Operations { - /// Walks bounded metadata pages and marks expired sessions terminal. + /// Starts discardable expiry positions for the configured tenant. + #[must_use] + pub fn multipart_expiry_sweep(&self) -> MultipartExpirySweep { + MultipartExpirySweep::new(self.storage.metadata.clone(), self.config.tenant.clone()) + } + + /// Marks expired sessions within one bounded background window. /// /// # Errors /// Defers the sweep when bucket or upload metadata is unavailable. - pub async fn expire_multipart_uploads(&self) -> Result { - let buckets = bucket::list_buckets(&self.storage.metadata, &self.config.tenant, 1_001) - .await - .map_err(|_| S3ErrorCode::ServiceUnavailable)?; - if buckets.len() > 1_000 { - return Err(S3ErrorCode::SlowDown); - } - let now = unix_millis(); - let mut expired = 0; - for bucket in buckets { - let mut cursor = None; - loop { - let page = self - .multipart() - .expire_page(bucket.bucket_id, cursor.as_deref(), now, 1_000) - .await - .map_err(|error| map_multipart_error(&error))?; - expired += page.expired; - let Some(next) = page.next else { - break; - }; - cursor = Some(next); - tokio::task::yield_now().await; - } - } - Ok(expired) + pub async fn expire_multipart_uploads( + &self, + sweep: &mut MultipartExpirySweep, + ) -> Result { + sweep.step(unix_millis()).await } pub(super) fn multipart(&self) -> MultipartRepository { - MultipartRepository::new(self.storage.metadata.clone(), self.config.tenant.clone()) + self.execution.multipart_repository() } pub(super) async fn multipart_identity( &self, route: &S3Route, ) -> Result { - let bucket = self.resolve_bucket(required_bucket(route)?).await?; - let key = required_key(route)?; - let upload_id = route.upload_id.ok_or(S3ErrorCode::InvalidRequest)?; - let session = self - .multipart() - .load_identity(bucket, key, &upload_id) + self.execution + .multipart_session( + required_bucket(route)?, + required_key(route)?, + &route.upload_id.ok_or(S3ErrorCode::InvalidRequest)?, + route.operation == S3Operation::AbortMultipartUpload, + ) .await - .map_err(|error| map_multipart_error(&error))? - .ok_or(S3ErrorCode::NoSuchUpload)?; - if session.phase == MultipartPhase::Open && session.expires_ms <= unix_millis() { - let expired = self - .multipart() - .abort(&session) - .await - .map_err(|error| map_multipart_error(&error))?; - if route.operation != S3Operation::AbortMultipartUpload { - return Err(S3ErrorCode::NoSuchUpload); - } - return Ok(expired); - } - Ok(session) + .map_err(|error| error.code()) } pub(super) async fn create_multipart_upload( @@ -104,43 +71,23 @@ impl ProductionS3Operations { ) -> Result, S3ErrorCode> { let bucket_name = required_bucket(&route)?; let key = required_key(&route)?; - let bucket_id = self.resolve_bucket(bucket_name).await?; if content_length(request)?.is_some_and(|length| length != 0) { return Err(S3ErrorCode::InvalidRequest); } - let now = unix_millis(); - let session = MultipartSessionRecord { - bucket_id, - object_key: key.to_vec(), - upload_id: new_upload_id(now), - revision: 1, - phase: MultipartPhase::Open, - created_ms: now, - expires_ms: now - .checked_add(UPLOAD_LIFETIME_MS) - .ok_or(S3ErrorCode::InternalError)?, - content_type: strict_header(request, "content-type", S3ErrorCode::InvalidRequest)? - .unwrap_or("application/octet-stream") - .to_owned(), - attributes: crowdb_access_s3::metadata::UserMetadata::from_headers(request.headers())? - .encode()?, - max_parts: MAX_PARTS, - max_part_bytes: MAX_PART_BYTES, - max_object_bytes: MAX_OBJECT_BYTES, - max_staged_bytes: MAX_STAGED_BYTES, - part_count: 0, - staged_bytes: 0, - pending: None, - selection: None, - completion_request_digest: None, - publication_ms: None, - object_predecessor: None, - etag: None, - }; - self.multipart() - .begin(&session) + let session = self + .execution + .begin_multipart( + bucket_name, + key, + CopyMetadata { + content_type: strict_header(request, "content-type", S3ErrorCode::InvalidRequest)? + .unwrap_or("application/octet-stream") + .to_owned(), + user_metadata: crowdb_access_s3::metadata::UserMetadata::from_headers(request.headers())?, + }, + ) .await - .map_err(|error| map_multipart_error(&error))?; + .map_err(|error| error.code())?; xml_response(wire::create_multipart_upload( bucket_name, key, @@ -165,12 +112,20 @@ impl ProductionS3Operations { let payload_sha256 = strict_header(&request, "x-amz-content-sha256", S3ErrorCode::InvalidRequest)? .filter(|value| *value != "UNSIGNED-PAYLOAD" && streaming.is_none()) .map(str::to_owned); - let mut route_key = self.config.tenant.as_bytes().to_vec(); - route_key.extend_from_slice(session.bucket_id.as_bytes()); - route_key.extend_from_slice(&session.upload_id); - route_key.extend_from_slice(&number.to_be_bytes()); - let mut writer = self.prepare_writer(Some(length), &route_key).await?; - let native_receiver = if writer.is_large() && streaming.is_none() { + let mut part = self + .execution + .prepare_part( + &session, + number, + length, + &S3WritePolicy { + small_object_limit: self.config.small_object_limit, + large_write: self.config.large_write.clone(), + }, + ) + .await + .map_err(|error| error.code())?; + let native_receiver = if part.is_large() && streaming.is_none() { install_body_receive_provider(&mut request) } else { None @@ -189,7 +144,7 @@ impl ProductionS3Operations { body.defer_md5(); let written = write_object_body( &mut body, - &mut writer, + part.writer_mut(), native_receiver.as_deref(), Some(length), content_md5.as_deref(), @@ -198,39 +153,16 @@ impl ProductionS3Operations { self.metrics.as_deref(), ) .await; - let (etag, _) = match written { + let (etag, checksum) = match written { Ok(value) => value, Err(outcome) => { - let _ = writer.on_error().await; + part.abort().await; return Err(body .failure() .map_or_else(|| map_put_outcome(&outcome), super::encoding_error)); } }; - let locations = writer.on_finish().await.map_err(|error| { - tracing::error!(%error, "multipart part finalization failed; returning ServiceUnavailable"); - S3ErrorCode::ServiceUnavailable - })?; - let actual: u64 = locations.iter().map(|location| location.logical_length).sum(); - if actual != length { - return Err(S3ErrorCode::InvalidRequest); - } - let part = MultipartPartRecord { - bucket_id: session.bucket_id, - upload_id: session.upload_id, - number, - revision: 1, - modified_ms: unix_millis(), - length, - raw_md5: parse_md5(&etag)?, - locations, - }; - let _saved = self - .multipart() - .put_stream_part(&session, &part, part.modified_ms) - .await - .map_err(|error| map_multipart_error(&error))? - .ok_or(S3ErrorCode::SlowDown)?; + part.finish(checksum).await.map_err(|error| error.code())?; Response::builder() .status(StatusCode::OK) .header(ETAG, format!("\"{etag}\"")) @@ -306,10 +238,10 @@ impl ProductionS3Operations { route: S3Route, ) -> Result, S3ErrorCode> { let session = self.multipart_identity(&route).await?; - self.multipart() - .abort(&session) + self.execution + .abort_multipart(&session) .await - .map_err(|error| map_multipart_error(&error))?; + .map_err(|error| error.code())?; response(StatusCode::NO_CONTENT, Vec::new()) } @@ -343,37 +275,22 @@ impl ProductionS3Operations { etag: part.etag.clone(), }) .collect(); - let frozen = self - .multipart() - .freeze_completion(&session, &requested, unix_millis()) - .await - .map_err(|error| map_multipart_error(&error))? - .ok_or(S3ErrorCode::ServiceUnavailable)?; let published = self - .multipart() - .publish_completion(&frozen) + .execution + .complete_multipart(&session, &requested) .await - .map_err(|error| map_multipart_error(&error))?; + .map_err(|error| error.code())?; xml_response(wire::complete_multipart_upload( &location, required_bucket(&route)?, required_key(&route)?, - published.etag.as_deref().ok_or(S3ErrorCode::InternalError)?, + &published.etag, )) } } pub(super) fn map_multipart_error(error: &MultipartRepositoryError) -> S3ErrorCode { - match error { - MultipartRepositoryError::Key(_) | MultipartRepositoryError::Record(_) => S3ErrorCode::InvalidRequest, - MultipartRepositoryError::Store(_) => S3ErrorCode::ServiceUnavailable, - MultipartRepositoryError::Conflict => S3ErrorCode::NoSuchUpload, - MultipartRepositoryError::Busy | MultipartRepositoryError::ScanBudgetExhausted => { - S3ErrorCode::SlowDown - } - MultipartRepositoryError::InvalidPart => S3ErrorCode::InvalidPart, - MultipartRepositoryError::EntityTooSmall => S3ErrorCode::EntityTooSmall, - } + crowdb_access_s3::execution::map_multipart_error(error) } fn map_integrity_error(error: IntegrityError) -> S3ErrorCode { @@ -411,21 +328,6 @@ fn parse_upload_id(value: &str) -> Result<[u8; 16], S3ErrorCode> { (id != [0; 16]).then_some(id).ok_or(S3ErrorCode::InvalidRequest) } -pub(super) fn parse_md5(value: &str) -> Result<[u8; 16], S3ErrorCode> { - if value.len() != 32 { - return Err(S3ErrorCode::InternalError); - } - let mut md5 = [0; 16]; - for (byte, pair) in md5.iter_mut().zip(value.as_bytes().chunks_exact(2)) { - *byte = u8::from_str_radix( - std::str::from_utf8(pair).map_err(|_| S3ErrorCode::InternalError)?, - 16, - ) - .map_err(|_| S3ErrorCode::InternalError)?; - } - Ok(md5) -} - async fn read_completion(mut body: Incoming) -> Result, S3ErrorCode> { let mut bytes = Vec::new(); while let Some(frame) = body.frame().await { diff --git a/app/crowdb-access-server/src/s3/operations/upload.rs b/app/crowdb-access-server/src/s3/operations/upload.rs index 1980416b8..16f92ff2e 100644 --- a/app/crowdb-access-server/src/s3/operations/upload.rs +++ b/app/crowdb-access-server/src/s3/operations/upload.rs @@ -13,14 +13,14 @@ use crowdb_access_s3::streaming::{PutErrorCode, PutOutcome}; use crowdb_chunk_client::FramedWriteBuffer; use crowdb_protocol::frame::FrameMagic; use http_body_util::BodyExt; -use hyper::body::{Bytes, Incoming}; +use hyper::body::{Body, Bytes}; use tokio::sync::mpsc; use crate::upload_flow::body_encoding::{UploadBody, UploadEncodingError}; use crate::upload_flow::digest_pipe::DigestPipe; use crate::upload_flow::{drive_transfer, write_buffers, OfferStatus, UploadBuffer, WriteFlow}; -use super::ObjectWriter; +use crowdb_chunk_client::ChunkIoWriter; const TARGET_BUFFER_BYTES: usize = 1024 * 1024; @@ -32,16 +32,20 @@ fn failed(code: PutErrorCode, message: &impl ToString) -> PutOutcome { } #[allow(clippy::too_many_arguments)] -pub(super) async fn write_object_body( - body: &mut UploadBody, - writer: &mut ObjectWriter, +pub(crate) async fn write_object_body( + body: &mut UploadBody, + writer: &mut W, native_receiver: Option<&NativeBodyReceiver>, declared_length: Option, expected_content_md5: Option<&str>, expected_payload_sha256: Option<&str>, held_buffers: usize, metrics: Option<&S3Metrics>, -) -> Result<(String, Vec), PutOutcome> { +) -> Result<(String, Vec), PutOutcome> +where + B: Body + Unpin, + W: ChunkIoWriter + ?Sized, +{ let (sender, receiver) = mpsc::channel(held_buffers); let progress = AtomicU64::new(0); let queued_peak = AtomicU64::new(0); @@ -87,8 +91,8 @@ pub(super) async fn write_object_body( }) } -async fn receive_body( - body: &mut UploadBody, +async fn receive_body + Unpin>( + body: &mut UploadBody, native_receiver: Option<&NativeBodyReceiver>, declared_length: Option, flow: WriteFlow<'_>, @@ -99,7 +103,7 @@ async fn receive_body( .unwrap_or(TARGET_BUFFER_BYTES) .clamp(1, TARGET_BUFFER_BYTES); let mut length = 0u64; - let mut pending = Vec::with_capacity(if native_receiver.is_some() { 0 } else { target }); + let mut pending = Vec::new(); let mut digest_pending = Vec::with_capacity(16); loop { let Some(frame) = body.frame().await else { break }; @@ -126,12 +130,27 @@ async fn receive_body( } } else { while !bytes.is_empty() { + if pending.is_empty() && bytes.len() >= target { + let owner = bytes.split_to(target); + digest_pending.push(owner.clone()); + handoff( + &flow, + digest, + UploadBuffer::Data(owner), + std::mem::take(&mut digest_pending), + ) + .await?; + continue; + } + if pending.capacity() == 0 { + pending.reserve_exact(target); + } let count = (target - pending.len()).min(bytes.len()); let piece = bytes.split_to(count); pending.extend_from_slice(&piece); digest_pending.push(piece); if pending.len() == target { - let owner = Bytes::from(std::mem::replace(&mut pending, Vec::with_capacity(target))); + let owner = Bytes::from(std::mem::take(&mut pending)); handoff( &flow, digest, diff --git a/app/crowdb-access-server/src/s3/test_support.rs b/app/crowdb-access-server/src/s3/test_support.rs new file mode 100644 index 000000000..28205339c --- /dev/null +++ b/app/crowdb-access-server/src/s3/test_support.rs @@ -0,0 +1,59 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +//! Production HTTP body transfer with injected storage and placement. + +use crowdb_access_s3::native_buffer::NativeBodyAllocator; +use crowdb_access_s3::{metrics::S3Metrics, streaming::PutOutcome}; +use crowdb_chunk_client::ChunkIoWriter; +use hyper::body::{Body, Bytes, Incoming}; +use hyper::Request; + +/// Exercise the production S3 body transfer through a real HTTP listener. +/// +/// # Errors +/// Returns the same framing, integrity or write failure as the production flow. +pub async fn write_large_body_for_tests( + mut request: Request, + writer: &mut impl ChunkIoWriter, + allocator: &NativeBodyAllocator, +) -> Result<(String, Vec), String> { + let receiver = crate::http_receive::install_native_body_receive_provider(&mut request, allocator); + receiver.enable_owner_handoff(); + let (parts, incoming) = request.into_parts(); + let mut body = + crate::upload_flow::body_encoding::UploadBody::new(incoming, &parts.headers, None, 64 << 20) + .map_err(|error| error.to_string())?; + body.defer_md5(); + let length = body.decoded_length(); + super::operations::write_object_body(&mut body, writer, Some(&receiver), length, None, None, 4, None) + .await + .map_err(|error| format!("{error:?}")) +} + +/// Exercises the production fallback handoff with caller-owned body frames. +/// +/// # Errors +/// Returns production body, checksum, writer and backpressure failures. +pub async fn write_fallback_body_for_tests + Unpin>( + body: &mut crate::upload_flow::body_encoding::UploadBody, + writer: &mut impl ChunkIoWriter, + held_buffers: usize, + expected_content_md5: Option<&str>, + expected_payload_sha256: Option<&str>, + metrics: Option<&S3Metrics>, +) -> Result<(String, Vec), PutOutcome> { + body.defer_md5(); + let length = body.decoded_length(); + super::operations::write_object_body( + body, + writer, + None, + length, + expected_content_md5, + expected_payload_sha256, + held_buffers, + metrics, + ) + .await +} diff --git a/app/crowdb-access-server/tests/README.md b/app/crowdb-access-server/tests/README.md index f7e75fc19..72d334bff 100644 --- a/app/crowdb-access-server/tests/README.md +++ b/app/crowdb-access-server/tests/README.md @@ -8,7 +8,7 @@ Client recipes: [container development](../../../container/single-node-container - REST/official-SDK conformance, native durability and ecosystem compatibility are distinct evidence. Passing SDK tests cannot certify compute engines. -- The [manual ecosystem fixture](../../../container/single-node-container/tests/ecosystem/README.md) +- The [manual ecosystem fixture](../../../e2e-test/clients/ecosystem/README.md) uses a separate locked environment and workflow dispatch. Its matrix records only actual pinned client operations against the container and a volume restart. - [S3 language SDKs](../../../e2e-test/clients/S3-SDK.md) and the Rust Iceberg SDK retain @@ -22,6 +22,205 @@ Client recipes: [container development](../../../container/single-node-container Provisioned allocation capacity bounds storage; cumulative orphan storage is not bounded by active request limits. +## Main integration acceptance + +The 2026-10-26 integration rebases all 252 local commits onto main +`94135bfeca2a04e59b5b3c7339d6300dc17f2042`. The range comparison retains +210 identical patches, 41 adapted patches and the corresponding standalone RPC +benchmark move. Integration commit `c550f4c5` preserves ordinary V2 frames, +owner-backed payload views, the reader layout cache, single-request synchronized +DiskIO writes and ordered Stream chunk records. Serving metadata maintenance +keeps progressing after one-record deletions and revisits unchanged generations. +Nonzero WAL trimming still requires durable expired-request floors and fails +closed while those floors are unavailable. + +The frozen service image is `crowdb-node:access-cleanup-v11-20261026`: + +- Build revision: `c550f4c5bd977c1287778fa17add9fb6fd79328f`. +- Source tree: `3483a0c16d1ad44e27626be1e2262ee4ddf1659b`. +- Imported Docker ID: + `sha256:10c1042b734b9af43223db3d3a71817839d0327e644b6588abb2bfed1599b255`. +- Runtime SHA-256: + `1f3f5444b801d5099dc6b1d6aeb9880c76db8803e09a8606edc61d23c1439443`. +- OCI receipt/config/layer digests, the imported Docker runtime configuration + and uncompressed layer identities, staged runtime hash and source archive + are independently verified. Docker conversion changes the local image ID; + it is recorded separately from the OCI manifest and config digests. + +The completed component inventories contain 129 storage/transport targets +(716 passing tests, one configured ignore) and 411 Access/KV targets +(2,438 passing tests, 13 configured ignores). Monitor preview adds four passes; +the independent HTTP SDK has 43 passing tests and all-target clippy success. +Workspace all-feature/all-target clippy, normal `rs-lint` with version/DashMap +checks, formatting and C++ tree-lint pass. C++ Tree, RPC, DiskIO and common +inventories complete, retaining their configured hardware skips. Python fixture +tests pass 252 cases and the benchmark runner passes 17. + +The first five-node KV group run missed its initial three-second leader wait. +The unchanged exact case passes alone; the complete group subsequently passes +131 cases under both default concurrency and serial execution. The failed log +and both full regressions are retained. High filesystem I/O pressure was present +on the first run; its causal role is unproven. Election timers and caller retry +behavior were unchanged. Other integration test repairs replace V1 geometry +literals with V2 protocol sizes and retain exact buffer-address, feed-count, +ownership and error assertions. + +Current-source real-service checks pass all five Dataset cases, including +installed portable SDKs and restart publication semantics. The Split case +performs five storage/Access restarts in 219.99 seconds within its unchanged +600-second bound. The extended two-cleaner GC matrix passes in 122.12 seconds +with three simultaneous cleaner restart rounds, nine dead files and successful +foreground work during deletion. It records the exact accepted large range +`[0,16786211)`, sealed boundary `16786432`, unchanged layout/epoch and +115,288 ms of final continuous drain. The external/tree/live-gap case passes +in 161.14 seconds. These establish notification acceptance and retained live +ranges; they make no physical-space-release or production latency claim. + +Strict local-HCA checks pass HTTP/ChunkIO to the separate O_DIRECT DiskIO +process, its interrupted-download case, and Chunk-KV large logical controls. +The normal DiskIO RDMA payload-copy, fallback and CQ error counters remain zero. +Chunk-KV uses the production RPC/service/native tree/WAL with injected catalog, +authority and memory ChunkStream backing. The separately activated native +Dataset accepted-read failure test also passes: exactly two accepted reads and +one reply, no replay or metadata re-selection, a valid held first buffer through +shutdown and returned credits after its final release. This is same-host HCA +functional evidence rather than cross-host or throughput measurement. + +The installed native Python SDK also passes the separate semantics-only workload: +mixed-size publication, continuation replay, cancellation and buffer lifetime, +65 seconds idle, and storage/Access restart. Both complete native S3 and Dataset +HCA corpora pass write and post-storage-restart verification. S3 covers host and +non-root container clients; Dataset uses the installed native Python client. +Strict RDMA remains required, and Dataset receive credits return after the last +held buffer is released. + +All ten ordered jobs, including image creation, finish successfully. Independent +verification preserves 1,432 raw evidence files with byte counts and SHA-256 +digests under +`.crowdb-runtime/persistent/task-checkpoints/access-simplification-20261026/evidence-current/`. +Its `verified-summary.json` maps cases to their original run identities; OCI, +source and provenance records are retained alongside them. The additional SDK +HCA terminal-read test is recorded separately in +`current-dataset-hca-terminal.json` and its complete log. Installed SDK, S3 HCA +and Dataset HCA run identities are respectively +`dataset-workload/65c5124702d840bcb825d8d42a6a42f2`, +`native-s3-hca/56ee513daa424136bcda3c580e8ab4fe` and +`native-dataset-hca/9465ca2f4c754fd096b519b0691cb214`. + +The preceding v10 throughput trials retain their original source boundary; +they are not measurements of this rebased service image. + +## Expanded cleanup and read observations + +The 2026-10-25 cleanup batch passes 1,842 tests across 407 targets in six +affected crates, with zero failures and 13 separately configured cases ignored. +Workspace all-target/all-feature clippy, normal lint, formatting, C++ tree lint, +184 fixture unit checks and the independent HTTP SDK gates pass. These gates +and the measurements below precede the subsequent rebase onto newer `main`. + +The measured image is `crowdb-node:access-cleanup-v10-20261025`, Docker ID +`sha256:c54fca81a58c4255ee38668a95f0dfa615111c35eda574cc0c9895fc043446ce`. +Its build revision is `8611270da82b1b49cf4619092f7961c3ff1c7ea5`, captured tree +`b733912cba7ea0c88d2c4d6a8ceb23043694f45f`, and runtime SHA256 +`00189c332c7eafc461390b8f09266f441c9af7e3de4eeb7912fc5c1ccd3dcccc`. +The later host-test correction changes no production source. Every selected +service run verifies the matching image and server-source identity. + +Seven real-service cases pass with their original budgets and deadlines: + +- The new two-cleaner matrix passes in + `container-e2e/run-0c6216e0dd5b4d3297eb08926ad1986a`. Both Access processes + are killed and restarted three times. Nine dead canonical files disappear, + their candidates and purge task complete, and 16,777,304 logical bytes are + accounted. A future deleted file remains retained at its original deadline + while a higher-ID expired file completes. Each cleaner records ten errors + scanning an independent corrupt catalog; its original bytes remain intact + while the healthy catalog converges. +- A real 16-MiB dedicated object is written and verified before cleanup. The + accepted retirement is exactly `[0,16785954)` within the unchanged sealed + extent of 16,786,432 bytes, with writer epoch zero. This verifies a range + larger than the unchanged 8-MiB payload budget: a deletion control notification + consumes one chunk-operation request and no referenced payload bytes. +- The matrix takes 119.03 seconds; its final uninterrupted drain takes + 105,874 ms. Forty-eight official-SDK table lifecycles and 64 native small + writes complete, with successful SDK requests inside three observed deletion + intervals. Table create/commit/load/read-metadata p99 values are + 305.612/44.717/4.532/14.729 ms. Table actions have 48 samples each; namespace + create/drop have four. These are finite same-host observations, without an + isolated baseline, a physical-capacity claim or an arbitrary-restart SLO. +- The unchanged external/tree/live-gap case passes in 167.38 seconds. All five + Dataset HTTP, SDK and restart cases also pass on this image. + +The first matrix execution fails its overlap assertion: sparse multipart +cleanup starts pressure at 51.59 seconds, but purge deletion starts at +106.92 seconds after the SDK load has ended. The host test now starts and +observes pressure using the selected purge's deletion count. The original +overlap assertion, deadlines, workload and production implementation remain. +The failed run `run-5de26741afd24a4ba63215ddf20a9d04` is retained separately. + +Dataset workload `dataset-workload/fb782f743944466ea6193a148dc46e69` passes all +18 trials, 4,608 complete requests and 18 completed observations, with zero +errors. Installed Python byte/continuation/Split checks and storage restart +also pass. The original eight sizes, concurrency one/three, two warmup rounds, +three repeats, sixteen rounds, integrity checks and 600-second child bound +remain. Client CPU is pinned to 2, services to 4–15, and metadata replicas retain +their 2-GiB cap. This is the TCP profile; earlier HCA evidence retains its own +image identity in [the Dataset/HCA record](#dataset-httpnative-comparison). + +| Path | Concurrency | MiB/s | Inner client CPU (s) | Lifetime client RSS (MiB) | +| ------------- | ----------- | ------ | -------------------- | ------------------------ | +| HTTP | 1 | 152.98 | 0.95 | 12.18 | +| Native Rust | 1 | 202.25 | 0.94 | 29.04 | +| Native Python | 1 | 190.04 | 0.97 | 55.14 | +| HTTP | 3 | 241.76 | 2.74 | 16.88 | +| Native Rust | 3 | 271.95 | 2.73 | 31.54 | +| Native Python | 3 | 269.04 | 2.82 | 63.96 | + +Throughput divides summed bytes by summed inner trial time; client CPU sums +inner ticks, while RSS is the maximum lifetime high-water observation. These +independent runs do not isolate the speedup of any individual patch. + +Every trial now records cumulative client-visible batch-header, first nonempty +delivery and verified-EOF offsets alongside the unchanged completion latency. +They are not wire-first-byte timings or additive phase durations. Python's +explicit `to_bytes` copies are counted separately. All twelve native trials +validate the sixteen existing shared-reader counter deltas, including positive +layout-query counts, against the raw boundary snapshots. Native metadata, +aggregate and data sent groups remain separate from those shared-reader metrics. +The selected counters are not all RPCs or all copies. Service CPU windows include +background work and sequential capture; they cannot be added to inner client CPU +as exact total-system cost. Process/cgroup peaks and held high-water observations +are cumulative, not per-trial peaks. TCP receive totals, all-copy bytes, precise +GC attribution and per-request server CPU remain unmeasured. + +The former `/tmp` summaries are absent when work resumes on 2026-10-26. The +original repository artifacts and OCI receipt remain available. A read-only +reconstruction verifies all eighteen raw phase/count/copy reports, resource and +transport deltas, and the seven passing service cases without rerunning them. +Raw records, reconstructed summaries, verification source, image/source archives +and a verified Git checkpoint are preserved locally under +`.crowdb-runtime/persistent/task-checkpoints/access-simplification-20261026`. +The immutable image results above do not certify later production changes from +rebasing onto `main`. + +## Historical evidence retention after shared-directory removal + +During an earlier Dataset run, the repository's `.crowdb-runtime` and +`target` directories disappeared. The deletion source is unconfirmed. Artifact +IDs below identify their completed executions; the original runtime directories +are no longer present. Source commits and Git bundle backups remain intact. + +The new image's 12-case acceptance stdout, component/lint logs, recovered GC +observations and all 18 Dataset trial JSON records are retained in +`/tmp/crowdb-access-continuation-evidence-20261025`. The Dataset trials complete +4,608 reads with zero errors, but the semantic process exits unsuccessfully and +its deleted stderr cannot be recovered. The runner then fails on missing log and +manifest paths. This execution does not pass installed-binding or restart +acceptance, and its service cgroup manifest is unavailable. Rerun only with a +stable artifact directory; do not substitute the completed trials for the missing +semantic/restart gate. The imported Docker image remains available independently +of the removed OCI archive. + ## Performance work to consolidate later - A native fault-matrix diagnostic run returned `Store(Client(Deadline))` from @@ -32,8 +231,8 @@ Client recipes: [container development](../../../container/single-node-container recurs; do not describe it as fixed by FileIO scheduling changes. - Native multipart diagnostics exposed unequal competing copy windows, tiny checkpoint-only leaves, repeated directory reads and duplicate JSON digest - passes. These targeted costs are removed. One-frame assembly overlap preserves - checkpoint/replay/error invariants. Broader batching, shared decoded caches, + passes. Assembly now recomputes from the complete selected-part input without + persisting byte progress. Broader batching, shared decoded caches, sustained throughput and recovery-page scaling remain measurement work, not implied guarantees from the original-bound functional fixture passing. @@ -65,6 +264,613 @@ Client recipes: [container development](../../../container/single-node-container - Create one consolidated optimization backlog later. No new performance backlog or latency guarantee is introduced by the functional-test split itself. +## Previous cleanup regression gates + +The preceding v9 six-crate all-target/all-feature gate passes 1,828 tests across 403 +targets, with zero failures and 13 separately configured cases ignored. +Workspace all-feature clippy, normal lint, both workspace formatting checks, +the container client build and 179 fixture unit tests pass. The matching local +image is `crowdb-node:access-cleanup-v9-20261025`, imported Docker ID +`sha256:67a1ef4f24901cee4be69b6951461b00059fcf58bed9d5d6960c0dcfd94f5de6`. +Its revision label is `0225659a7d632140e7d24fb86aa7fa989f51ffb8`; captured source +tree `cc7421928d395ee3a2e6a64f4eb35aca836d6d25` also includes the budgeted +Locations notification fix. Runtime SHA256 is +`e994c5dc0d7f89c4ec03519ec69f1ec3ea8046e53a53a6a2cbed497ec6812683`. +All eleven selected real-service cases pass with the original case deadlines +and GC budgets: + +- External Locations/tree notification convergence: + `container-e2e/run-48bc19a9be0a48dbaf013cf41efefe8a`. +- Catalog recovery through real Chunk-KV restart: + `container-e2e/run-c521bc97e14c42059584f25a8d3cf494`. +- Official namespace CRUD through storage/listener restart: + `container-e2e/run-42f6dcb9f2b2472c80bd774e2c8ed7d5`. +- Official SDK foreground work during GC backlog: + `container-e2e/run-8e25a3f3d11c428bb6beef0f15319fb4`. +- Seven S3 cases cover replacement parts, UploadPartCopy, boto3 checksums, + user metadata, lost replies, an open/replaced-part upload through metadata + restart, and HTTP/native parity. Their exact case/run mappings, every client + exit code and matching image identities are retained in + `/tmp/crowdb-access-cleanup-v9-evidence-20261025/acceptance-status.json`. + +Depth-one empty namespace deletion uses six GETs, two CAS operations, one exact +mapping delete and two scans, compared with the measured journal path's 51 +GETs, 13 CAS operations, one delete and two scans. Depth three uses ten GETs. +The two complete child proofs, generation fences and four catalog checks remain. +A fresh retry may delete a recreated empty name; one selected invocation cannot +delete its replacement, and a nonempty namespace still fails. Deterministic +component races cover late creation, rename, property updates, unknown writes +and retained legacy recovery against permanent retired identities. + +An ordinary independent MPU part's metadata path uses three GETs and two +conditional writes, compared with two GETs and four conditional writes. Distinct +parts perform no shared upload CAS. An initially matching pointer retains two +GETs and no metadata writes. Frozen generation A remains authoritative when an +already admitted replacement B completes later; the deterministic component +barrier proves that interleaving. The service cases do not claim to force it. +Strict older resources and tighter quotas retain their reservation protocol. +These are logical metadata-action counts, not routed wire totals or isolated +end-to-end TPS measurements. + +The external GC case completes its convergence window in 161,787 ms at task +revision 56 after one actual Access restart; the full client case takes 162.47 s. +Both original file keys are absent, their candidates and task are Complete, +and the fixed task identity/deadline survive restart. Two dead files contain +33,280 logical payload bytes. Three tree blocks and one external frame produce +the exact durable retired range union `[0,16964)` and `[17510,34212)`, totaling +33,666 chunk bytes including frame/directory overhead. The interleaved live +range `[16964,17510)` remains readable, layout identity is unchanged, and a +stale marker cannot retire the newer live head. This establishes notification +acceptance, without a physical free-space or GC latency SLO claim. + +The preceding v8 external case failed at its unchanged 480-second bound because +`BudgetedGcBlocks` inherited the default deferred `reclaim_location` instead of +dispatching to its configured provider. The wrapper now charges its existing +request/byte budget and forwards the operation and result unchanged. The failed +v8 evidence remains preserved independently. Final v9 source/image archives, +raw logs, range validation and gate outputs are retained outside the repository +in `/tmp/crowdb-access-cleanup-v9-evidence-20261025`. Dataset read and transport +paths did not change in this batch; their completed v8 workload/HCA evidence +below retains its own image and source identity. + +Source `b566fdcd` adds compact upload-resource storage, one-step ordinary inline +purge completion and request-local Dataset delivery reductions. The upload/GC +batch passes 1,709 tests across five affected crates, with 13 independently +configured cases ignored. The subsequent Dataset/Access/native batch passes +669 tests, with the same 13 configured cases ignored. These suites overlap; +their counts are not additive. Workspace all-target/all-feature clippy, normal +lint, formatting and focused final regressions pass. The matching v7 image from +`a78e981f` passes six S3, five Dataset and five GC cases, plus the complete +TCP/installed-Python/restart workload. The v6 results below cover `c22bc40e`. + +Source `d400ae21` also discovers expired multipart uploads by immutable bucket +identity after a bucket name is deleted and recreated. A production-actor +regression fails on the preceding scanner and passes with the fix. The affected +S3/Access/native suite passes 495 tests, with 12 separately configured cases +ignored; the final multipart target passes all 64 cases. Only expired Open +uploads are marked Aborted. Index, part and generation rows remain, and this +does not establish physical space recovery. Workspace all-feature clippy, +normal lint and formatting pass. Matching v7 service acceptance passes. + +The upload codec keeps all owner identities and the public JSON contract while +writing a fixed 52-byte header plus 16 bytes per identity. Its maximum record is +69,684 bytes. Legacy JSON keeps its original validation, and CAS conditions use +the actual stored bytes. JSON-only Access servers and direct native clients +require a coordinated upgrade. No resource key or foreground RPC is added. +The fixed legacy authority-read budget is retained. + +An exclusive CPU-2 release measurement uses seven samples of 128 iterations, +with the same logical 4,095 retired and two live owners. Canned retirement +includes GET cloning and exact CAS comparisons, without KV, WAL or network. +Compact input takes a median 25.644 us after sorted insertion, compared with +29.395 us using append-and-sort; validated compact round trips take 7.589 us. +Legacy JSON migration takes 654.204 us in the later run and 629.274 us in the +preceding run, so it is not reported as an insertion speedup. Raw samples, +timer-boundary measurements, source/binary hashes and scope are retained in +`/tmp/crowdb-c12-owner-cpu-20261025` and +`/tmp/crowdb-c12-owner-cpu-insert-20261025`. These are CPU observations, not TPS +or total-system efficiency claims. + +Source `c22bc40e` additionally includes retained point-operation buffers across +routed retries, observed-authority multipart expiry and reused validated inline +digests. The six affected crates pass 1,109 tests, with 13 separately configured +cases ignored. Workspace clippy, normal lint and formatting pass. Its matching +`access-cleanup-v6-20261025` image passes six S3 protocol cases and all five +Dataset cases (`run-8376c20aa6ea425583e2d8d6be33fcd9`), plus the complete +[TCP/restart workload](#dataset-httpnative-comparison). The five-case GC scope also passes in +`run-d56438cb607349f3a4d819402ee8d484`, including exact full inline-backlog +convergence: 16 real-service cases in total. + +Boundary regressions preserve the caller's point payload allocations through +first dispatch and routed retry. Expired multipart refresh uses one GET and one +CAS instead of two GETs and one CAS; independent public abort still reads its +own authority. Both native Dataset entry points verify inline corruption before +reusing the immutable result. These are copy/scan and logical-operation results, +not an isolated end-to-end throughput measurement of the changes. Raw v6 +artifacts and immutable image identity are independently retained in +`/tmp/crowdb-access-cleanup-v6-evidence-20261025`. + +The preceding source `ae4e1b8a` includes the bounded upload-retirement, Split-membership and +Iceberg receiver optimizations. All-target/all-feature Dataset/Access +Server/native coverage passes: 645 tests, 13 separately configured cases +ignored. Workspace clippy, `rs-lint` and formatting pass. +The matching `access-cleanup-v5-20261025` image passes 21 real-service cases: + +- All 16 Iceberg file cases: `run-f2d2bfc503214a77bae216d1cc8942f6`. +- All five Dataset cases: `run-23ba53d4761b494781c86b799899ad17`. + +The file scope includes the size matrix, 32 concurrent PUTs, 100-MiB PUT/MPU, +slow uploads, capacity failure/recovery, explicit cleanup and storage restart. +These functional checks do not measure receiver fast-path hit rate or copy CPU. +Executable cost regressions show ten shared upload references need one upload +record GET plus one CAS within a cleanup window, and twenty candidates in one +Split membership page need one bounded page read. Previously those paths used +ten and twenty reads respectively. These count logical metadata actions; +routed frame counts and end-to-end TPS savings are not inferred from them. + +Logs, image identity, summaries and raw evidence are independently backed up in +`/tmp/crowdb-access-cleanup-v5-evidence-20261025`. The +[current Dataset comparison](#dataset-httpnative-comparison) records the same +image's complete TCP workload and installed-binding restart acceptance. + +The preceding source `15efaaec` passes the broader affected +Dataset/Iceberg/S3/Access Server/native coverage across full runs and corrected +HTTP targets: +1,644 passed, 13 cases requiring a separate environment or measurement run +ignored. Workspace clippy, `rs-lint`, formatting, SDK dependency boundaries +and C++ tree lint pass. +The installed portable Python package passes 21 contract tests. + +That matching `access-cleanup-v4-20261025` image passes 18 real-service cases. +Artifact identifiers are relative to `.crowdb-runtime/artifacts/container-e2e/`: + +- Catalog creation/recovery: `run-3cbb504df0c6455e9613caf31be0ae1f`. +- Namespace CRUD/restart: `run-c1b02d1e879e49a881fbccbfec31cb8d`. +- Four GC cases: `run-5fb1fc306a394e59aefbc5393f3527b8`. +- Table metadata budget: `run-45c57f6f283a40fcbf47b6619377d189`. +- Five Dataset HTTP/SDK/restart cases: `run-05a9a16625e2492a84aab1953e2c7ec9`. +- S3 checksums/multipart: `run-3bf48fc5107b4433a48da5a8b6ab2e3c`. +- S3 fragmentation/boundaries: `run-f41c7b14cf57438fa374fd2393f3579d`. +- Independent S3 frontends: `run-4eb57d28dbc84853ac86fd1b802f7666`. +- Multipart copy/replaced parts: `run-76edf0fd31e341abb8f0cc3260254f82`. +- Selected multipart publication: `run-1cd9d713bdea4fac8b6104bbd79a7f99`. +- S3 metadata/copy/multipart: `run-51db5b3ff45340b18187286ca0a6cb65`. + +Logs, manifests, image identity and trial summaries are also retained outside +the runtime/build directories in `/tmp/crowdb-access-cleanup-v4-evidence-20261025`. +Its complete TCP workload also passed in +`dataset-workload/50925d2ddaf04a5da8fb1687132c4228`; that earlier image's +measurements remain evidence for its own source. + +## Earlier cleanup regression gates + +At source `be5eca5c`, the affected Iceberg crate passes all 823 tests; Access +Server passes 201 tests with 11 existing ignored cases. Workspace all-target / +all-feature clippy, normal `rs-lint`, formatting, the 163 fixture unit tests and +container-client compilation pass. + +The matching `access-cleanup-v3-20261025` image passes 12 affected real-service +cases. GC evidence is below. Namespace CRUD and native-storage/listener restart +pass in `container-e2e/run-79033ed427994b0ca117463063119374`. +Seven independent `s3.protocol` cases pass with the original checks and timeouts: + +- AWS chunked trailers: `run-b59ea5a1911247dc802bbe593b2357df`. +- Default boto3 checksums and multipart: `run-704e090a405f47b9835b83e98e96b75d`. +- Fragmentation and storage boundaries: `run-a4f03ef2a6b642daa229a5b03af0b6f1`. +- Ordinary PUT size matrix: `run-37c54686be42429d8cee6ecf9de4afa3`. +- Slow signed upload and buffer release: `run-4b60cd2091a5470dafcc065bd1214c42`. +- Truncated signed upload and credit release: `run-b757efd59d334ed58237fd9ae846556c`. +- Signed raw HTTP wire contract: `run-5d9fbbfe7b894eb685dd6c68ac037ed3`. + +These are functional and request-cost regressions. They do not measure the hit +rate or end-to-end throughput benefit of forwarding complete S3 body owners. + +## GC scheduling and foreground observations + +The v7 five-case scope passes in +`container-e2e/run-35fb5dec04fa4fe69dc9200665a10519`. The unchanged 128-file, +1,554-logical-byte case completes in 2,396,811 ms, with final revision 792. +All original canonical keys are absent and all candidates/task are Complete; +no deferred range, retry, stall or fence remains. First deletion occurs at +2,002,959 ms, revision 663. The observer records 2,393 task GETs and 256 final +verification GETs, preserving all 792 observed revisions. + +The separate v6 run below completes at revision 920 in 2,785,403 ms. The current +inline path removes 128 checkpoint-only worker steps, consistent with the +128-revision difference. These independent same-host runs are not an isolated +timing comparison or a general GC capacity/SLO guarantee. + +Four SDK workers complete 48 table lifecycles while 64 native writes succeed; +two observed deletion intervals overlap the finite load. SDK create/commit/drop +p50/p99 are 89.42/338.77, 47.15/56.36 and 41.39/46.37 ms. Native write p50/p99 +are 0.72/84.88 ms. Full per-operation samples and exact image identity are in +`/tmp/crowdb-access-cleanup-v7-evidence-20261025`. This proves inline metadata +convergence and bounded overlap, without external range/tree notification or +physical capacity recovery evidence. + +The preceding v6 five-case scope passes in +`container-e2e/run-d56438cb607349f3a4d819402ee8d484`. Its separate expired-inline +case starts with 128 canonical files (1,554 logical bytes), a genuinely elapsed +fixed deletion deadline, one-item pages and the production 30-minute idle +interval. Other window, concurrency and I/O budgets remain unchanged. + +- First actual deletion: 2,005,848 ms, task revision 664. +- Complete verification: 2,785,403 ms, final task revision 920, deleted 128 and + logical bytes 1,554. All 128 original file keys are absent and their exact + candidates are Complete; no deferred ranges, retry, stalled state or fence + remains. This is logical inline metadata retirement, not chunk notification + or physical capacity release. +- The observer performs 2,781 task GETs and 256 separate final verification + GETs. All 919 observed revisions and phase transitions remain in the raw log. +- After first deletion, four official SDK workers each process twelve tables + while the native writer completes 64 writes. Both loads remain active across + one observed interval with increasing deletion count, and a successful SDK + operation finishes inside it. This is finite overlap, not sustained traffic + throughout the 46-minute cleanup or an isolated GC performance baseline. +- One supplemental admin metrics request is recorded separately. Those counters + belong to the foreground store; GC's independent store is not included. + +The new case retains raw latencies and nearest-rank quantiles. SDK table actions +have 48 samples; namespace actions have four. Intentional 500-ms inter-table +and 250-ms inter-write delays are excluded from the operation timings. + +| Operation | Samples | p50 (ms) | p99 (ms) | +| ------------------ | ------- | -------- | -------- | +| Create namespace | 4 | 75.22 | 75.38 | +| Create table | 48 | 78.82 | 318.43 | +| Commit | 48 | 42.87 | 50.72 | +| Load table | 48 | 2.71 | 4.26 | +| Credentials | 48 | 2.73 | 4.11 | +| Read metadata | 48 | 2.52 | 9.31 | +| Drop table | 48 | 38.53 | 51.99 | +| Drop namespace | 4 | 245.05 | 253.12 | +| Native chunk write | 64 | 0.71 | 84.79 | + +Raw output, exact candidate checks, summaries and image identity are retained +in `/tmp/crowdb-access-cleanup-v6-evidence-20261025`. The original foreground +case remains independently unchanged. Its earlier v3 observations follow. + +The four GC container cases pass on `crowdb-node:access-cleanup-v3-20261025` +in `container-e2e/run-15dee04958f34b6f9ad7207fcff1d679`. The imported Docker ID is +`sha256:6636ec6d437cc3bbe32d4c0d355cd1b0a778fbc156797fb39f00ee8eed3cbe0d`, +source revision `be5eca5c52e8f64bdbc4c5c2e96fc6091af6729e`, source tree +`c4e3b70c54ba6c4b4aef2d54b0af21cf98b3f473`. + +The foreground case uses the production default 30-minute idle interval, +one-item discovery pages, 128 seeded files and the original per-window budgets. +Four PyIceberg workers each create, commit, read and drop three tables while a +native chunk writer completes 64 writes of 128 bytes. All operations succeed. +The task is observed at revisions 1 and 2 at 0 and 1,018 ms; the continuation +check observes revision 3 at 4,077 ms. It remains in `Discover`: this proves +continued advancement, not complete backlog retirement or a sustained cleanup rate. + +The test prints every successful operation's latency and nearest-rank quantiles: + +| Operation | Samples | p50 (ms) | p99 (ms) | +| --------------------- | ------- | -------- | -------- | +| Create namespace | 4 | 281.07 | 282.19 | +| Create table | 12 | 72.68 | 472.67 | +| Commit | 12 | 33.58 | 50.15 | +| Load table | 12 | 2.95 | 7.65 | +| Credentials | 12 | 3.14 | 5.97 | +| Read metadata | 12 | 2.79 | 20.89 | +| Drop table | 12 | 146.84 | 151.18 | +| Drop namespace | 4 | 243.95 | 244.53 | +| Native chunk write | 64 | 0.54 | 85.17 | + +Chunk timing includes writer preparation, payload delivery and finish; it excludes +the intentional 50-ms sleeps and initial client connection. It does not measure +an HTTP object PUT or object metadata publication. Its first sample is 85.17 ms +and remains included. The observer adds one task GET per 100 ms during overlap +and continuation checks. These same-host, unpinned, small-sample observations have +no isolated baseline; their p99 values are near maxima, not production latency +guarantees or evidence that GC adds no foreground cost. + +```sh +pixi run clean-env && pixi run python e2e-test/run.py --layer iceberg --scope iceberg.gc --image crowdb-node:access-cleanup-v3-20261025 --concurrency 1 --cpus 4 --memory-mib 12288 +``` + +## Dataset HTTP/native comparison + +The workload runner now records finite observations around each original trial. +The child captures local cached status, announces its exact run/path/concurrency/ +repeat/Snapshot identity, and waits while the parent reads owned service cgroups +and process counters. A second handshake captures the end before the independent +digest kernel or next trial. The original mixed sizes, concurrency, warmups, +three repeats, 4,608 requests, verification and 600-second child bound remain. +Direct child execution without the observation environment variable stays +standalone. The tool passes all 179 fixture unit checks, including 16 +protocol/resource cases, and all 18 matching-image trial observations complete. + +Each path writes its raw log and `*-observations.json`. Service CPU deltas include +background work and the explicitly timed sequential capture interval. Actual +member-process RSS, cumulative process HWM, charged cgroup memory and cumulative +cgroup peaks are retained separately; none is a per-trial peak. Local native +metadata, aggregate and data transport groups retain selected sent-frame/byte +deltas without adding repeated process RUSAGE counters. Missing, reset or changed +identities remain unavailable. In this initial observation revision, receive +traffic, all-copy bytes, layout queries and per-request header/first-byte/EOF +timing remain unmeasured; the [expanded observations](#expanded-cleanup-and-read-observations) +later add layout counters and client-visible delivery phases. The enclosing +service and inner client windows are not additive total-system CPU measurements. + +The v8 complete TCP/installed-Python/storage-restart acceptance passes in +`dataset-workload/c660f7ea934540a3bcb527482ecc0da1`: 18 trials, 4,608 requests, +zero errors and 18 completed observations. The five Dataset service cases pass +in `container-e2e/run-7d55f126f179499fa00b288de78aae5f`. Image +`crowdb-node:access-cleanup-v8-20261025` has source revision +`0174f6b98b8cb5051cdeaff1c7436d9683806e28`, tree +`394adb644056e73cf480a19005807796198a92fa` and Docker ID +`sha256:daaf9de782e91b232ebb288ccaf479c4646c2c7529c9110d115ba90bbfca61e1`. +The original eight sizes, CPU placement, concurrency, repeats, budgets and +verification remain unchanged. Raw artifacts, validated summaries and hashes +are retained in `/tmp/crowdb-access-cleanup-v8-evidence-20261025`. + +| Path | Concurrency | MiB/s | Inner client CPU (s) | Lifetime client RSS (MiB) | +| ------------- | ----------- | ------ | -------------------- | ------------------------- | +| HTTP | 1 | 161.69 | 0.92 | 11.78 | +| Native Rust | 1 | 196.41 | 0.95 | 28.55 | +| Native Python | 1 | 192.16 | 0.99 | 54.78 | +| HTTP | 3 | 253.58 | 2.56 | 16.44 | +| Native Rust | 3 | 270.53 | 2.75 | 30.66 | +| Native Python | 3 | 264.55 | 2.88 | 61.70 | + +Native Rust throughput is about 21%/7% above HTTP in this run, with higher +client CPU and RSS. For 128 bytes, HTTP/native Rust median-trial p50/p99 are +563/1,071 versus 623/710 microseconds at concurrency one, and 818/15,919 versus +1,080/7,105 at three. At 1 MiB and concurrency one, they are 6,561/9,286 versus +3,503/3,870 microseconds. Full per-size quantiles remain in the summary and raw +trials; each trial has only 16/48 observations per size. The separate resident +64-MiB MD5 kernel takes about 86 ms in Rust and 63–67 ms in Python. Normal reads +retain all integrity work and Python byte copies. + +All twelve native trials confirm one fewer bounded metadata request per read: +aggregate sent frames are 896 per 128-request trial, versus v7's 1,024, and +2,688 at concurrency three. Sent aggregate bytes are 273,664/820,992. +Metadata and data groups remain 192/288 frames and 39,040/21,952 sent bytes +at concurrency one, with exact threefold counts at three. The strict scan +preserves a validated terminal owner page, removing its empty confirmation +request. These selected sent counters prove the request reduction; independent +v7/v8 throughput or CPU differences do not isolate its timing benefit. The +observation-window, cumulative-memory and unmeasured-counter scopes above apply. + +The preceding v7 complete TCP/installed-Python/storage-restart acceptance passes in +`dataset-workload/419095e911824fdc9aefe50bbc5bacc9`: 18 trials, 4,608 requests, +zero errors. Source revision is `a78e981fdb8f3f8251aa3bc99e7beb88aa078d80`, tree +`32ac07470397bb295e58d58abf79d72f3dbb15a1`, imported Docker ID +`sha256:979f2646dc189f3c0cb6e8a936faf2e9380716b404372c680b87d40863f060a2`. +The same eight sizes, CPU placement, concurrency, warmups and budgets as the +preceding v6 run below are retained. Raw records, validated summaries and hashes +are backed up in `/tmp/crowdb-access-cleanup-v7-evidence-20261025`. + +| Path | Concurrency | MiB/s | Inner client CPU (s) | Lifetime client RSS (MiB) | +| ------------- | ----------- | ------ | -------------------- | ------------------------- | +| HTTP | 1 | 175.26 | 0.87 | 11.59 | +| Native Rust | 1 | 207.31 | 0.91 | 28.55 | +| Native Python | 1 | 208.53 | 0.95 | 55.21 | +| HTTP | 3 | 249.01 | 2.55 | 17.07 | +| Native Rust | 3 | 280.41 | 2.68 | 30.56 | +| Native Python | 3 | 275.40 | 2.76 | 60.90 | + +Service capture spans are 5.80–10.18 ms. For the three concurrency-one trials, +HTTP/native-Rust enclosing Access CPU is 0.809512/0.002916 s; metadata/control +CPU is 0.286016/0.336947 s. These are separate role windows including background +work and capture slack, not exact total-system CPU. The native metadata, +aggregate and data groups send 192/1,024/288 frames per 128-request trial. +Aggregate here names the bounded metadata transport, not business-message +aggregation. These groups omit control/layout RPCs and expose outgoing request +bytes rather than payload receive bytes. The observed compatibility fallback +copy counter stays zero; Python byte copies remain included in client work. +Differences from v6 are independent observations, not isolated patch effects. + +The preceding v6 complete same-host TCP workload and installed Python/storage-restart +acceptance pass in `dataset-workload/d1fa8f646f124426b6c9e24688d92786` under +`.crowdb-runtime/artifacts/`. The manifest records image labels, CPU placement +and per-phase service counters; client logs retain all per-size p50/p99 and +digest timings. Raw trials and the aggregate summary are independently retained +in `/tmp/crowdb-access-cleanup-v6-evidence-20261025`. + +- Image: `crowdb-node:access-cleanup-v6-20261025`, imported Docker ID + `sha256:cc1f879276f039a13a0ed0b7232833d176a1659fc10bf508700a43ef980b4e2f`. +- Source revision: `c22bc40ebb6663b4b3cc4f0a89075a41f1c9f5a6`; source tree: + `e2d0f660762290085649081fd2b437b53cd343da`. +- Client CPU 2; service CPUs 4–15; each metadata replica capped at 2 GiB. + Metadata cgroup peaks were 204.61/204.60/204.68 MiB, without a stopped replica. +- Sizes: 0, 128, 513, 4,096, 65,535, 262,144, 1,048,576 and 4,194,321 bytes. + Two warmup rounds precede three trials at each concurrency. Each worker + performs 128 requests per trial: 4,608 measured requests, zero errors. + Every path verifies exact bytes and integrity; Python byte-copy costs remain + included. No workload budget, deadline or retry bound was increased. + +Throughput is total bytes divided by total elapsed time across three trials. +Client CPU is the three-trial sum; RSS is the largest observed client value. +HTTP runs authority and payload processing in Access Server, while native +runs that work in the client. Client CPU alone does not measure total system +efficiency. HTTP v1 uses Manifest projection and native uses inspection +metadata; the product comparison includes this implementation difference. + +| Path | Concurrency | MiB/s | Client CPU (s) | Peak RSS (MiB) | +| ------------- | ----------- | ------ | -------------- | -------------- | +| HTTP | 1 | 160.06 | 0.92 | 11.24 | +| Native Rust | 1 | 190.86 | 0.96 | 28.95 | +| Native Python | 1 | 187.70 | 0.98 | 54.60 | +| HTTP | 3 | 255.21 | 2.55 | 16.73 | +| Native Rust | 3 | 268.17 | 2.77 | 31.21 | +| Native Python | 3 | 266.02 | 2.86 | 61.30 | + +Native Rust throughput is about 19% higher at concurrency one and 5% higher at +concurrency three in this execution. Client CPU and memory increase. Individual +sizes also differ: at concurrency one, the median trial p50 for 128 bytes is +557 µs over HTTP and 700 µs in native Rust; for 1 MiB it is 6,788 and 3,681 µs +respectively. At concurrency three, the 128-byte p50 is 798 versus 3,854 µs, +while its p99 is 9,548 versus 7,268 µs. These are medians of three trial +quantiles, not pooled percentiles. Only 16/48 observations per size enter each trial, so these tails +do not establish a production latency guarantee. +The separately timed resident-buffer 64-MiB MD5 kernel takes approximately +85 ms in Rust and 63 ms in Python; normal reads still perform integrity checks. + +The shared fixture's service `memory.peak` is cumulative, and HTTP service CPU +also includes initial publication. Those counters are not isolated per-path +server peaks or a fair read-only server-CPU comparison. This is a pinned +same-host functional workload, not cross-host RDMA performance evidence. + +The semantic phase compares HTTP/native ReadSpec and Split batch headers, +sample IDs and bytes; replays the original cursor on a second runtime and after +storage restart; and checks publication conflicts, multipart ownership, a +65-second idle, cancellation, caller-held buffers and an explicit complete-Split +deletion whose fixed deadline survives retry and storage restart. The native +runtime never uses HTTP as an implicit fallback. + +Reproduce after building/importing the matching service image: + +```sh +pixi run clean-env && pixi run python tools/sdk/dataset_workload.py --image crowdb-node:access-cleanup-v6-20261025 +``` + +The strict same-host HCA profile passes on image +`crowdb-node:access-cleanup-v8-20261025` in +`native-dataset-hca/b8c70fd1b93443c8918e26122b168eb9`. Its immutable source is +`0174f6b98b8cb5051cdeaff1c7436d9683806e28`, tree +`394adb644056e73cf480a19005807796198a92fa`; imported Docker ID is +`sha256:daaf9de782e91b232ebb288ccaf479c4646c2c7529c9110d115ba90bbfca61e1`. +The installed native Python consumer publishes through the shared runtime, +then verifies after all storage services restart. Each phase establishes twelve +data connections with TCP fallback disabled. Write records 48 sent / 60 received +RPC frames; verify records 18 / 30. Fallback, protocol, CQ, send and receive-post +error counters remain zero. Registered memory is 436,404,224 bytes within the +512-MiB budget. A registered receive lease remains valid through runtime shutdown, +and its final release returns held credits. Raw observations and image/source +identity are retained in `/tmp/crowdb-access-cleanup-v8-evidence-20261025`. +This is local-HCA functional and ownership evidence, not a throughput or +cross-host measurement; its counters have a separate scope from the TCP workload. +The separately activated host test +`hardware_accepted_dataset_read_failure_is_sticky_without_replay` also passes +with `CROWDB_RDMA_E2E=1`, the same address and device. It verifies an accepted +second read interrupted after a registered first buffer remains terminal, +without reconnect, retry, metadata re-selection or loss of held ownership. +Its complete output is retained alongside the HCA evidence. + +The preceding v5 run was +`dataset-workload/def69a711d4e468880c5cb9fae82e32e`; its native Rust throughput +was about 36%/5% above HTTP at concurrency one/three. The preceding v4 run was +`dataset-workload/50925d2ddaf04a5da8fb1687132c4228`; its image and raw evidence +remain preserved separately. Earlier accepted runs were +`dataset-workload/9dbf56c386e142e995b8eb400305b467` +and `dataset-workload/abea0a0568824233a32ba85a063e522a`; their original runtime +directories were removed as described above. +Differences between independent runs +are not an isolated before/after measurement of any one code change. + +## Bounded immutable write comparison + +Build/import the matching service image, then run: + +```sh +pixi run clean-env && pixi run python e2e-test/write_batch_comparison.py --image crowdb-node:access-cleanup-20261025 +``` + +The owned persistent Chunk-KV fixture compares release point writes and routed +batch writes on client CPU 2 and service CPUs 4–15. Both paths admit at most +16 rows / 2 MiB of logical key/value input, plus one prepared row. Three warmups +precede three paired, alternating trials for small schema/membership/field rows +and 64-KiB inline payloads with their actual JSON encoding. The test records typed +results, routes, owner RPC attempts, completed TCP frames and protocol bytes, +client CPU ticks and RSS. Artifacts include the image/source identity, client +binary/source hashes, original output and `comparison.json`. +Cold warmups account for one capability-handshake frame per new pooled TCP +connection. Measured trials require no new connections and exact equality +between owner RPC attempts and completed protocol frames. + +This comparison does not publish a Dataset head. Logical admission bytes exclude +transport copies; TCP protocol bytes exclude IP/retransmissions. Allocation and +copied-byte counts are unmeasured. Process memory peaks are cumulative, and +short trials can report zero CPU ticks without having zero CPU cost. Report +the measured routing scope rather than assuming multiple partitions or owners. + +The comparison passes in +`write-batch-comparison/acd38136ec0943aeac3aaa9d849c0a5c` with +`crowdb-node:access-cleanup-20261025` from source `7b2c61aa`, tree `aecf740c`. +Its manifest retains the exact imported image and client binary/source hashes. +All warmup and measured creates return typed Applied results. The actual route +is one partition, one owner and catalog generation 1; it is not a multi-partition +measurement. Each measured trial keeps both connections open and has no setup +frames or failed owner attempts. + +| Rows | Path | Min / median / max (ms) | Owner RPCs | Sent protocol bytes | +| ---------------------- | -------------- | ------------------------ | ---------- | ------------------- | +| 256 metadata rows | Sliding points | 371.3 / 469.4 / 470.0 | 256 | 80,680 | +| 256 metadata rows | Routed batch | 2913.6 / 3708.7 / 4102.8 | 16 | 167,864 | +| 16 inline 64-KiB rows | Sliding points | 286.7 / 318.0 / 320.5 | 16 | 4,199,136 | +| 16 inline 64-KiB rows | Routed batch | 545.6 / 550.9 / 561.5 | 3 | 12,593,170 | + +Keep the existing bounded sliding point writes for inspection preparation: +the current batch reduces RPC count but is slower in both profiles. Inline +values already encode bytes as JSON numbers in the Dataset row; the group +envelope JSON-encodes that row again, increasing protocol bytes to about three +times the point path. Both large-value paths actually admit seven rows at a +time because the logical byte bound wins before the row-count bound. + +Across the three large-value trials, client CPU is 4 ticks for points and +21 ticks for batch at 100 Hz. Small-row CPU totals are only 3 and 2 ticks and +cannot establish a useful CPU advantage. The lifetime HWM reaches 60,116 KiB +during warmup and is shared by subsequent paths; it is not a per-path memory +comparison. Automatic catalog source refresh occurs twice in measured small +batches and once in measured large points; those calls remain included in +elapsed time and separately reported from owner RPCs. + +The service processes each batch's items sequentially, while the point window +can overlap row completion. This and encoding/copying are concrete differences; +their individual contributions were not profiled. Alternating trials do not +reset accumulated persistent state, and three rounds give no stable latency +SLO. Reconsider grouped writes after improving the wire/processing path and +repeating this comparison, without relaxing ordering or publication barriers. + +## Shared upload owner CPU + +The release CPU decomposition passes on client CPU 2 in +`upload-owner-cpu/10866bc17bf14e53877a2d3be19c390e`. Its manifest retains binary +and source hashes; `measurement.log` and `measurements.json` retain every sample. +The fixture has two live owners and 1/64/256/4,095 retired owners. A retirement +removes one live owner and adds its permanent negative fence. The last case +reaches the existing 4,096-entry bound; it does not change the saturated-resource +format or late-claim rules. + +Each mode has eight warmups and seven fixed 128-iteration samples. Fixture +setup is separate. Values below are medians of calling-thread CPU per operation, +with Linux scheduler accounting refreshed at both boundaries by a safe yield. +The empty boundary costs approximately 6.6 µs per sample and is reported without +subtraction. Wall-clock samples are retained separately. The initial unrefreshed +counter was tick-quantized and is not used for these results. + +| Retired owners | Input record (bytes) | Serde roundtrip (µs) | Canned retirement (µs) | +| -------------- | -------------------- | -------------------- | ---------------------- | +| 1 | 343 | 1.3 | 1.5 | +| 64 | 4,051 | 14.3 | 15.1 | +| 256 | 15,535 | 55.9 | 57.5 | +| 4,095 | 245,760 | 832.1 | 849.2 | + +The canned path calls production `retire_owner`, including its record checks, +one GET clone and one exact expected/result comparison. Its CAS returns a fixed +Applied outcome and does not persist. Standalone serde modes exclude session +validation. They are independently timed; subtracting or adding their medians +cannot apportion production CPU. At the largest input, decode and encode alone +measure approximately 574 and 236 µs. A separate test-store CAS/reset pair costs +about 106 µs and includes two mutations and fixture map/Vec cloning; it is never +subtracted from the authority measurement. + +This establishes substantial whole-record codec cost with large shared-owner +histories. It is not routed RPC latency, WAL cost, allocation counting or an +end-to-end throughput limit. Keep the CAS and retired-owner safety contract; +review a smaller resource representation or different layout before changing +publication/reclaim ordering. + +Reproduce in an exclusive window, with no service workload running: + +```sh +pixi run cargo test --release --locked -p crowdb-access-dataset --test upload_owner_cpu_test --no-run +pixi run taskset -c 2 cargo test --release --locked -p crowdb-access-dataset --test upload_owner_cpu_test upload_owner_cpu_decomposition -- --ignored --exact --nocapture --test-threads=1 +``` + ## Verification and execution notes - Library: `pixi run -- cargo test -p crowdb-access-iceberg --all-targets`. diff --git a/app/crowdb-access-server/tests/common/dataset.rs b/app/crowdb-access-server/tests/common/dataset.rs index d4541afdd..31ba58a57 100644 --- a/app/crowdb-access-server/tests/common/dataset.rs +++ b/app/crowdb-access-server/tests/common/dataset.rs @@ -16,6 +16,12 @@ pub use upload::TestStore; pub struct TestDatasetHttp { pub origin: String, + #[allow(dead_code)] + pub authority: Arc, + #[allow(dead_code)] + pub uploads: Arc, + #[allow(dead_code)] + pub clock: Arc, stop: Option>, task: JoinHandle>, } @@ -42,20 +48,21 @@ impl TestDatasetHttp { } else { DatasetAuthority::new(store) }; + let clock = Arc::new(std::sync::atomic::AtomicU64::new(1000)); + let authority = Arc::new(authority.with_reclaim_clock_for_tests(clock.clone())); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let origin = format!("http://{}", listener.local_addr().unwrap()); let reads = DatasetReadService::new( - Arc::new(authority), + authority.clone(), DatasetIdentity::new(NamespacePath::root(), "legacy").unwrap(), 3, - u64::MAX - 1000, ) .unwrap(); let service = DatasetHttpService::new(Arc::new(reads), Duration::from_secs(30)) .with_bearer("secret") .with_api_capacity(capacity); let service = Arc::new(if payload { - service.with_uploads(uploads) + service.with_uploads(uploads.clone()) } else { service }); @@ -65,6 +72,9 @@ impl TestDatasetHttp { })); Self { origin, + authority, + uploads, + clock, stop: Some(stop), task, } diff --git a/app/crowdb-access-server/tests/common/dataset_composition.rs b/app/crowdb-access-server/tests/common/dataset_composition.rs index 9b96300e2..df1f227ce 100644 --- a/app/crowdb-access-server/tests/common/dataset_composition.rs +++ b/app/crowdb-access-server/tests/common/dataset_composition.rs @@ -3,49 +3,22 @@ use crate::common::response; use reqwest::Client; use serde_json::{json, Value}; -pub fn begin(operation: &str, parent: &Value) -> Value { - json!({"operation":operation,"parent":parent,"source":{"kind":"all"},"schema":{"version":1,"fields":[{"name":"value","required":true}]},"inherit_parent_splits":false,"parent_split_revision":null,"splits":[],"policy":null}) +pub fn input(parent: &Value) -> Value { + json!({"parent":parent,"source":{"kind":"all"},"schema":{"version":1,"fields":[{"name":"value","required":true}]},"samples":[],"splits":[],"policy":{"overlap":"disjoint","coverage":"allow_unassigned"}}) } -pub async fn prepare(client: &Client, draft: &str, revision: &str, members: &str) -> Value { - let mut done = false; - for page in 0..100 { - let preview = response( - client - .post(format!("{draft}/evaluate")) - .json(&json!({"expected_revision":revision,"expected_page":page.to_string()})), - 200, - ) - .await; - if preview["complete"] == true { - assert_eq!(preview["members"], members); - done = true; - break; - } - } - assert!(done, "composition evaluation did not finish"); - for _ in 0..100 { - let prepared = response( - client - .post(format!("{draft}/prepare")) - .json(&json!({"expected_revision":revision})), - 200, - ) - .await; - if prepared["uploads"]["publication_ready"] == true { - return prepared; - } - } - panic!("composition preparation did not finish"); +pub async fn prepare(client: &Client, root: &str, snapshot: &str, input: &Value) -> Value { + response( + client.put(format!("{root}/snapshots/{snapshot}")).json(input), + 200, + ) + .await } -pub async fn publish(client: &Client, root: &str, draft: &str, prepared: &Value, attempt: &str) -> Value { - let head = response(client.get(format!("{root}/head")), 200).await; - response(client.post(format!("{draft}/publish")).json(&json!({"attempt":attempt,"snapshot":prepared["preparation"]["snapshot"],"expected_head":head["generation"]})), 200).await -} -pub async fn cleanup(client: &Client, draft: &str) { - for _ in 0..100 { - if response(client.post(format!("{draft}/cleanup")), 200).await["complete"] == true { - return; - } - } - panic!("composition cleanup did not finish"); +pub async fn publish(client: &Client, root: &str, prepared: &Value, expected_head: &Value) -> Value { + response( + client + .put(format!("{root}/head")) + .json(&json!({"snapshot":prepared["snapshot"],"expected_head":expected_head})), + 200, + ) + .await } diff --git a/app/crowdb-access-server/tests/common/dataset_v2.rs b/app/crowdb-access-server/tests/common/dataset_v2.rs index af2bc2fdd..5ef51f687 100644 --- a/app/crowdb-access-server/tests/common/dataset_v2.rs +++ b/app/crowdb-access-server/tests/common/dataset_v2.rs @@ -8,7 +8,13 @@ use serde_json::{json, Value}; pub async fn response(request: reqwest::RequestBuilder, expected: u16) -> Value { let response = request.bearer_auth("secret").send().await.unwrap(); let status = response.status().as_u16(); - let body: Value = response.json().await.unwrap(); + let url = response.url().to_string(); + let bytes = response + .bytes() + .await + .unwrap_or_else(|error| panic!("{status} {url}: {error}")); + let body: Value = serde_json::from_slice(&bytes) + .unwrap_or_else(|error| panic!("{status} {url}: {error}; {}", String::from_utf8_lossy(&bytes))); assert_eq!(status, expected, "{body}"); body } @@ -33,37 +39,29 @@ pub fn definitions() -> Value { {"name":"test", "definition":{"kind":"weighted_partition", "seed":"18446744073709551615", "weights":[{"name":"train/a b", "weight":8},{"name":"test", "weight":2}], "index":1}, "origin":{"kind":"evaluated"}} ]) } -pub fn begin(operation: &str, expected: &Value) -> Value { - json!({"operation":operation, "expected_revision":expected, "policy":{"overlap":"disjoint", "coverage":"require_complete"}, "splits":definitions()}) -} -pub async fn prepare(client: &Client, draft: &str, revision: &str) -> Value { - for page in 0..10 { - let p = response( - client - .post(format!("{draft}/evaluate")) - .json(&json!({"expected_revision":revision,"expected_page":page.to_string()})), - 200, - ) - .await; - if p["complete"] == true { - assert_eq!(p["validation"]["scanned"], "45"); - return response( - client - .post(format!("{draft}/prepare")) - .json(&json!({"expected_revision":revision})), - 200, - ) - .await; - } +pub fn preparation(empty: bool) -> Value { + if empty { + json!({"policy":{"overlap":"disjoint", "coverage":"allow_unassigned"}, "splits":[]}) + } else { + json!({"policy":{"overlap":"disjoint", "coverage":"require_complete"}, "splits":definitions()}) } - panic!("Split evaluation did not finish"); } -pub async fn publish(client: &Client, draft: &str, prepared: &Value, attempt: &str, head: Value) -> Value { +pub async fn prepare(client: &Client, root: &str, snapshot: &str, revision: &str, empty: bool) -> Value { response( client - .post(format!("{draft}/publish")) - .json(&json!({"attempt":attempt,"revision":prepared["revision"],"expected_head":head})), + .put(format!("{root}/snapshots/{snapshot}/split-revisions/{revision}")) + .json(&preparation(empty)), 200, ) .await } +pub async fn publish( + client: &Client, + root: &str, + snapshot: &str, + prepared: &Value, + expected: &Value, +) -> Value { + response(client.put(format!("{root}/snapshots/{snapshot}/split-head")) + .json(&json!({"revision":prepared["revision"], "expected_revision":expected["revision"], "expected_head":expected["generation"]})), 200).await +} diff --git a/app/crowdb-access-server/tests/common/iceberg_client.py b/app/crowdb-access-server/tests/common/iceberg_client.py index f0745c3b8..684f5eb5d 100644 --- a/app/crowdb-access-server/tests/common/iceberg_client.py +++ b/app/crowdb-access-server/tests/common/iceberg_client.py @@ -38,7 +38,7 @@ def main(): "POST /v1/{prefix}/namespaces/{namespace}/properties", "DELETE /v1/{prefix}/namespaces/{namespace}", } - assert response.json()["idempotency-key-lifetime"] == "PT24H" + assert "idempotency-key-lifetime" not in response.json() catalog = load_catalog("crowdb", **properties) namespaces = catalog.list_namespaces() assert isinstance(namespaces, list) diff --git a/app/crowdb-access-server/tests/common/iceberg_commit_case.rs b/app/crowdb-access-server/tests/common/iceberg_commit_case.rs index c1883bd42..b202ea705 100644 --- a/app/crowdb-access-server/tests/common/iceberg_commit_case.rs +++ b/app/crowdb-access-server/tests/common/iceberg_commit_case.rs @@ -105,6 +105,13 @@ impl TestCommitCase { } pub async fn replay(&self, endpoint: &str) -> Value { + if self.staged { + return self.retry_client_stage(endpoint).await; + } + if self.generation == 1 { + return self.retry_complete_creation(endpoint).await; + } + let first = post(endpoint, &self.path, &self.identity, &self.body) .await .unwrap(); @@ -131,18 +138,103 @@ impl TestCommitCase { .unwrap(); let status = loaded.status(); let loaded = loaded.text().await.unwrap(); - if self.staged { - assert_eq!(status, 404, "{loaded}"); - } else { - assert_eq!(status, 200, "{loaded}"); - let loaded: Value = serde_json::from_str(&loaded).unwrap(); - let result: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(loaded["metadata-location"], result["metadata-location"]); - if self.generation == 2 { - assert_eq!(loaded["metadata"]["properties"]["owner"], "after"); + assert_eq!(status, 200, "{loaded}"); + let loaded: Value = serde_json::from_str(&loaded).unwrap(); + let result: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(loaded["metadata-location"], result["metadata-location"]); + assert_eq!(loaded["metadata"]["properties"]["owner"], "after"); + serde_json::from_slice(&bytes).unwrap() + } + async fn retry_complete_creation(&self, endpoint: &str) -> Value { + let mut first = None; + // Complete client requests may reconcile a reserved resource before retrying. + for _ in 0..4 { + let response = post(endpoint, &self.path, &self.identity, &self.body) + .await + .unwrap(); + let status = response.status().as_u16(); + let bytes = response.bytes().await.unwrap(); + assert!( + matches!(status, 200 | 409 | 503), + "{status}: {}", + String::from_utf8_lossy(&bytes) + ); + if status == 200 { + first = Some(serde_json::from_slice::(&bytes).unwrap()); + break; + } + if status == 409 { + break; } } - serde_json::from_slice(&bytes).unwrap() + let loaded = Client::new() + .get(format!("{endpoint}/v1/namespaces/analytics/tables/{}", self.name)) + .bearer_auth("r".repeat(32)) + .send() + .await + .unwrap(); + let status = loaded.status(); + let bytes = loaded.bytes().await.unwrap(); + assert_eq!(status, 200, "{}", String::from_utf8_lossy(&bytes)); + let loaded: Value = serde_json::from_slice(&bytes).unwrap(); + if let Some(first) = first { + assert_eq!(loaded["metadata-location"], first["metadata-location"]); + } + let input: Value = serde_json::from_str(&self.body).unwrap(); + let mut properties = input + .get("properties") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if let Some(updates) = input.get("updates").and_then(Value::as_array) { + for update in updates + .iter() + .filter(|update| update["action"] == "set-properties") + { + properties.extend(update["updates"].as_object().unwrap().clone()); + } + assert_eq!(loaded["metadata"]["location"], input["updates"][8]["location"]); + assert_eq!(loaded["metadata"]["table-uuid"], input["updates"][0]["uuid"]); + } + properties + .entry("write.parquet.compression-codec") + .or_insert(json!("zstd")); + assert_eq!(loaded["metadata"]["properties"], Value::Object(properties)); + let duplicate = post(endpoint, &self.path, &self.identity, &self.body) + .await + .unwrap(); + let status = duplicate.status(); + let bytes = duplicate.bytes().await.unwrap(); + assert_eq!(status, 409, "{}", String::from_utf8_lossy(&bytes)); + loaded + } + + async fn retry_client_stage(&self, endpoint: &str) -> Value { + let first = post(endpoint, &self.path, &self.identity, &self.body) + .await + .unwrap(); + let status = first.status(); + let body = first.text().await.unwrap(); + assert_eq!(status, 200, "{body}"); + let first: Value = serde_json::from_str(&body).unwrap(); + let retry = post(endpoint, &self.path, &self.identity, &self.body) + .await + .unwrap(); + let status = retry.status(); + let body = retry.text().await.unwrap(); + assert_eq!(status, 200, "{body}"); + let retry: Value = serde_json::from_str(&body).unwrap(); + assert_ne!(first["metadata"]["location"], retry["metadata"]["location"]); + assert_ne!(first["metadata"]["table-uuid"], retry["metadata"]["table-uuid"]); + assert_eq!(first["metadata"]["schemas"], retry["metadata"]["schemas"]); + let loaded = Client::new() + .get(format!("{endpoint}/v1/namespaces/analytics/tables/{}", self.name)) + .bearer_auth("r".repeat(32)) + .send() + .await + .unwrap(); + assert_eq!(loaded.status(), 404, "{}", loaded.text().await.unwrap()); + first } } diff --git a/app/crowdb-access-server/tests/common/iceberg_commit_fault.rs b/app/crowdb-access-server/tests/common/iceberg_commit_fault.rs index 603c8b6db..061a9203f 100644 --- a/app/crowdb-access-server/tests/common/iceberg_commit_fault.rs +++ b/app/crowdb-access-server/tests/common/iceberg_commit_fault.rs @@ -135,6 +135,7 @@ impl CatalogStore for Test } StorageRecord::File(_) => "file-record".into(), StorageRecord::FileMapping(_) => "file-mapping".into(), + StorageRecord::TableMapping(mapping) => format!("name-{:?}", mapping.state), StorageRecord::MultipartSession(session) => format!("multipart-{:?}", session.phase), _ => "journal-or-fence".into(), }; diff --git a/app/crowdb-access-server/tests/common/iceberg_fallback.rs b/app/crowdb-access-server/tests/common/iceberg_fallback.rs new file mode 100644 index 000000000..14ad23706 --- /dev/null +++ b/app/crowdb-access-server/tests/common/iceberg_fallback.rs @@ -0,0 +1,80 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use crate::body::{body, TestWriter}; +use crowdb_access_iceberg::{ + catalog::CatalogContext, + file::{FileGrant, FileOperation, FileOperations, TableLocation}, + key::{CatalogId, OperationId, TableId}, +}; +use crowdb_access_server::iceberg::{ + write_iceberg_fallback_body_for_tests, FileRequest, FileServiceLimits, FileTransferAdmission, + UploadFlowSnapshot, +}; +use hyper::{body::Bytes, Method}; +use md5::{Digest, Md5}; +use sha2::Sha256; + +pub const TARGET: usize = 16 * crowdb_protocol::frame::MAX_FRAME_V2_PAYLOAD_BYTES; + +pub fn admission(limit: u64) -> FileTransferAdmission { + let grant = FileGrant { + storage_namespace: None, + context: CatalogContext { + catalog: CatalogId::random(), + activation_epoch: 1, + }, + table: TableId::random(), + principal: [5; 32], + nonce: OperationId::random(), + issued_ms: 100, + expires_ms: 200, + operations: FileOperations::new(&[FileOperation::Put]).unwrap(), + max_request_bytes: limit, + max_file_bytes: limit, + }; + let location = TableLocation { + storage_namespace: None, + catalog: grant.context.catalog, + table: grant.table, + } + .file("data/fallback.parquet") + .unwrap(); + let uri = format!("/{}/{}", location.table().bucket(), location.object_key()) + .parse() + .unwrap(); + let request = FileRequest::parse(&Method::PUT, &uri).unwrap(); + let limits = FileServiceLimits { + max_request_bytes: limit, + max_file_bytes: limit, + max_part_bytes: limit, + max_staged_bytes: limit, + }; + FileTransferAdmission::authorize(&grant, &request, limits, None, 101).unwrap() +} + +pub async fn checked(frames: Vec, declared: Option) -> (TestWriter, UploadFlowSnapshot) { + let expected: Vec<_> = frames.iter().flat_map(|bytes| bytes.iter().copied()).collect(); + let expected_md5: [u8; 16] = Md5::digest(&expected).into(); + let sha: [u8; 32] = Sha256::digest(&expected).into(); + let mut body = body(frames, declared); + let mut writer = TestWriter::default(); + let (length, md5, metrics) = + write_iceberg_fallback_body_for_tests(&mut body, &mut writer, &admission(64 << 20), 2, Some(sha)) + .await + .unwrap(); + assert_eq!(length, expected.len() as u64); + assert_eq!(md5, expected_md5); + assert_eq!(metrics.logical_bytes, length); + assert_eq!(metrics.digest_enqueues, writer.buffers.len() as u64); + assert_eq!(metrics.writer_feeds, writer.buffers.len() as u64); + assert_eq!(metrics.frames_prepared, 0); + assert!(metrics.queued_buffers_peak <= 2); + let actual: Vec<_> = writer + .buffers + .iter() + .flat_map(|bytes| bytes.iter().copied()) + .collect(); + assert_eq!(actual, expected); + (writer, metrics) +} diff --git a/app/crowdb-access-server/tests/common/iceberg_file_delete.rs b/app/crowdb-access-server/tests/common/iceberg_file_delete.rs new file mode 100644 index 000000000..e80a66330 --- /dev/null +++ b/app/crowdb-access-server/tests/common/iceberg_file_delete.rs @@ -0,0 +1,234 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, +}; + +use arc_swap::ArcSwap; +use async_trait::async_trait; +use crowdb_access_iceberg::{ + catalog::{ActiveCatalogRecord, CatalogAuthority, CatalogContext, CatalogStore, RootState}, + file::{ + canonical_file_key, ChunkRoot, ContentFormat, FileBlockStore, FileContent, FileIdentity, FileIoError, + FileKind, FileRecord, FileRepository, TableLocation, + }, + gc::{GcCandidate, GcLimits, GcPhase, GcRepository, GcTask, GcWorker}, + key::{CatalogId, CatalogScope, FileId, IcebergKey, NamespaceId, OperationId, SystemScope, TableId}, + operation::mutation_identity, + record::StorageRecord, +}; +use crowdb_access_server::iceberg::{mark_unreferenced_file_for_tests, FileS3ErrorCode}; +use crowdb_chunk_client::ReclaimOutcome; +use crowdb_protocol::common::ChunkId; + +#[path = "iceberg_file_delete/store.rs"] +mod store; +pub use store::TestStore; + +pub const DELETED_MS: u64 = 2_000; + +#[derive(Default)] +pub struct TestBlocks { + pub notifications: AtomicUsize, + pub denied: AtomicBool, + pub lose_reply: AtomicBool, + pub accepted: ArcSwap>, +} + +#[async_trait] +impl FileBlockStore for TestBlocks { + async fn put(&self, _owner: FileIdentity, _height: u8, _bytes: &[u8]) -> Result { + Err(FileIoError::Bounds) + } + + async fn read(&self, _root: &ChunkRoot) -> Result, FileIoError> { + Err(FileIoError::Bounds) + } + + async fn reclaim(&self, root: &ChunkRoot) -> Result { + self.notifications.fetch_add(1, Ordering::SeqCst); + if self.denied.load(Ordering::SeqCst) { + return Err(FileIoError::Bounds); + } + self.accepted.rcu(|roots| { + let mut next = (**roots).clone(); + next.push(root.clone()); + next + }); + if self.lose_reply.swap(false, Ordering::SeqCst) { + return Err(crowdb_chunk_client::IoError::Internal("lost DeleteRange reply".into()).into()); + } + Ok(ReclaimOutcome::Accepted) + } +} + +pub struct TestDeletion { + pub store: Arc, + pub blocks: Arc, + pub context: CatalogContext, + pub file: FileRecord, + pub limits: GcLimits, +} + +impl TestDeletion { + pub async fn new(native: bool) -> Self { + let store = Arc::new(TestStore::default()); + let context = CatalogContext { + catalog: CatalogId::random(), + activation_epoch: 1, + }; + let table = TableLocation { + catalog: context.catalog, + table: TableId::random(), + storage_namespace: native.then(NamespaceId::random), + }; + let root = ChunkRoot { + chunk: ChunkId { high: 1, low: 7 }, + offset: 64, + physical_length: 80, + logical_offset: 0, + logical_length: 16, + height: 0, + digest: [7; 32], + }; + let file = FileRecord { + file: FileId::random(), + location: table.file("data/object.parquet").unwrap(), + kind: FileKind::Data, + format: ContentFormat::Parquet, + length: 16, + digest: root.digest, + content: FileContent::Chunks { root: Some(root) }, + hint: None, + }; + let authority = if native { + CatalogAuthority::new_native(context.catalog, "current".into()).unwrap() + } else { + CatalogAuthority::new(context.catalog, "current".into()).unwrap() + }; + for (key, record) in [ + ( + IcebergKey::System { + scope: SystemScope::ActiveRoot, + suffix: Vec::new(), + }, + StorageRecord::Active(ActiveCatalogRecord { + context, + operation: OperationId::random(), + state: RootState::Ready, + }), + ), + ( + IcebergKey::Catalog { + catalog: context.catalog, + scope: CatalogScope::Authority, + suffix: Vec::new(), + }, + StorageRecord::Authority(authority), + ), + ] { + let key = key.encode().unwrap(); + let bytes = record.encode().unwrap(); + store + .compare_exchange(&key, None, &bytes, mutation_identity(&key, None, &bytes)) + .await + .unwrap(); + } + FileRepository::new(store.clone()) + .publish(context, &file) + .await + .unwrap(); + Self { + store, + blocks: Arc::new(TestBlocks::default()), + context, + file, + limits: GcLimits::default(), + } + } + + pub async fn mark(&self, now_ms: u64) -> Result<(), FileS3ErrorCode> { + mark_unreferenced_file_for_tests( + &FileRepository::new(self.store.clone()), + self.context, + &self.file.location, + now_ms, + Some(&[]), + ) + .await + } + + pub async fn candidate(&self) -> Option { + let key = GcCandidate::deleted_key(&self.file); + self.store + .get(&key.encode().unwrap()) + .await + .unwrap() + .map(|value| { + let StorageRecord::GcCandidate(candidate) = + StorageRecord::decode(&key, &value.bytes).unwrap() + else { + panic!("expected a GC candidate"); + }; + *candidate + }) + } + + pub async fn discover(&self) -> GcTask { + let repository = GcRepository::new(self.store.clone()); + let mut task = repository + .admit_multipart(self.context, DELETED_MS, self.limits) + .await + .unwrap(); + for _ in 0..20 { + if self.candidate().await.is_some() { + return task; + } + task = GcWorker::new(repository.clone(), self.blocks.clone(), self.limits) + .unwrap() + .step(&task, DELETED_MS) + .await + .unwrap(); + } + panic!("DeletedFile was not discovered"); + } + + pub async fn step_after_restart( + &self, + task: &GcTask, + now_ms: u64, + ) -> Result { + let repository = GcRepository::new(self.store.clone()); + let durable = repository + .task(self.context.catalog, task.identity) + .await + .unwrap() + .unwrap(); + assert_eq!(&durable, task); + GcWorker::new(repository, self.blocks.clone(), self.limits) + .unwrap() + .step(&durable, now_ms) + .await + } + + pub async fn finish(&self, mut task: GcTask) { + for _ in 0..100 { + task = self + .step_after_restart(&task, DELETED_MS + GcTask::PURGE_DELAY_MS) + .await + .unwrap(); + if task.phase == GcPhase::Complete { + return; + } + } + panic!("cleanup did not complete: {task:?}"); + } + + pub fn authority(&self) -> StorageRecord { + let key = canonical_file_key(&self.file.location); + let snapshot = self.store.values.load(); + StorageRecord::decode(&key, &snapshot.get(&key.encode().unwrap()).unwrap().bytes).unwrap() + } +} diff --git a/app/crowdb-access-server/tests/common/iceberg_file_delete/store.rs b/app/crowdb-access-server/tests/common/iceberg_file_delete/store.rs new file mode 100644 index 000000000..ed3b056db --- /dev/null +++ b/app/crowdb-access-server/tests/common/iceberg_file_delete/store.rs @@ -0,0 +1,193 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use std::collections::BTreeMap; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, +}; + +use arc_swap::ArcSwap; +use async_trait::async_trait; +use crowdb_access_iceberg::{ + catalog::{CasOutcome, CatalogStore, StoreError, StoredValue}, + gc::{GcScan, GcStore, GcSystemScan}, + key::{CatalogScope, IcebergKey, StorageKey}, + record::StorageRecord, +}; +use crowdb_chunk_kv_client::{MultiScanContinuation, MultiScanPage, MultiScanRequest}; +use crowdb_protocol::chunk_kv::{ClientRequestId, RpcValue}; + +#[derive(Default)] +pub struct TestStore { + pub values: ArcSwap, StoredValue>>, + pub reads: AtomicUsize, + pub exchanges: AtomicUsize, + pub gc_requests: AtomicUsize, + pub deny_gc: AtomicBool, + pub mark_failure: AtomicUsize, +} + +impl TestStore { + pub fn reset_counts(&self) { + self.reads.store(0, Ordering::SeqCst); + self.exchanges.store(0, Ordering::SeqCst); + self.gc_requests.store(0, Ordering::SeqCst); + } + + pub fn counts(&self) -> (usize, usize) { + ( + self.reads.load(Ordering::SeqCst), + self.exchanges.load(Ordering::SeqCst), + ) + } + + fn admit_key(&self, key: &[u8]) -> Result<(), StoreError> { + if matches!( + IcebergKey::decode(key), + Ok(IcebergKey::Catalog { + scope: CatalogScope::GcTask + | CatalogScope::GcClaim + | CatalogScope::GcCandidate + | CatalogScope::GcRetirement, + .. + }) + ) { + self.gc_requests.fetch_add(1, Ordering::SeqCst); + if self.deny_gc.load(Ordering::SeqCst) { + return Err(StoreError::Budget); + } + } + Ok(()) + } + + fn scan(&self, request: MultiScanRequest) -> MultiScanPage { + let snapshot = self.values.load_full(); + let mut candidates = snapshot.iter().filter(|(key, _)| { + request.start.as_ref().map_or(true, |start| *key >= start) + && request.end.as_ref().map_or(true, |end| *key < end) + }); + let mut items = Vec::new(); + let mut bytes = 0; + let mut more = false; + for (key, value) in candidates.by_ref() { + let size = key.len() + value.bytes.len(); + if items.len() == request.max_items || bytes + size > request.max_bytes { + more = true; + break; + } + items.push(RpcValue { + key: key.clone(), + value: value.bytes.clone(), + revision: value.revision, + }); + bytes += size; + } + MultiScanPage { + continuation: more.then(|| MultiScanContinuation { + direction: request.direction, + original_start: request.start, + original_end: request.end, + last_key: items.last().unwrap().key.clone(), + catalog_generation: 1, + }), + items, + terminal_failure: None, + } + } +} + +#[async_trait] +impl CatalogStore for TestStore { + async fn get(&self, key: &[u8]) -> Result, StoreError> { + self.reads.fetch_add(1, Ordering::SeqCst); + self.admit_key(key)?; + Ok(self.values.load().get(key).cloned()) + } + + async fn compare_exchange( + &self, + key: &[u8], + expected: Option<&[u8]>, + value: &[u8], + identity: ClientRequestId, + ) -> Result { + identity.validate().unwrap(); + self.exchanges.fetch_add(1, Ordering::SeqCst); + self.admit_key(key)?; + let marking = matches!( + StorageKey::decode(key).and_then(|key| StorageRecord::decode(&key, value)), + Ok(StorageRecord::DeletedFile(_)) + ); + let failure = if marking { + self.mark_failure.swap(0, Ordering::SeqCst) + } else { + 0 + }; + if failure == 1 { + return Err(StoreError::Budget); + } + loop { + let current = self.values.load_full(); + let previous = current.get(key); + if previous.map(|value| value.bytes.as_slice()) != expected { + return Ok(CasOutcome::Conflict(previous.cloned())); + } + let revision = previous.map_or(1, |value| value.revision + 1); + let mut next = (*current).clone(); + next.insert( + key.to_vec(), + StoredValue { + bytes: value.to_vec(), + revision, + }, + ); + let observed = self.values.compare_and_swap(¤t, Arc::new(next)); + if Arc::ptr_eq(¤t, &observed) { + return if failure == 2 { + Err(StoreError::Response) + } else { + Ok(CasOutcome::Applied(revision)) + }; + } + } + } +} + +#[async_trait] +impl GcStore for TestStore { + async fn scan_gc(&self, scan: GcScan) -> Result { + self.gc_requests.fetch_add(1, Ordering::SeqCst); + if self.deny_gc.load(Ordering::SeqCst) { + return Err(StoreError::Budget); + } + Ok(self.scan(scan.request()?)) + } + + async fn scan_gc_system(&self, scan: GcSystemScan) -> Result { + Ok(self.scan(scan.request()?)) + } + + async fn delete_gc_record( + &self, + key: &[u8], + expected: &[u8], + identity: ClientRequestId, + ) -> Result { + identity.validate().unwrap(); + loop { + let current = self.values.load_full(); + let previous = current.get(key); + if previous.map(|value| value.bytes.as_slice()) != Some(expected) { + return Ok(CasOutcome::Conflict(previous.cloned())); + } + let revision = previous.unwrap().revision + 1; + let mut next = (*current).clone(); + next.remove(key); + let observed = self.values.compare_and_swap(¤t, Arc::new(next)); + if Arc::ptr_eq(¤t, &observed) { + return Ok(CasOutcome::Applied(revision)); + } + } + } +} diff --git a/app/crowdb-access-server/tests/common/iceberg_file_recovery/component.rs b/app/crowdb-access-server/tests/common/iceberg_file_recovery/component.rs index 8b18738a0..272187e3e 100644 --- a/app/crowdb-access-server/tests/common/iceberg_file_recovery/component.rs +++ b/app/crowdb-access-server/tests/common/iceberg_file_recovery/component.rs @@ -3,14 +3,10 @@ use std::collections::BTreeSet; use std::path::Path; -use std::time::Duration; use crowdb_access_iceberg::{ - catalog::ClearBounds, - file::{ - FileKind, FileRepository, MultipartAdmission, MultipartPhase, MultipartRepository, MultipartSession, - TableLocation, - }, + catalog::{CatalogStore, ClearBounds}, + file::{FileKind, FileRepository, MultipartPhase, MultipartRepository, MultipartSession, TableLocation}, }; use reqwest::Method; use serde_json::Value; @@ -127,42 +123,20 @@ async fn interrupt( .unwrap(); assert_eq!(session.phase, MultipartPhase::Published); assert_eq!(session.published, Some(record.file)); - released_by_recovery(stack, &session).await; + no_global_admission(stack, &session).await; } label } -async fn released_by_recovery(stack: &TestCommitFixture, published: &MultipartSession) { - let store = stack.store(); - let sessions = MultipartRepository::new(store.clone()); - let admission = MultipartAdmission::new(store); - if published.credit.is_none() { - // Current sessions have per-session limits and no catalog-wide credits. - // Recovery must not recreate the legacy admission journal. - assert!(admission.load(published.context).await.unwrap().is_none()); - return; - } - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let current = sessions - .load(published.context, published.upload) - .await - .unwrap() - .unwrap(); - assert_eq!(current.phase, MultipartPhase::Published); - assert_eq!(current.published, published.published); - let policy = admission.load(published.context).await.unwrap().unwrap(); - assert!(policy.sessions <= 1); - assert!(policy.reserved_bytes <= published.limits.max_staged_bytes); - if current.credit.unwrap().released && policy.pending.is_none() { - assert_eq!((policy.sessions, policy.reserved_bytes), (0, 0)); - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - }) - .await - .expect("background recovery must settle terminal credits without another Complete request"); +async fn no_global_admission(stack: &TestCommitFixture, published: &MultipartSession) { + use crowdb_access_iceberg::key::{CatalogScope, IcebergKey}; + assert!(published.credit.is_none()); + let key = IcebergKey::Catalog { + catalog: published.context.catalog, + scope: CatalogScope::MultipartAdmission, + suffix: Vec::new(), + }; + assert!(stack.store().get(&key.encode().unwrap()).await.unwrap().is_none()); } struct TestFileCase { diff --git a/app/crowdb-access-server/tests/common/iceberg_gc_scheduler.rs b/app/crowdb-access-server/tests/common/iceberg_gc_scheduler.rs new file mode 100644 index 000000000..09aecd8d3 --- /dev/null +++ b/app/crowdb-access-server/tests/common/iceberg_gc_scheduler.rs @@ -0,0 +1,182 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use async_trait::async_trait; +use crowdb_access_iceberg::file::{ChunkRoot, FileBlockStore, FileIdentity, FileIoError}; +use crowdb_chunk_client::ReclaimOutcome; + +#[path = "iceberg_gc_scheduler/store.rs"] +mod store; +use crowdb_access_iceberg::{ + catalog::{ActiveCatalogRecord, CatalogAuthority, CatalogContext, CatalogStore, RootState}, + file::TableLocation, + gc::{GcLimits, GcRepository, GcTask}, + key::{CatalogId, CatalogScope, FileId, IcebergKey, NamespaceId, OperationId, SystemScope, TableId}, + operation::mutation_identity, + record::StorageRecord, + table::{storage_head_key, TableHead, TableLifecycle, TablePurgeTask}, +}; +use crowdb_access_server::iceberg::TestGcSweep; +use std::sync::Arc; +pub use store::TestStore; + +#[path = "iceberg_gc_scheduler/timing.rs"] +pub mod timing; + +pub struct TestBlocks; + +#[async_trait] +impl FileBlockStore for TestBlocks { + async fn put(&self, _: FileIdentity, _: u8, _: &[u8]) -> Result { + Err(FileIoError::Bounds) + } + async fn read(&self, _: &ChunkRoot) -> Result, FileIoError> { + Err(FileIoError::Bounds) + } + async fn reclaim(&self, _: &ChunkRoot) -> Result { + Err(FileIoError::Bounds) + } +} + +pub async fn put(store: &TestStore, key: &[u8], bytes: &[u8]) { + store + .compare_exchange(key, None, bytes, mutation_identity(key, None, bytes)) + .await + .unwrap(); +} + +pub fn scheduler(store: Arc, context: CatalogContext) -> TestGcSweep { + TestGcSweep::new( + store, + Arc::new(TestBlocks), + context.catalog, + Some(context), + GcLimits { + page_items: 1, + ..GcLimits::default() + }, + ) + .unwrap() +} + +pub async fn advance(sweep: &mut TestGcSweep, windows: usize) { + for _ in 0..windows { + if let Err(error) = sweep.advance_for_tests().await { + eprintln!("scheduler retained error: {error}"); + } + } +} + +pub async fn assert_progress(store: Arc, before: &GcTask) { + let current = GcRepository::new(store) + .task(before.context.catalog, before.identity) + .await + .unwrap() + .unwrap(); + assert!( + current.revision > before.revision, + "healthy task did not advance: {current:?}" + ); +} + +pub async fn purge(store: &TestStore, context: CatalogContext, identity: u8) -> TablePurgeTask { + let table = TableId::from_bytes(&[identity; 16]).unwrap(); + let metadata_file = FileId::random(); + let marker = TablePurgeTask { + activation_epoch: context.activation_epoch, + dropped_ms: 1000, + head: TableHead { + catalog: context.catalog, + table, + namespace: NamespaceId::random(), + name: "dropped".into(), + name_epoch: 1, + lifecycle: TableLifecycle::Tombstone, + generation: 1, + metadata_file, + metadata_location: TableLocation { + catalog: context.catalog, + table, + storage_namespace: None, + } + .file(&format!("metadata/1-{metadata_file}.metadata.json")) + .unwrap(), + metadata_digest: [1; 32], + commit_binding: None, + format_version: 1, + table_uuid: None, + operation_fence: 1, + pending_operation: Some(OperationId::random()), + }, + }; + put( + store, + &storage_head_key(marker.head.storage_location()).encode().unwrap(), + &StorageRecord::TableHead(Box::new(marker.head.clone())) + .encode() + .unwrap(), + ) + .await; + put( + store, + &marker.key().encode().unwrap(), + &StorageRecord::TablePurgeTask(Box::new(marker.clone())) + .encode() + .unwrap(), + ) + .await; + marker +} + +pub fn purge_task_key(marker: &TablePurgeTask) -> Vec { + IcebergKey::Catalog { + catalog: marker.head.catalog, + scope: CatalogScope::GcTask, + suffix: marker.head.table.as_bytes().to_vec(), + } + .encode() + .unwrap() +} + +pub async fn setup() -> (Arc, CatalogContext, GcTask) { + let store = Arc::new(TestStore::default()); + let context = CatalogContext { + catalog: CatalogId::random(), + activation_epoch: 1, + }; + for (key, record) in [ + ( + IcebergKey::System { + scope: SystemScope::ActiveRoot, + suffix: Vec::new(), + }, + StorageRecord::Active(ActiveCatalogRecord { + context, + operation: OperationId::random(), + state: RootState::Ready, + }), + ), + ( + IcebergKey::Catalog { + catalog: context.catalog, + scope: CatalogScope::Authority, + suffix: Vec::new(), + }, + StorageRecord::Authority(CatalogAuthority::new(context.catalog, "current".into()).unwrap()), + ), + ] { + let key = key.encode().unwrap(); + let bytes = record.encode().unwrap(); + store + .compare_exchange(&key, None, &bytes, mutation_identity(&key, None, &bytes)) + .await + .unwrap(); + } + store.inner.reset_counts(); + let task = GcRepository::new(store.clone()) + .admit_multipart(context, 1000, GcLimits::default()) + .await + .unwrap(); + assert!(store.inner.counts().0 > 0); + (store, context, task) +} diff --git a/app/crowdb-access-server/tests/common/iceberg_gc_scheduler/store.rs b/app/crowdb-access-server/tests/common/iceberg_gc_scheduler/store.rs new file mode 100644 index 000000000..8cb6711b6 --- /dev/null +++ b/app/crowdb-access-server/tests/common/iceberg_gc_scheduler/store.rs @@ -0,0 +1,156 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use arc_swap::ArcSwap; +use async_trait::async_trait; +use crowdb_access_iceberg::{ + catalog::{CasOutcome, CatalogStore, StoreError, StoredValue}, + gc::{GcLocalScan, GcScan, GcStore, GcSystemScan}, +}; +use crowdb_chunk_kv_client::{MultiScanContinuation, MultiScanPage}; +use crowdb_protocol::chunk_kv::{ClientRequestId, RpcValue}; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, +}; +use tokio::sync::Notify; + +#[path = "../iceberg_file_delete/store.rs"] +mod inner; + +#[derive(Default)] +pub struct TestStore { + pub inner: inner::TestStore, + pub fault_key: ArcSwap>, + pub write_failure: AtomicUsize, + pub read_failure: AtomicUsize, + pub pause_write: AtomicBool, + pub pause_scope: AtomicUsize, + pub entered: Notify, + pub resume: Notify, + pub invalid_page: AtomicBool, + pub requests: AtomicUsize, +} + +impl TestStore { + pub fn fault_at(&self, key: Vec) { + self.fault_key.store(Arc::new(key)); + } +} + +#[async_trait] +impl CatalogStore for TestStore { + async fn get(&self, key: &[u8]) -> Result, StoreError> { + self.requests.fetch_add(1, Ordering::SeqCst); + if self.fault_key.load().as_slice() == key && self.read_failure.swap(0, Ordering::SeqCst) != 0 { + return Err(StoreError::Response); + } + self.inner.get(key).await + } + + async fn compare_exchange( + &self, + key: &[u8], + expected: Option<&[u8]>, + value: &[u8], + identity: ClientRequestId, + ) -> Result { + self.requests.fetch_add(1, Ordering::SeqCst); + let targeted = self.fault_key.load().as_slice() == key; + if targeted && self.pause_write.load(Ordering::SeqCst) { + self.entered.notify_one(); + self.resume.notified().await; + } + let failure = if targeted { + self.write_failure.swap(0, Ordering::SeqCst) + } else { + 0 + }; + if failure == 1 { + return Err(StoreError::Budget); + } + let outcome = self + .inner + .compare_exchange(key, expected, value, identity) + .await?; + if failure == 2 { + Err(StoreError::Response) + } else { + Ok(outcome) + } + } +} + +#[async_trait] +impl GcStore for TestStore { + async fn scan_gc_local(&self, scan: GcLocalScan) -> Result { + self.requests.fetch_add(1, Ordering::SeqCst); + let request = scan.request(1)?; + let start = request.start.as_ref().unwrap(); + if self.pause_scope.load(Ordering::SeqCst) == usize::from(*start.last().unwrap()) { + self.entered.notify_one(); + self.resume.notified().await; + } + let snapshot = self.inner.values.load_full(); + let mut rows = snapshot.iter().filter(|(key, _)| { + *key >= start + && request.end.as_ref().map_or(true, |end| *key < end) + && request + .continuation + .as_ref() + .map_or(true, |token| *key > &token.last_key) + }); + let mut items = Vec::new(); + let mut bytes = 0; + let mut more = false; + for (key, value) in rows.by_ref() { + let size = key.len() + value.bytes.len(); + if items.len() == request.max_items || bytes + size > request.max_bytes { + if items.is_empty() { + return Err(StoreError::Response); + } + more = true; + break; + } + items.push(RpcValue { + key: key.clone(), + value: value.bytes.clone(), + revision: value.revision, + }); + bytes += size; + } + if !items.is_empty() && self.invalid_page.swap(false, Ordering::SeqCst) { + items[0].revision = 0; + } + let page = MultiScanPage { + continuation: more.then(|| MultiScanContinuation { + direction: request.direction, + original_start: request.start, + original_end: request.end, + last_key: items.last().unwrap().key.clone(), + catalog_generation: 1, + }), + items, + terminal_failure: None, + }; + scan.validate_page(&page)?; + Ok(page) + } + async fn scan_gc(&self, scan: GcScan) -> Result { + self.requests.fetch_add(1, Ordering::SeqCst); + self.inner.scan_gc(scan).await + } + async fn scan_gc_system(&self, scan: GcSystemScan) -> Result { + self.requests.fetch_add(1, Ordering::SeqCst); + self.inner.scan_gc_system(scan).await + } + async fn delete_gc_record( + &self, + key: &[u8], + expected: &[u8], + identity: ClientRequestId, + ) -> Result { + self.requests.fetch_add(1, Ordering::SeqCst); + self.inner.delete_gc_record(key, expected, identity).await + } +} diff --git a/app/crowdb-access-server/tests/common/iceberg_gc_scheduler/timing.rs b/app/crowdb-access-server/tests/common/iceberg_gc_scheduler/timing.rs new file mode 100644 index 000000000..728744a75 --- /dev/null +++ b/app/crowdb-access-server/tests/common/iceberg_gc_scheduler/timing.rs @@ -0,0 +1,106 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::{CatalogContext, CatalogStore, GcTask, TestStore}; +use crowdb_access_iceberg::{ + catalog::{CatalogError, CatalogRepository, ClearBounds, ManagementPrivilege, RootState}, + gc::{GcLimits, GcRepository}, + key::OperationId, + operation::{mutation_identity, ManagementAction, ManagementRequest, RequestIdentity}, + record::StorageRecord, +}; +use std::sync::Arc; + +pub const IDLE_MS: u64 = 30 * 60_000; + +pub fn now_ms() -> u64 { + u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(), + ) + .unwrap() +} + +pub async fn replace_task(store: &TestStore, task: &GcTask) { + let key = task.key().encode().unwrap(); + let before = store.get(&key).await.unwrap().unwrap(); + let bytes = StorageRecord::GcTask(Box::new(task.clone())).encode().unwrap(); + assert!(matches!( + store + .compare_exchange( + &key, + Some(&before.bytes), + &bytes, + mutation_identity(&key, Some(&before.bytes), &bytes) + ) + .await + .unwrap(), + crowdb_access_iceberg::catalog::CasOutcome::Applied(_) + )); +} + +pub async fn cleared_catalog() -> (Arc, CatalogContext, GcTask) { + let store = Arc::new(TestStore::default()); + let catalog = CatalogRepository::new( + store.clone(), + ClearBounds { + root_lease_ms: 0, + request_ms: 10, + delegated_access_ms: 0, + clock_skew_ms: 1, + }, + ) + .unwrap(); + let request = |action, epoch| ManagementRequest { + identity: RequestIdentity { + operation: OperationId::random(), + issued_ms: 100, + }, + principal: "operator".into(), + action, + expected_epoch: epoch, + display_name: "catalog".into(), + confirmation: None, + capabilities: None, + }; + let initialized = catalog + .execute( + request(ManagementAction::Initialize, 0), + ManagementPrivilege::Manage, + 100, + ) + .await + .unwrap(); + let mut clear = request(ManagementAction::Clear, 1); + clear.confirmation = Some(initialized.catalog); + assert!(matches!( + catalog + .execute(clear.clone(), ManagementPrivilege::Clear, 101) + .await, + Err(CatalogError::Busy) + )); + let RootState::Published(transition) = catalog.status().await.unwrap().0.state else { + panic!("expected published clear"); + }; + catalog + .execute( + clear.clone(), + ManagementPrivilege::Clear, + transition.complete_after_ms, + ) + .await + .unwrap(); + let active = catalog.status().await.unwrap().0.context; + let operation = catalog + .operation(clear.identity.operation) + .await + .unwrap() + .unwrap(); + let task = GcRepository::new(store.clone()) + .admit_retired(&operation, now_ms(), GcLimits::default()) + .await + .unwrap(); + (store, active, task) +} diff --git a/app/crowdb-access-server/tests/common/iceberg_store.rs b/app/crowdb-access-server/tests/common/iceberg_store.rs index 1d69a0691..cca72d599 100644 --- a/app/crowdb-access-server/tests/common/iceberg_store.rs +++ b/app/crowdb-access-server/tests/common/iceberg_store.rs @@ -261,7 +261,12 @@ impl CatalogStore for TestStore { )) => { (mode == 2 && scope == crowdb_access_iceberg::key::CatalogScope::NamespaceAuthority) - || (mode == 3 && scope == crowdb_access_iceberg::key::CatalogScope::Operation) + || (mode == 3 + && scope == crowdb_access_iceberg::key::CatalogScope::NamespaceOperation + && matches!(crowdb_access_iceberg::key::IcebergKey::decode(key).and_then(|key| + crowdb_access_iceberg::record::StorageRecord::decode(&key, value)), + Ok(crowdb_access_iceberg::record::StorageRecord::NamespaceOperation(operation)) + if operation.phase == crowdb_access_iceberg::namespace::NamespacePhase::Complete)) || (mode == 5 && scope == crowdb_access_iceberg::key::CatalogScope::TableHead) || (mode == 4 && scope diff --git a/app/crowdb-access-server/tests/common/iceberg_table_http.rs b/app/crowdb-access-server/tests/common/iceberg_table_http.rs index 65c4205c0..e51022739 100644 --- a/app/crowdb-access-server/tests/common/iceberg_table_http.rs +++ b/app/crowdb-access-server/tests/common/iceberg_table_http.rs @@ -1,16 +1,19 @@ use std::{sync::Arc, time::Duration}; use crowdb_access_iceberg::{ - catalog::{CatalogContext, CatalogRepository, ClearBounds, ManagementPrivilege, StoredValue}, + catalog::{ + CatalogContext, CatalogRepository, CatalogStorageLayout, ClearBounds, ManagementPrivilege, + StoredValue, + }, file::{ContentFormat, FileContent, FileKind, FileRecord, FileRepository, TableLocation}, - key::{FileId, IcebergKey, NamespaceId, OperationId, TableId}, + key::{FileId, IcebergKey, NamespaceId, OperationId, StorageKey, TableId}, namespace::{ NamespaceCreateRequest, NamespaceCreator, NamespaceIdentifier, NamespaceProperties, NamespaceRepository, }, operation::{ManagementAction, ManagementRequest, RequestIdentity}, record::StorageRecord, - table::{head_key, name_key, TableHead, TableLifecycle, TableMapping, TableMappingState}, + table::{name_key, storage_head_key, TableHead, TableLifecycle, TableMapping, TableMappingState}, wire::BearerAuthenticator, }; use crowdb_access_server::iceberg::{serve, IcebergHttpService}; @@ -157,8 +160,15 @@ impl TestTableHttp { } pub async fn install_with_location(&self, name: &str, trailing_slash: bool) -> (TableHead, Vec) { + let authority = CatalogRepository::new(self.store.clone(), ClearBounds::default()) + .unwrap() + .status() + .await + .unwrap() + .1; let location = TableLocation { - storage_namespace: None, + storage_namespace: (authority.storage_layout == CatalogStorageLayout::Native) + .then_some(self.namespace), catalog: self.context.catalog, table: TableId::random(), }; @@ -212,14 +222,14 @@ impl TestTableHttp { ) .await .unwrap(); - self.put( - &head_key(head.catalog, head.table), + self.put_storage( + &storage_head_key(location), &StorageRecord::TableHead(Box::new(head.clone())), ); self.put( &name_key(head.catalog, head.namespace, name).unwrap(), &StorageRecord::TableMapping(TableMapping { - storage_namespace: None, + storage_namespace: location.storage_namespace, catalog: head.catalog, namespace: head.namespace, name: name.into(), @@ -233,6 +243,10 @@ impl TestTableHttp { } pub fn put(&self, key: &IcebergKey, record: &StorageRecord) { + self.put_storage(&StorageKey::Legacy(key.clone()), record); + } + + pub fn put_storage(&self, key: &StorageKey, record: &StorageRecord) { let mut values = (**self.store.values.load()).clone(); values.insert( key.encode().unwrap(), diff --git a/app/crowdb-access-server/tests/common/publication_payload.rs b/app/crowdb-access-server/tests/common/publication_payload.rs new file mode 100644 index 000000000..ce8e6cafb --- /dev/null +++ b/app/crowdb-access-server/tests/common/publication_payload.rs @@ -0,0 +1,130 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "publication_payload/storage.rs"] +mod storage; +#[path = "../../../../lib/crowdb-access-dataset/tests/common/upload.rs"] +#[allow(dead_code)] +mod upload; +use base64::{engine::general_purpose::STANDARD, Engine}; +use bytes::Bytes; +use crowdb_access_dataset::*; +use crowdb_access_server::dataset::{serve, DatasetHttpService}; +use md5::{Digest, Md5}; +use serde_json::{json, Value}; +use std::{sync::Arc, time::Duration}; +pub use storage::TestPublicationStore; +use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle}; + +pub struct TestPublicationHttp { + pub root: String, + pub store: Arc, + pub authority: Arc, + pub uploads: Arc, + pub identity: DatasetIdentity, + stop: oneshot::Sender<()>, + task: JoinHandle>, +} +impl TestPublicationHttp { + pub async fn start() -> Self { + let store = Arc::new(TestPublicationStore::default()); + let uploads = Arc::new(DatasetUploads::new( + store.clone(), + Arc::new(upload::TestPayload::default()), + )); + let authority = Arc::new(DatasetAuthority::new(store.clone())); + let identity = DatasetIdentity::new(NamespacePath::root(), "publication-payload").unwrap(); + authority.create_dataset(identity.clone()).await.unwrap(); + let reads = Arc::new(DatasetReadService::new(authority.clone(), identity.clone(), 3).unwrap()); + let service = DatasetHttpService::new(reads, Duration::from_secs(30)) + .with_bearer("secret") + .with_uploads(uploads.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let root = format!( + "http://{}/api/dataset/v1/namespaces/dataset-ns/datasets/publication-payload", + listener.local_addr().unwrap() + ); + let (stop, rx) = oneshot::channel(); + let task = tokio::spawn(serve(listener, Arc::new(service), async { + let _ = rx.await; + })); + Self { + root, + store, + authority, + uploads, + identity, + stop, + task, + } + } + + pub async fn upload(&self, marker: u8, complete: bool) -> String { + let id = OperationId::from_bytes(&[marker; 16]).unwrap(); + let location = DatasetUploads::location(&self.identity, id).unwrap(); + let data = vec![marker; usize::from(marker) + 4]; + self.uploads + .create( + &location, + data.len() as u64, + 4, + Md5::digest(&data).into(), + u64::MAX, + ) + .await + .unwrap(); + if complete { + for (index, bytes) in data.chunks(4).enumerate() { + self.uploads + .put_part( + &location, + u32::try_from(index).unwrap(), + Bytes::copy_from_slice(bytes), + 1000, + ) + .await + .unwrap(); + } + self.uploads.complete(&location, 1000).await.unwrap(); + } + id.to_string() + } + + pub fn location(&self, id: &str) -> Vec { + DatasetUploads::location( + &self.identity, + OperationId::from_bytes(&hex::decode(id).unwrap()).unwrap(), + ) + .unwrap() + } + + pub async fn stop(self) { + self.stop.send(()).unwrap(); + self.task.await.unwrap().unwrap(); + } +} + +pub fn input(uploads: &[String], fields: u8, operation: u8) -> Value { + let samples: Vec<_> = (0..fields) + .map(|id| { + json!({ + "id": STANDARD.encode([id]), + "fields":{"value":{"kind":"payload","upload":uploads[usize::from(id) % uploads.len()]}} + }) + }) + .collect(); + json!({ + "operation":hex::encode([operation;16]),"parent":null, + "manifest":{"schema":[{"name":"value","required":true}],"samples":samples} + }) +} + +pub async fn response(request: reqwest::RequestBuilder, expected: u16) -> Value { + let response = request.bearer_auth("secret").send().await.unwrap(); + let status = response.status().as_u16(); + let bytes = response.bytes().await.unwrap(); + let body: Value = serde_json::from_slice(&bytes) + .unwrap_or_else(|error| panic!("{status}: {error}; {}", String::from_utf8_lossy(&bytes))); + assert_eq!(status, expected, "{body}"); + body +} diff --git a/app/crowdb-access-server/tests/common/publication_payload/storage.rs b/app/crowdb-access-server/tests/common/publication_payload/storage.rs new file mode 100644 index 000000000..72d1bfe11 --- /dev/null +++ b/app/crowdb-access-server/tests/common/publication_payload/storage.rs @@ -0,0 +1,106 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use crowdb_access_dataset::*; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +#[derive(Default)] +pub struct TestPublicationStore { + inner: super::upload::TestStore, + pub upload_reads: AtomicU64, + pub claim_attempts: AtomicU64, + pub complete_attempts: AtomicU64, + pub fail_metadata: AtomicBool, + pub lose_claim_reply: AtomicBool, + pub abort_at_claim: AtomicBool, + pub unknown: AtomicBool, + pub operations_after_unknown: AtomicU64, +} +impl TestPublicationStore { + pub fn mutations(&self) -> u64 { + self.inner.mutations.load(Ordering::Relaxed) + } + + fn observe(&self) { + if self.unknown.load(Ordering::Relaxed) { + self.operations_after_unknown.fetch_add(1, Ordering::Relaxed); + } + } +} +#[async_trait::async_trait] +impl DatasetStore for TestPublicationStore { + async fn get(&self, key: &[u8]) -> Result, StoreError> { + self.observe(); + if key.starts_with(b"DS\0U\0") { + self.upload_reads.fetch_add(1, Ordering::Relaxed); + if self.fail_metadata.swap(false, Ordering::Relaxed) { + self.unknown.store(true, Ordering::Relaxed); + return Err(StoreError::Rejected); + } + } + self.inner.get(key).await + } + async fn get_bounded( + &self, + key: &[u8], + budget: MetadataScanBudget, + cancel: &ReadCancellation, + ) -> Result, StoreError> { + self.observe(); + self.inner.get_bounded(key, budget, cancel).await + } + async fn scan_page( + &self, + request: &MetadataScan, + cancel: &ReadCancellation, + ) -> Result { + self.observe(); + self.inner.scan_page(request, cancel).await + } + async fn compare_exchange( + &self, + key: &[u8], + expected: Option<&[u8]>, + value: &[u8], + ) -> Result { + self.observe(); + let json: Option = serde_json::from_slice(value).ok(); + let claiming = key.starts_with(b"DS\0U\0") + && UploadRecord::decode_stored_for_tests(value) + .is_ok_and(|record| record.state == UploadState::Claimed); + if claiming { + self.claim_attempts.fetch_add(1, Ordering::Relaxed); + if self.abort_at_claim.swap(false, Ordering::Relaxed) { + let stored = self.inner.get(key).await?.unwrap(); + let mut aborted = UploadRecord::decode_stored_for_tests(&stored.bytes).unwrap(); + aborted.state = UploadState::Aborted; + assert!(matches!( + self.inner + .compare_exchange( + key, + Some(&stored.bytes), + &aborted.encode_stored_for_tests().unwrap() + ) + .await?, + CasOutcome::Applied(_) + )); + } + } + if key.starts_with(b"DS\0CB\0") && json.as_ref().is_some_and(|value| value["version"] == 2) { + self.complete_attempts.fetch_add(1, Ordering::Relaxed); + } + let outcome = self.inner.compare_exchange(key, expected, value).await?; + if claiming + && matches!(outcome, CasOutcome::Applied(_)) + && self.lose_claim_reply.swap(false, Ordering::Relaxed) + { + self.unknown.store(true, Ordering::Relaxed); + return Err(StoreError::Rejected); + } + Ok(outcome) + } + async fn delete(&self, key: &[u8]) -> Result<(), StoreError> { + self.observe(); + self.inner.delete(key).await + } +} diff --git a/app/crowdb-access-server/tests/common/rdma_datapath/allocator.rs b/app/crowdb-access-server/tests/common/rdma_datapath/allocator.rs new file mode 100644 index 000000000..2ccd43363 --- /dev/null +++ b/app/crowdb-access-server/tests/common/rdma_datapath/allocator.rs @@ -0,0 +1,142 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +//! Inject only placement; payload, framing and disk I/O use production code. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use arc_swap::ArcSwap; +use async_trait::async_trait; +use crowdb_chunk_client::{ChunkAllocator, Result}; +use crowdb_protocol::chunkdb::rpc::*; +use crowdb_protocol::common::{ChunkId, DiskId}; +use crowdb_protocol::diskdb::rpc::Segment; + +#[derive(Default)] +pub struct TestAllocator { + next_segment: AtomicU64, + chunks: ArcSwap>, +} + +impl TestAllocator { + fn update(&self, action: impl Fn(&mut HashMap<(u64, u64), Chunk>) -> T) -> T { + loop { + let current = self.chunks.load_full(); + let mut next = (*current).clone(); + let result = action(&mut next); + let previous = self.chunks.compare_and_swap(¤t, Arc::new(next)); + if Arc::ptr_eq(&previous, ¤t) { + return result; + } + } + } + + fn strip(&self, id: ChunkId, sequence: u32) -> ChunkStrip { + let unit_offset = self.next_segment.fetch_add(256, Ordering::Relaxed); + ChunkStrip { + chunk_offset: sequence * 1024, + strip_sequence: sequence, + unit_kb: 4, + capacity: 1024, + strip_type: StripType::Mirror as i32, + strip: Some(Strip::MirrorStrip(MirrorStrip { + segments: (1..=2) + .map(|high| Segment { + disk_id: Some(DiskId { high, low: 0 }), + owner_chunk: Some(id), + unit_offset, + unit_count: 256, + ..Segment::default() + }) + .collect(), + })), + ..ChunkStrip::default() + } + } +} + +#[async_trait] +impl ChunkAllocator for TestAllocator { + async fn allocate_chunk(&self, req: AllocateChunkRequest) -> Result { + assert_eq!(req.strip_type, StripType::Mirror as i32); + assert_eq!(req.copy_count, 2); + let id = req.chunk_id.unwrap(); + let strips: Vec<_> = (0..req.strip_count.max(1)) + .map(|sequence| self.strip(id, sequence)) + .collect(); + let chunk = Chunk { + id: Some(id), + modify_ts: 1, + state: ChunkState::Active as i32, + capacity: strips.iter().map(|strip| strip.capacity).sum(), + next_strip_sequence: u32::try_from(strips.len()).unwrap(), + strips, + chunk_type: req.chunk_type, + writer_epoch: req.writer_epoch, + ..Chunk::default() + }; + self.update(|chunks| chunks.insert((id.high, id.low), chunk.clone())); + Ok(AllocateChunkResponse { chunk: Some(chunk) }) + } + + async fn append_chunk(&self, req: AppendChunkRequest) -> Result { + let id = req.chunk_id.unwrap(); + self.update(|chunks| { + let chunk = chunks.get_mut(&(id.high, id.low)).unwrap(); + assert_eq!(chunk.modify_ts, req.modify_ts); + let first = chunk.next_strip_sequence; + let strips: Vec<_> = (0..req.strip_count.max(1)) + .map(|offset| self.strip(id, first + offset)) + .collect(); + chunk.capacity += strips.iter().map(|strip| strip.capacity).sum::(); + chunk.next_strip_sequence += u32::try_from(strips.len()).unwrap(); + chunk.modify_ts += 1; + chunk.strips.extend(strips.iter().cloned()); + Ok(AppendChunkResponse { + modify_ts: chunk.modify_ts, + strips, + chunk: None, + }) + }) + } + + async fn seal_chunk(&self, req: SealChunkRequest) -> Result { + let id = req.chunk_id.unwrap(); + self.update(|chunks| { + let chunk = chunks.get_mut(&(id.high, id.low)).unwrap(); + chunk.state = ChunkState::Sealed as i32; + chunk.acknowledged_cursor = req.seal_bytes; + chunk.sealed_length = u32::try_from(req.seal_bytes.div_ceil(1024)).unwrap(); + for strip in &mut chunk.strips { + strip.sealed_length = chunk + .sealed_length + .saturating_sub(strip.chunk_offset) + .min(strip.capacity); + } + Ok(SealChunkResponse { + chunk: Some(chunk.clone()), + }) + }) + } + + async fn delete_chunk(&self, req: DeleteChunkRequest) -> Result { + let id = req.chunk_id.unwrap(); + self.update(|chunks| { + let chunk = chunks.get_mut(&(id.high, id.low)).unwrap(); + chunk.state = ChunkState::Deleted as i32; + Ok(DeleteChunkResponse { + chunk: Some(chunk.clone()), + }) + }) + } + + async fn query_chunk(&self, req: QueryChunkRequest) -> Result { + let id = req.chunk_id.unwrap(); + Ok(QueryChunkResponse { + chunk: self.chunks.load().get(&(id.high, id.low)).cloned(), + layout_validity_ms: 60_000, + }) + } +} diff --git a/app/crowdb-access-server/tests/common/rdma_datapath/http.rs b/app/crowdb-access-server/tests/common/rdma_datapath/http.rs new file mode 100644 index 000000000..4ab200f7c --- /dev/null +++ b/app/crowdb-access-server/tests/common/rdma_datapath/http.rs @@ -0,0 +1,113 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use std::sync::Arc; + +use arc_swap::ArcSwap; +use crowdb_access_s3::native_buffer::NativeBodyAllocator; +use crowdb_access_server::s3::{HandlerFuture, ResponseBody, S3HttpHandler}; +use crowdb_chunk_client::{ChunkClientConfig, ChunkIoClient, ChunkIoWriter, LargeWritePolicy}; +use crowdb_common::ec::EcScheme; +use crowdb_protocol::chunkdb::rpc::{ChunkType, Location}; +use futures::stream; +use http_body_util::{BodyExt, Full, StreamBody}; +use hyper::body::{Bytes, Frame, Incoming}; +use hyper::{Method, Request, Response}; + +pub struct TestHttp { + chunks: Arc, + pub allocator: Arc, + pub locations: Arc>>, +} + +impl TestHttp { + pub fn new(chunks: Arc) -> Self { + let allocator = NativeBodyAllocator::new(2 << 20, 1 << 20) + .unwrap() + .with_producer_allocator(chunks.producer_allocator().unwrap()) + .unwrap(); + Self { + chunks, + allocator: Arc::new(allocator), + locations: Arc::new(ArcSwap::from_pointee(Vec::new())), + } + } + + async fn put( + chunks: Arc, + allocator: Arc, + locations: Arc>>, + request: Request, + ) -> Response { + let size = request.headers()["content-length"] + .to_str() + .unwrap() + .parse() + .unwrap(); + let mut config = ChunkClientConfig::new(ChunkType::S3); + config.large_mirror_copies = Some(2); + config.max_chunk_size = 2 << 20; + config.large_parallel_strip_writes = 2; + let policy = LargeWritePolicy { + ec_scheme: EcScheme::new(1, 1), + client: Arc::new(config), + }; + let mut writer = chunks.prepare_large_write(Some(size), policy); + writer.wait_until_prepared().await.unwrap(); + let (etag, _) = crowdb_access_server::s3::test_support::write_large_body_for_tests( + request, + &mut writer, + &allocator, + ) + .await + .unwrap(); + locations.store(Arc::new(writer.on_finish().await.unwrap())); + Response::builder().header("etag", etag).body(empty()).unwrap() + } + + fn get(&self) -> Response { + let read = self.chunks.read_stream(&self.locations.load()).unwrap(); + let body = stream::unfold((read, 0), |(mut read, delivered)| async move { + if delivered == 1 && std::env::var("CROWDB_RDMA_DATAPATH_FAULT").as_deref() == Ok("1") { + let marker = std::env::var("CROWDB_RDMA_DATAPATH_PAUSED").unwrap(); + let resume = std::env::var("CROWDB_RDMA_DATAPATH_RESUME").unwrap(); + std::fs::write(marker, b"verified frame delivered").unwrap(); + while !std::path::Path::new(&resume).exists() { + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + } + read.next_chunk().await.map(|result| { + ( + result + .map(Frame::data) + .map_err(|error| -> crowdb_access_server::s3::BoxError { + eprintln!("datapath HTTP download failed: {error:?}"); + error.to_string().into() + }), + (read, delivered + 1), + ) + }) + }); + Response::builder() + .body(BodyExt::boxed(StreamBody::new(Box::pin(body)))) + .unwrap() + } +} + +impl S3HttpHandler for TestHttp { + fn handle(&self, request: Request) -> HandlerFuture { + if request.method() == Method::PUT { + let chunks = Arc::clone(&self.chunks); + let allocator = Arc::clone(&self.allocator); + let locations = Arc::clone(&self.locations); + Box::pin(async move { Ok(Self::put(chunks, allocator, locations, request).await) }) + } else { + let response = self.get(); + Box::pin(async move { Ok(response) }) + } + } +} + +fn empty() -> ResponseBody { + Full::new(Bytes::new()).map_err(|never| match never {}).boxed() +} diff --git a/app/crowdb-access-server/tests/common/s3_fallback.rs b/app/crowdb-access-server/tests/common/s3_fallback.rs new file mode 100644 index 000000000..86c1de96a --- /dev/null +++ b/app/crowdb-access-server/tests/common/s3_fallback.rs @@ -0,0 +1,148 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use base64::{engine::general_purpose::STANDARD, Engine}; +use crowdb_access_s3::metrics::S3Metrics; +use crowdb_access_server::s3::test_support::write_fallback_body_for_tests; +use crowdb_access_server::upload_flow::body_encoding::UploadBody; +use crowdb_chunk_client::{ChunkIoWriter, FeedStatus, IoError}; +use crowdb_protocol::chunkdb::rpc::Location; +use hyper::body::{Body, Bytes, Frame}; +use hyper::HeaderMap; +use md5::{Digest, Md5}; +use sha2::Sha256; +use std::{ + collections::VecDeque, + pin::Pin, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, +}; + +pub const TARGET: usize = 1024 * 1024; + +pub struct TestFrames { + pub frames: VecDeque, std::io::Error>>, + pub polls: Arc, +} +impl TestFrames { + pub fn new(frames: Vec) -> Self { + Self { + frames: frames.into_iter().map(|bytes| Ok(Frame::data(bytes))).collect(), + polls: Arc::default(), + } + } +} +impl Body for TestFrames { + type Data = Bytes; + type Error = std::io::Error; + fn poll_frame( + mut self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + self.polls.fetch_add(1, Ordering::Relaxed); + Poll::Ready(self.frames.pop_front()) + } +} + +pub fn body(frames: Vec, declared: Option) -> UploadBody { + let mut headers = HeaderMap::new(); + if let Some(length) = declared { + headers.insert("content-length", length.to_string().parse().unwrap()); + } + UploadBody::new(TestFrames::new(frames), &headers, None, 64 << 20).unwrap() +} + +#[derive(Default)] +pub struct TestWriter { + pub buffers: Vec, + pub calls: Arc, + pub reject: bool, + pub pause: bool, + pub blocked: Arc, + pub resume: Arc, +} +#[async_trait::async_trait] +impl ChunkIoWriter for TestWriter { + async fn on_data(&mut self, buffer: Bytes) -> crowdb_chunk_client::Result { + self.calls.fetch_add(1, Ordering::Relaxed); + if self.reject { + return Err(IoError::WriteFailed("injected write failure".into())); + } + self.buffers.push(buffer); + if self.pause { + self.pause = false; + self.blocked.store(true, Ordering::Relaxed); + Ok(FeedStatus::Pause) + } else { + Ok(FeedStatus::Continue) + } + } + async fn on_finish(&mut self) -> crowdb_chunk_client::Result> { + panic!("body handoff must not finish storage before integrity validation") + } + async fn on_error(&mut self) -> crowdb_chunk_client::Result> { + panic!("the caller owns abort and physical cleanup") + } + fn require_data(&self) -> bool { + !self.blocked.load(Ordering::Relaxed) + } + async fn wait_for_capacity(&mut self) { + if self.blocked.load(Ordering::Relaxed) { + self.resume.notified().await; + } + } +} + +struct TestOwner { + bytes: Vec, + live: Arc, +} +impl AsRef<[u8]> for TestOwner { + fn as_ref(&self) -> &[u8] { + &self.bytes + } +} +impl Drop for TestOwner { + fn drop(&mut self) { + self.live.fetch_sub(1, Ordering::Relaxed); + } +} +pub fn owned(length: usize, live: &Arc) -> Bytes { + live.fetch_add(1, Ordering::Relaxed); + Bytes::from_owner(TestOwner { + bytes: vec![37; length], + live: Arc::clone(live), + }) +} + +pub async fn checked(frames: Vec, declared: Option) -> TestWriter { + let expected: Vec<_> = frames.iter().flat_map(|bytes| bytes.iter().copied()).collect(); + let md5: [u8; 16] = Md5::digest(&expected).into(); + let expected_md5 = STANDARD.encode(md5); + let sha = hex::encode(Sha256::digest(&expected)); + let mut body = body(frames, declared); + let mut writer = TestWriter::default(); + let metrics = S3Metrics::default(); + let result = write_fallback_body_for_tests( + &mut body, + &mut writer, + 2, + Some(&expected_md5), + Some(&sha), + Some(&metrics), + ) + .await + .unwrap(); + assert_eq!(result, (hex::encode(md5), md5.to_vec())); + assert_eq!(metrics.snapshot().checksum_bytes, expected.len() as u64); + let actual: Vec<_> = writer + .buffers + .iter() + .flat_map(|bytes| bytes.iter().copied()) + .collect(); + assert_eq!(actual, expected); + writer +} diff --git a/app/crowdb-access-server/tests/config_test.rs b/app/crowdb-access-server/tests/config_test.rs index 07c72b2db..5cd0a826b 100644 --- a/app/crowdb-access-server/tests/config_test.rs +++ b/app/crowdb-access-server/tests/config_test.rs @@ -6,6 +6,30 @@ use std::path::Path; use crowdb_access_server::config::{load_args, AccessConfig, SmallWriteConfig}; use crowdb_common::config::{load_from_file, BaseConfig}; +#[test] +fn all_access_frontends_share_the_serialized_chunk_kv_rpc_class_settings() { + let mut config = AccessConfig::default(); + let native = crowdb_rpc_ffi::RdmaConfig { + enabled: true, + ..crowdb_rpc_ffi::RdmaConfig::default() + }; + config.common.chunk_kv_rpc.metadata_rdma = + Some(native.clone().with_control_data_capacity(65_536).unwrap()); + config.common.chunk_kv_rpc.aggregate_rdma = Some(native.with_control_data_capacity(262_144).unwrap()); + config.validate().unwrap(); + let encoded = serde_json::to_vec(&config).unwrap(); + let decoded: AccessConfig = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded.common.chunk_kv_rpc, config.common.chunk_kv_rpc); + config + .common + .chunk_kv_rpc + .metadata_rdma + .as_mut() + .unwrap() + .wire_layout = crowdb_rpc_ffi::RdmaWireLayout::Contiguous; + assert!(config.validate().unwrap_err().contains("metadata_rdma")); +} + #[test] fn tracked_access_configs_load_and_set_bounded_read_resources() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/app/crowdb-access-server/tests/credential_authority_test.rs b/app/crowdb-access-server/tests/credential_authority_test.rs index 45a71a49b..192a15aaa 100644 --- a/app/crowdb-access-server/tests/credential_authority_test.rs +++ b/app/crowdb-access-server/tests/credential_authority_test.rs @@ -11,6 +11,15 @@ const USER: &[u8] = b"console-root"; const PREPARED: &[u8] = b"crowdb/s3/user-bootstrap/"; const CREDENTIALS: &[u8] = b"crowdb/s3/credential/"; +fn legacy_key() -> Vec { + use sha2::Digest; + format!( + "crowdb/s3/user-bootstrap/{}", + hex::encode(sha2::Sha256::digest(USER)) + ) + .into_bytes() +} + #[tokio::test] async fn concurrent_bootstrap_callers_publish_one_encrypted_credential_and_reuse_it() { let test = TestCredentials::start().await; @@ -56,9 +65,8 @@ async fn concurrent_bootstrap_callers_publish_one_encrypted_credential_and_reuse ) .await .unwrap(); - assert_eq!(prepared.items.len(), 1); - let state: serde_json::Value = serde_json::from_slice(&prepared.items[0].1).unwrap(); - let encrypted: Vec = serde_json::from_value(state["record"].clone()).unwrap(); + assert!(prepared.items.is_empty()); + let encrypted = &durable.items[0].1; assert!(!encrypted .windows(tokens[0].secret_key.len()) .any(|bytes| bytes == tokens[0].secret_key.as_bytes())); @@ -71,31 +79,41 @@ async fn concurrent_bootstrap_callers_publish_one_encrypted_credential_and_reuse } #[tokio::test] -async fn interrupted_projection_resumes_the_original_encrypted_candidate_after_authority_restart() { +async fn deleted_final_credential_is_recreated_without_resurrecting_a_legacy_preparation() { let test = TestCredentials::start().await; let original = test.authority().ensure_user(USER).await.unwrap(); let key = format!("crowdb/s3/credential/{}", original.access_key_id); - let before = test + let GetOutcome::Found { value: before, .. } = test .control .get(0, 0, key.as_bytes(), ReadMode::Linearizable, None) .await - .unwrap(); - // Reproduce interruption after preparation but before its credential projection. + .unwrap() + else { + panic!() + }; + let legacy_key = legacy_key(); + let legacy = serde_json::to_vec(&serde_json::json!({"version": 1, + "access_key_id": original.access_key_id, "record": before.to_vec()})) + .unwrap(); + test.control.put(0, 0, &legacy_key, &legacy, None).await.unwrap(); test.control.delete(0, 0, key.as_bytes(), None).await.unwrap(); assert!(test.authority().lookup_user(USER).await.unwrap().is_none()); - let resumed = test.authority().ensure_user(USER).await.unwrap(); - assert_eq!(resumed.access_key_id, original.access_key_id); - assert_eq!(resumed.secret_key, original.secret_key); - match ( - before, - test.control - .get(0, 0, key.as_bytes(), ReadMode::Linearizable, None) - .await - .unwrap(), - ) { - (GetOutcome::Found { value: a, .. }, GetOutcome::Found { value: b, .. }) => assert_eq!(a, b), - _ => panic!("credential projection is absent"), - } + let replaced = test.authority().ensure_user(USER).await.unwrap(); + assert_eq!(replaced.access_key_id, original.access_key_id); + assert_ne!(replaced.secret_key, original.secret_key); + let GetOutcome::Found { value: after, .. } = test + .control + .get(0, 0, key.as_bytes(), ReadMode::Linearizable, None) + .await + .unwrap() + else { + panic!() + }; + assert_ne!(before, after); + assert_eq!( + test.authority().load_credentials().await.unwrap()[0].0, + replaced.access_key_id + ); } #[tokio::test] @@ -151,53 +169,67 @@ async fn legacy_credentials_are_preserved_and_existing_duplicates_are_never_repa } #[tokio::test] -async fn malformed_preparation_never_creates_or_replaces_a_user_credential() { +async fn malformed_legacy_process_records_do_not_authorize_or_block_credentials() { let test = TestCredentials::start().await; - let original = test.authority().ensure_user(USER).await.unwrap(); - test.control - .delete( - 0, - 0, - format!("crowdb/s3/credential/{}", original.access_key_id).as_bytes(), - None, - ) + let key = legacy_key(); + let bytes = b"not an encrypted credential"; + test.control.put(0, 0, &key, bytes, None).await.unwrap(); + assert!(test.authority().lookup_user(USER).await.unwrap().is_none()); + let issued = test.authority().ensure_user(USER).await.unwrap(); + assert_eq!( + test.authority() + .lookup_user(USER) + .await + .unwrap() + .unwrap() + .secret_key, + issued.secret_key + ); + assert_eq!(test.authority().load_credentials().await.unwrap().len(), 1); + let GetOutcome::Found { value, .. } = test + .control + .get(0, 0, &key, ReadMode::Linearizable, None) .await - .unwrap(); - let rows = test + .unwrap() + else { + panic!() + }; + assert_eq!(value.as_ref(), bytes); +} + +#[tokio::test] +async fn disabled_canonical_credentials_are_never_overwritten_by_bootstrap() { + let test = TestCredentials::start().await; + let issued = test.authority().ensure_user(USER).await.unwrap(); + let key = format!("crowdb/s3/credential/{}", issued.access_key_id); + let GetOutcome::Found { value, .. } = test .control - .scan( - 0, - 0, - PREPARED, - b"", - b"", - 100, - ReadMode::Linearizable, - None, - false, - None, - ) + .get(0, 0, key.as_bytes(), ReadMode::Linearizable, None) + .await + .unwrap() + else { + panic!() + }; + let mut record = crowdb_access_s3::auth::DurableCredentialRecord::decode(&value).unwrap(); + record.encrypted.enabled = false; + let disabled = record.encode().unwrap(); + test.control + .put(0, 0, key.as_bytes(), &disabled, None) .await .unwrap(); - let key = &rows.items[0].0; - let mut invalid: serde_json::Value = serde_json::from_slice(&rows.items[0].1).unwrap(); - invalid["version"] = serde_json::json!(2); - let bytes = serde_json::to_vec(&invalid).unwrap(); - test.control.put(0, 0, key, &bytes, None).await.unwrap(); assert!(matches!( test.authority().ensure_user(USER).await, - Err(CredentialAuthorityError::InvalidUserPreparation) + Err(CredentialAuthorityError::InvalidUserCredential) )); - assert!(test.authority().lookup_user(USER).await.unwrap().is_none()); let GetOutcome::Found { value, .. } = test .control - .get(0, 0, key, ReadMode::Linearizable, None) + .get(0, 0, key.as_bytes(), ReadMode::Linearizable, None) .await .unwrap() else { - panic!("preparation disappeared") + panic!() }; - assert_eq!(value.as_ref(), bytes.as_slice()); + assert_eq!(value.as_ref(), disabled); } #[tokio::test] diff --git a/app/crowdb-access-server/tests/dataset_batch_cost_test.rs b/app/crowdb-access-server/tests/dataset_batch_cost_test.rs new file mode 100644 index 000000000..c6a2a3f6d --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_batch_cost_test.rs @@ -0,0 +1,113 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/dataset_v2.rs"] +#[allow(dead_code)] +mod common; +#[path = "common/dataset.rs"] +mod dataset; + +use common::response; +use dataset::{TestDatasetHttp, TestStore}; +use md5::{Digest, Md5}; +use reqwest::Client; +use serde_json::json; +use std::sync::{atomic::Ordering, Arc}; + +#[tokio::test] +async fn multipart_fields_use_one_upload_read_each_and_no_eof_query() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start_payload(store.clone()).await; + let client = Client::new(); + let root = format!( + "{}/api/dataset/v1/namespaces/dataset-ns/datasets/cost", + test.origin + ); + response(client.put(&root), 200).await; + let upload = "11111111111111111111111111111111"; + let data = b"abcdefghij"; + response( + client.post(format!("{root}/uploads")).json(&json!({ + "id":upload,"length":10,"part_size":4,"md5":hex::encode(Md5::digest(data)) + })), + 200, + ) + .await; + for (index, part) in data.chunks(4).enumerate() { + response( + client + .put(format!("{root}/uploads/{upload}/parts/{index}")) + .header("content-type", "application/octet-stream") + .body(part.to_vec()), + 200, + ) + .await; + } + response(client.post(format!("{root}/uploads/{upload}/complete")), 200).await; + let published = response( + client.post(format!("{root}/snapshots/publish")).json(&json!({ + "operation":"22222222222222222222222222222222", "parent":null, + "manifest":{"schema":[{"name":"value","required":true}],"samples":[ + {"id":"YQ==","fields":{"value":{"kind":"payload","upload":upload}}}, + {"id":"Yg==","fields":{"value":{"kind":"payload","upload":upload}}} + ]} + })), + 200, + ) + .await; + let reads = store.upload_reads.load(Ordering::Relaxed); + let writes = store.mutations.load(Ordering::Relaxed); + let reply = client + .post(format!("{root}/reads")) + .bearer_auth("secret") + .json(&json!({ + "snapshot":published["id"],"sample_ids":["YQ==","Yg=="],"fields":["value"] + })) + .send() + .await + .unwrap(); + let status = reply.status(); + let bytes = reply.bytes().await.unwrap(); + assert_eq!(status, 200, "{}", String::from_utf8_lossy(&bytes)); + assert_fields(&bytes, &[data.as_slice(), data.as_slice()]); + assert_eq!(store.upload_reads.load(Ordering::Relaxed) - reads, 8); + assert_eq!(store.mutations.load(Ordering::Relaxed), writes); + drop(client); + test.stop().await; +} + +fn assert_fields(mut bytes: &[u8], expected: &[&[u8]]) { + assert_eq!(&bytes[..8], b"CDBDS001"); + bytes = &bytes[8..]; + let mut field = Vec::new(); + let mut index = 0; + let mut finished = false; + while !bytes.is_empty() { + assert!(!finished); + let kind = bytes[0]; + let length = u32::from_be_bytes(bytes[1..5].try_into().unwrap()) as usize; + let body = &bytes[5..5 + length]; + bytes = &bytes[5 + length..]; + match kind { + 1 => { + let _: serde_json::Value = serde_json::from_slice(body).unwrap(); + } + 2 => { + assert_eq!(&body[..16], Md5::digest(&body[16..]).as_slice()); + field.extend_from_slice(&body[16..]); + } + 3 => { + assert_eq!(field, expected[index]); + index += 1; + field.clear(); + } + 0 => { + assert_eq!(index, expected.len()); + assert!(field.is_empty()); + finished = true; + } + _ => panic!("unexpected frame {kind}: {}", String::from_utf8_lossy(body)), + } + } + assert!(finished); +} diff --git a/app/crowdb-access-server/tests/dataset_batch_header_test.rs b/app/crowdb-access-server/tests/dataset_batch_header_test.rs new file mode 100644 index 000000000..603fdac1e --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_batch_header_test.rs @@ -0,0 +1,135 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use crowdb_access_dataset::{FieldLocator, ReadPage, ResolvedReadPage, SampleView, SnapshotId}; +use crowdb_access_server::dataset::DatasetHttpService; +use hyper::body::Bytes; +use md5::{Digest, Md5}; +use std::collections::BTreeMap; + +const LIMIT: usize = 8 * 1024 * 1024; +const SNAPSHOT: &str = "11111111111111111111111111111111"; + +#[test] +fn ordered_cells_preserve_unicode_missing_tombstone_empty_and_chunk_wire_bytes() { + let (prefix, metadata) = DatasetHttpService::batch_header_for_tests(resolved(Some("游标\"/next"))) + .expect("bounded batch header"); + let expected = concat!( + "{\"snapshot\":\"11111111111111111111111111111111\",", + "\"sample_ids\":[\"YQ==\",\"+/8=\"],\"fields\":[", + "{\"sample\":\"YQ==\",\"field\":\"空\",\"state\":\"value\",\"length\":0,", + "\"md5\":\"d41d8cd98f00b204e9800998ecf8427e\"},", + "{\"sample\":\"YQ==\",\"field\":\"erased\",\"state\":\"tombstone\",\"length\":0,\"md5\":null},", + "{\"sample\":\"YQ==\",\"field\":\"absent\",\"state\":\"missing\",\"length\":0,\"md5\":null},", + "{\"sample\":\"YQ==\",\"field\":\"blob\",\"state\":\"value\",\"length\":1,", + "\"md5\":\"9dd4e461268c8034f5c8564e155c67a6\"},", + "{\"sample\":\"+/8=\",\"field\":\"空\",\"state\":\"missing\",\"length\":0,\"md5\":null},", + "{\"sample\":\"+/8=\",\"field\":\"erased\",\"state\":\"missing\",\"length\":0,\"md5\":null},", + "{\"sample\":\"+/8=\",\"field\":\"absent\",\"state\":\"missing\",\"length\":0,\"md5\":null},", + "{\"sample\":\"+/8=\",\"field\":\"blob\",\"state\":\"value\",\"length\":4294967297,", + "\"md5\":\"abababababababababababababababab\"}],", + "\"next\":\"游标\\\"/next\",\"end\":false}" + ); + assert_wire(&prefix, &metadata, expected); +} + +#[test] +fn terminal_header_keeps_sample_and_field_order_without_a_cursor() { + let (prefix, metadata) = DatasetHttpService::batch_header_for_tests(resolved(None)).unwrap(); + assert!(metadata.ends_with(b",\"next\":null,\"end\":true}")); + let value: serde_json::Value = serde_json::from_slice(&metadata).unwrap(); + assert_eq!(value["sample_ids"], serde_json::json!(["YQ==", "+/8="])); + assert_eq!(value["fields"][0]["field"], "空"); + assert_eq!(value["fields"][7]["sample"], "+/8="); + assert_eq!(value["next"], serde_json::Value::Null); + assert_eq!(value["end"], true); + assert_eq!(prefix.len(), 13); + assert_eq!( + u32::from_be_bytes(prefix[9..13].try_into().unwrap()) as usize, + metadata.len() + ); +} + +#[test] +fn empty_terminal_header_preserves_exact_wire_bytes() { + let (prefix, metadata) = DatasetHttpService::batch_header_for_tests(empty(None)).unwrap(); + assert_wire( + &prefix, + &metadata, + "{\"snapshot\":\"11111111111111111111111111111111\",\"sample_ids\":[],\"fields\":[],\"next\":null,\"end\":true}", + ); +} + +#[test] +fn metadata_header_accepts_exact_limit_and_rejects_one_more_encoded_byte() { + // Isolate the encoded-header limit; cursor admission has its own smaller bound. + let (_, fixed) = DatasetHttpService::batch_header_for_tests(empty(Some(String::new()))).unwrap(); + let remaining = LIMIT - fixed.len(); + let (prefix, metadata) = + DatasetHttpService::batch_header_for_tests(empty(Some("x".repeat(remaining)))).unwrap(); + assert_eq!(metadata.len(), LIMIT); + assert_eq!( + u32::from_be_bytes(prefix[9..13].try_into().unwrap()) as usize, + LIMIT + ); + assert_eq!( + DatasetHttpService::batch_header_for_tests(empty(Some("x".repeat(remaining + 1)))), + Err("request_too_large") + ); +} + +fn assert_wire(prefix: &Bytes, metadata: &Bytes, expected: &str) { + assert_eq!(metadata.as_ref(), expected.as_bytes()); + let mut expected_prefix = b"CDBDS001\x01".to_vec(); + expected_prefix.extend_from_slice(&u32::try_from(expected.len()).unwrap().to_be_bytes()); + assert_eq!(prefix.as_ref(), expected_prefix); +} + +fn resolved(next: Option<&str>) -> ResolvedReadPage { + let mut value = empty(next.map(str::to_owned)); + value.page.ids = vec![b"a".to_vec(), vec![0xfb, 0xff]]; + value.page.fields = ["空", "erased", "absent", "blob"].map(str::to_owned).to_vec(); + value.samples = vec![ + SampleView { + sample_id: b"a".to_vec(), + fields: BTreeMap::from([ + ("空".into(), inline(b"")), + ("erased".into(), FieldLocator::Tombstone), + ("blob".into(), inline(b"x")), + ]), + values: BTreeMap::new(), + }, + SampleView { + sample_id: vec![0xfb, 0xff], + fields: BTreeMap::from([( + "blob".into(), + FieldLocator::Chunk { + location: b"unread-location".to_vec(), + length: 4_294_967_297, + md5: [0xab; 16], + }, + )]), + values: BTreeMap::new(), + }, + ]; + value +} + +fn empty(next: Option) -> ResolvedReadPage { + ResolvedReadPage { + page: ReadPage { + snapshot: SnapshotId::from_bytes(&hex::decode(SNAPSHOT).unwrap()).unwrap(), + ids: Vec::new(), + fields: Vec::new(), + next, + }, + samples: Vec::new(), + } +} + +fn inline(value: &[u8]) -> FieldLocator { + FieldLocator::Inline { + value: value.to_vec(), + md5: Md5::digest(value).into(), + } +} diff --git a/app/crowdb-access-server/tests/dataset_complete_result_contract_test.rs b/app/crowdb-access-server/tests/dataset_complete_result_contract_test.rs new file mode 100644 index 000000000..e455420ea --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_complete_result_contract_test.rs @@ -0,0 +1,68 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/dataset.rs"] +mod dataset; +#[path = "../../../lib/crowdb-access-dataset/tests/common/publication.rs"] +mod publication; + +use crowdb_access_dataset::{ + DatasetAuthority, DatasetIdentity, DatasetSnapshotRequest, NamespacePath, OperationId, +}; +use dataset::{TestDatasetHttp, TestStore}; +use reqwest::Client; +use std::sync::Arc; + +async fn manifests( + client: &Client, + origin: &str, + snapshot: crowdb_access_dataset::SnapshotId, + expected: u16, +) { + let path = format!( + "{origin}/api/dataset/v1/namespaces/dataset-ns/datasets/legacy/snapshots/{snapshot}/manifest" + ); + let response = client.get(path).bearer_auth("secret").send().await.unwrap(); + let actual = response.status().as_u16(); + assert_eq!(actual, expected, "{}", response.text().await.unwrap()); + let response = client + .post(format!("{origin}/v1/dataset/manifest")) + .bearer_auth("secret") + .body(bincode::serialize(&DatasetSnapshotRequest { snapshot }).unwrap()) + .send() + .await + .unwrap(); + let actual = response.status().as_u16(); + let body = response.bytes().await.unwrap(); + assert_eq!(actual, expected, "{body:?}"); +} + +#[tokio::test] +async fn json_and_binary_manifest_reads_admit_only_complete_live_resources() { + let store = Arc::new(TestStore::default()); + let authority = DatasetAuthority::new(store.clone()); + let identity = DatasetIdentity::new(NamespacePath::root(), "legacy").unwrap(); + authority.create_dataset(identity.clone()).await.unwrap(); + let snapshot = authority + .prepare_snapshot_with_operation( + &identity, + None, + publication::publication_manifest(1), + OperationId::random(), + ) + .await + .unwrap(); + let test = TestDatasetHttp::start(store).await; + let client = Client::new(); + manifests(&client, &test.origin, snapshot, 404).await; + authority + .complete_snapshot_candidate(&identity, snapshot) + .await + .unwrap(); + assert_eq!(authority.latest(&identity).await.unwrap(), None); + manifests(&client, &test.origin, snapshot, 200).await; + authority.begin_reclaim(&identity, snapshot).await.unwrap(); + manifests(&client, &test.origin, snapshot, 409).await; + drop(client); + test.stop().await; +} diff --git a/app/crowdb-access-server/tests/dataset_composition_contract_test.rs b/app/crowdb-access-server/tests/dataset_composition_contract_test.rs index b52d2f237..3df0b6d22 100644 --- a/app/crowdb-access-server/tests/dataset_composition_contract_test.rs +++ b/app/crowdb-access-server/tests/dataset_composition_contract_test.rs @@ -11,10 +11,10 @@ use common::response; use dataset::{TestDatasetHttp, TestStore}; use reqwest::Client; use serde_json::{json, Value}; -use std::sync::Arc; +use std::sync::{atomic::Ordering, Arc}; #[tokio::test] -async fn uploaded_composition_publishes_initial_splits_and_branches_from_a_frozen_split() { +async fn uploaded_resources_publish_initial_splits_and_branch_from_a_frozen_split() { let test = TestDatasetHttp::start_payload(Arc::new(TestStore::default())).await; let client = Client::new(); let capabilities = response( @@ -22,12 +22,16 @@ async fn uploaded_composition_publishes_initial_splits_and_branches_from_a_froze 200, ) .await; - for name in ["composition", "uploads", "snapshot-reclaim"] { + for name in ["composition-preparation", "uploads", "snapshot-reclaim"] { assert!(capabilities["capabilities"] .as_array() .unwrap() .contains(&json!(name))); } + assert!(!capabilities["capabilities"] + .as_array() + .unwrap() + .contains(&json!("composition"))); let root = format!( "{}/api/dataset/v2/namespaces/dataset-ns/datasets/import", test.origin @@ -36,38 +40,20 @@ async fn uploaded_composition_publishes_initial_splits_and_branches_from_a_froze response(client.put(&root), 409).await; let upload = "11111111111111111111111111111111"; create_upload(&client, &root, upload).await; - let operation = "22222222222222222222222222222222"; - let draft = format!("{root}/compositions/{operation}"); - let mut begin = composition::begin(operation, &Value::Null); - begin["splits"] = common::definitions(); - begin["policy"] = json!({"overlap":"disjoint","coverage":"require_complete"}); - response(client.post(format!("{root}/compositions")).json(&begin), 200).await; - let edit = json!({"edit":"33333333333333333333333333333333","expected_revision":"1","samples":[{"kind":"upsert","id":"AAH/","fields":[{"name":"value","value":{"kind":"upload","id":upload}}]}]}); + let snapshot = "22222222222222222222222222222222"; + let mut input = composition::input(&Value::Null); + input["splits"] = common::definitions(); + input["policy"]["coverage"] = json!("require_complete"); + input["samples"] = json!([{"kind":"upsert","id":"AAH/","fields":[{"name":"value","value":{"kind":"upload","id":upload,"length":"4","md5":"8d777f385d3dfec8815d20f7496026dc"}}]}]); + let prepared = composition::prepare(&client, &root, snapshot, &input).await; + assert_eq!(prepared["sample_count"], "1"); assert_eq!( - response(client.put(&draft).json(&edit), 200).await["revision"], - "2" + composition::prepare(&client, &root, snapshot, &input).await, + prepared ); response(client.delete(format!("{root}/uploads/{upload}")), 409).await; - let prepared = composition::prepare(&client, &draft, "2", "1").await; - let snapshot = prepared["preparation"]["snapshot"].as_str().unwrap(); - response(client.get(format!("{root}/snapshots/{snapshot}/summary")), 404).await; - let receipt = composition::publish( - &client, - &root, - &draft, - &prepared, - "44444444444444444444444444444444", - ) - .await; - assert_eq!( - response( - client.get(format!("{root}/commits/44444444444444444444444444444444")), - 200 - ) - .await["receipt"], - receipt - ); - let head = response(client.get(format!("{root}/head")), 200).await; + response(client.get(format!("{root}/snapshots/{snapshot}/summary")), 200).await; + let head = composition::publish(&client, &root, &prepared, &Value::Null).await; assert_eq!(head["latest"], snapshot); assert_eq!(head["stable"], Value::Null); let preview = response( @@ -79,25 +65,17 @@ async fn uploaded_composition_publishes_initial_splits_and_branches_from_a_froze .await; assert_eq!(preview["data"], "ZGF0YQ=="); let (revision, name) = populated_split(&client, &root, snapshot).await; - let child_op = "55555555555555555555555555555555"; - let child = format!("{root}/compositions/{child_op}"); - let mut begin = composition::begin(child_op, &json!(snapshot)); - begin["source"] = json!({"kind":"split","source":{"snapshot":snapshot,"revision":revision,"name":name},"selection":null}); - begin["inherit_parent_splits"] = json!(true); - begin["parent_split_revision"] = json!(revision); - response(client.post(format!("{root}/compositions")).json(&begin), 200).await; - let child_prepared = composition::prepare(&client, &child, "1", "1").await; - assert_eq!(child_prepared["uploads"]["candidate_uploads"], "1"); - composition::publish( - &client, - &root, - &child, - &child_prepared, - "66666666666666666666666666666666", - ) - .await; - composition::cleanup(&client, &draft).await; - composition::cleanup(&client, &child).await; + let mut child = composition::input(&json!(snapshot)); + child["schema"] = Value::Null; + child["source"] = json!({"kind":"split","source":{"snapshot":snapshot,"revision":revision,"name":name},"selection":null}); + child["splits"] = input["splits"].clone(); + child["policy"] = input["policy"].clone(); + for split in child["splits"].as_array_mut().unwrap() { + split["origin"] = json!({"kind":"inherited","source":{"snapshot":snapshot,"revision":revision,"name":split["name"]}}); + } + let prepared = composition::prepare(&client, &root, "55555555555555555555555555555555", &child).await; + assert_eq!(prepared["sample_count"], "1"); + composition::publish(&client, &root, &prepared, &head["generation"]).await; response( client .put(format!("{root}/names/stable")) @@ -107,50 +85,39 @@ async fn uploaded_composition_publishes_initial_splits_and_branches_from_a_froze .await; assert_eq!( response(client.get(format!("{root}/head")), 200).await["latest"], - child_prepared["preparation"]["snapshot"] + prepared["snapshot"] ); response(client.post(format!("{root}/snapshots/{snapshot}/reclaim")), 409).await; + drop(client); test.stop().await; } #[tokio::test] -async fn composition_conflicts_and_cancellation_preserve_exact_retry_identity() { - let test = TestDatasetHttp::start_payload(Arc::new(TestStore::default())).await; +async fn retired_composition_workflow_writes_never_access_storage() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start_payload(store.clone()).await; let client = Client::new(); - let (root, parent) = common::fixture(&client, &test.origin).await; - let operation = "77777777777777777777777777777777"; - let draft = format!("{root}/compositions/{operation}"); - let begin = composition::begin(operation, &json!(parent)); - response(client.post(format!("{root}/compositions")).json(&begin), 200).await; - let edit = json!({"edit":"88888888888888888888888888888888","expected_revision":"1","samples":[]}); - response(client.put(&draft).json(&edit), 200).await; - response( - client - .put(&draft) - .json(&json!({"edit":"99999999999999999999999999999999","expected_revision":"1","samples":[]})), - 409, - ) - .await; - response(client.put(&draft).json(&json!({"edit":"99999999999999999999999999999999","expected_revision":"2","samples":[{"kind":"upsert","id":"YQ==","fields":[{"name":"value","value":{"kind":"chunk","location":"arbitrary"}}]}]})),400).await; - response(client.delete(&draft).json(&json!({"expected_revision":"1"})), 409).await; - response(client.delete(&draft).json(&json!({"expected_revision":"2"})), 200).await; - composition::cleanup(&client, &draft).await; - assert_eq!( - response(client.put(&draft).json(&edit), 200).await["revision"], - "2" + let (root, _) = common::fixture(&client, &test.origin).await; + let before = ( + store.reads.load(Ordering::Relaxed), + store.bounded_reads.load(Ordering::Relaxed), + store.mutations.load(Ordering::Relaxed), ); + response(client.post(format!("{root}/compositions")).json(&json!({})), 501).await; + let draft = format!("{root}/compositions/77777777777777777777777777777777"); + response(client.put(&draft).json(&json!({})), 501).await; + for suffix in ["evaluate", "prepare", "publish", "cleanup"] { + response(client.post(format!("{draft}/{suffix}")).json(&json!({})), 501).await; + } assert_eq!( - response(client.get(format!("{root}/head")), 200).await["latest"], - parent + ( + store.reads.load(Ordering::Relaxed), + store.bounded_reads.load(Ordering::Relaxed), + store.mutations.load(Ordering::Relaxed) + ), + before ); - response(client.get(format!("{root}/head?unexpected=1")), 400).await; - response( - client - .post(format!("{draft}/publish")) - .json(&json!({"attempt":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","snapshot":parent})), - 400, - ) - .await; + drop(client); test.stop().await; } diff --git a/app/crowdb-access-server/tests/dataset_composition_preparation_test.rs b/app/crowdb-access-server/tests/dataset_composition_preparation_test.rs new file mode 100644 index 000000000..7f5311cd4 --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_composition_preparation_test.rs @@ -0,0 +1,186 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/dataset_v2.rs"] +#[allow(dead_code)] +mod common; +#[path = "common/dataset.rs"] +mod dataset; +use common::response; +use dataset::{TestDatasetHttp, TestStore}; +use reqwest::Client; +use serde_json::{json, Value}; +use std::sync::{atomic::Ordering, Arc}; + +fn input() -> Value { + json!({"parent":null, "source":{"kind":"all"}, + "schema":{"version":1,"fields":[{"name":"value","required":true}]}, + "samples":[{"kind":"upsert","id":"AAH/","fields":[{"name":"value","value":{"kind":"inline","data":"ZGF0YQ=="}}]}], + "splits":[{"name":"train","definition":{"kind":"selection","predicate":{"kind":"all"}},"origin":{"kind":"evaluated"}}], + "policy":{"overlap":"disjoint","coverage":"require_complete"}}) +} +fn counts(store: &TestStore) -> (u64, u64) { + ( + store.reads.load(Ordering::Relaxed) + store.bounded_reads.load(Ordering::Relaxed), + store.mutations.load(Ordering::Relaxed), + ) +} +fn root(origin: &str) -> String { + format!("{origin}/api/dataset/v2/namespaces/dataset-ns/datasets/composition-resource") +} + +#[tokio::test] +async fn complete_resource_reuses_the_snapshot_id_across_restart_without_advancing_head() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start_payload(store.clone()).await; + let client = Client::new(); + let root = root(&test.origin); + response(client.put(&root), 200).await; + let id = "12".repeat(16); + let url = format!("{root}/snapshots/{id}"); + let before = counts(&store); + let prepared = response(client.put(&url).json(&input()), 200).await; + let after = counts(&store); + assert_eq!((after.0 - before.0, after.1 - before.1), (12, 16)); + assert_eq!(prepared["snapshot"], id); + assert_eq!(prepared["sample_count"], "1"); + assert!(prepared["initial_split"].is_string()); + assert_eq!( + response(client.get(format!("{root}/head")), 200).await["latest"], + Value::Null + ); + response(client.get(format!("{url}/summary")), 200).await; + drop(client); + test.stop().await; + let client = Client::new(); + let test = TestDatasetHttp::start_payload(store.clone()).await; + let root = self::root(&test.origin); + let url = format!("{root}/snapshots/{id}"); + let before = counts(&store); + assert_eq!(response(client.put(&url).json(&input()), 200).await, prepared); + assert_eq!(counts(&store).1, before.1); + let mut changed = input(); + changed["splits"] = json!([]); + response(client.put(&url).json(&changed), 409).await; + assert_eq!(counts(&store).1, before.1); + response( + client + .put(format!("{root}/head")) + .json(&json!({"snapshot":id,"expected_head":null})), + 200, + ) + .await; + assert_eq!( + response(client.get(format!("{root}/head")), 200).await["latest"], + id + ); + drop(client); + test.stop().await; +} + +#[tokio::test] +async fn uploaded_descriptors_are_validated_once_per_unique_upload_without_conversion_queries() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start_payload(store.clone()).await; + let client = Client::new(); + let root = root(&test.origin); + response(client.put(&root), 200).await; + let upload = "34".repeat(16); + let upload_url = format!("{root}/uploads/{upload}"); + let descriptor = json!({"id":upload,"length":"4","part_size":4,"md5":"8d777f385d3dfec8815d20f7496026dc"}); + response(client.post(format!("{root}/uploads")).json(&descriptor), 200).await; + response( + client + .put(format!("{upload_url}/parts/0")) + .header("content-type", "application/octet-stream") + .body("data"), + 200, + ) + .await; + response(client.post(format!("{upload_url}/complete")), 200).await; + let mut input = input(); + input["splits"] = json!([]); + input["schema"]["fields"] + .as_array_mut() + .unwrap() + .push(json!({"name":"other","required":true})); + let value = json!({"kind":"upload","id":upload,"length":descriptor["length"],"md5":descriptor["md5"]}); + input["samples"][0]["fields"] = json!([{"name":"value","value":value},{"name":"other","value":value}]); + let before = store.upload_reads.load(Ordering::Relaxed); + let snapshot = "56".repeat(16); + response( + client.put(format!("{root}/snapshots/{snapshot}")).json(&input), + 200, + ) + .await; + assert_eq!(store.upload_reads.load(Ordering::Relaxed) - before, 2); + let url = format!("{root}/snapshots/{snapshot}/preview?sample=AAH%2F&field=other"); + assert_eq!(response(client.get(url), 200).await["data"], "ZGF0YQ=="); + input["samples"][0]["fields"][0]["value"]["length"] = json!("5"); + let before = counts(&store).1; + response( + client + .put(format!("{root}/snapshots/{}", "78".repeat(16))) + .json(&input), + 409, + ) + .await; + assert_eq!(counts(&store).1, before); + drop(client); + test.stop().await; +} + +#[tokio::test] +async fn auth_strict_input_and_limits_reject_before_storage_and_no_service_has_no_capability() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start_payload(store.clone()).await; + let client = Client::new(); + let url = format!("{}/snapshots/{}", root(&test.origin), "12".repeat(16)); + let denied = client.put(&url).json(&input()).send().await.unwrap(); + assert_eq!(denied.status(), 401); + denied.bytes().await.unwrap(); + for field in ["parent", "schema", "samples", "policy"] { + let mut invalid = input(); + invalid.as_object_mut().unwrap().remove(field); + response(client.put(&url).json(&invalid), 400).await; + } + let mut invalid = input(); + invalid["operation"] = json!("ab".repeat(16)); + response(client.put(&url).json(&invalid), 400).await; + invalid = input(); + invalid["samples"][0]["fields"][0]["value"] = json!({"kind":"upload","id":"ab".repeat(16)}); + response(client.put(&url).json(&invalid), 400).await; + response( + client.put(format!("{url}?expected_revision=1")).json(&input()), + 400, + ) + .await; + response( + client.put(&url).header("content-type", "text/plain").body("{}"), + 400, + ) + .await; + response( + client + .put(&url) + .header("content-type", "application/json") + .body(vec![b' '; 8 * 1024 * 1024 + 1]), + 413, + ) + .await; + assert_eq!(counts(&store), (0, 0)); + drop(client); + test.stop().await; + let test = TestDatasetHttp::start(store.clone()).await; + let client = Client::new(); + response( + client + .put(format!("{}/snapshots/{}", root(&test.origin), "12".repeat(16))) + .json(&input()), + 501, + ) + .await; + assert_eq!(counts(&store), (0, 0)); + drop(client); + test.stop().await; +} diff --git a/app/crowdb-access-server/tests/dataset_composition_recovery_test.rs b/app/crowdb-access-server/tests/dataset_composition_recovery_test.rs index 82afeb9fa..faf96fed8 100644 --- a/app/crowdb-access-server/tests/dataset_composition_recovery_test.rs +++ b/app/crowdb-access-server/tests/dataset_composition_recovery_test.rs @@ -11,32 +11,29 @@ mod dataset; use common::response; use dataset::{TestDatasetHttp, TestStore}; use reqwest::Client; -use serde_json::{json, Value}; +use serde_json::json; use std::sync::Arc; #[tokio::test] -async fn restart_and_head_conflict_preserve_candidates_and_receipts() { +async fn restart_and_head_conflict_preserve_candidates_without_receipt_writes() { let store = Arc::new(TestStore::default()); let first = TestDatasetHttp::start_payload(store.clone()).await; let client = Client::new(); let (root, parent) = common::fixture(&client, &first.origin).await; let origin = first.origin.clone(); let old_head = response(client.get(format!("{root}/head")), 200).await; - let operation = "12121212121212121212121212121212"; - response( - client - .post(format!("{root}/compositions")) - .json(&composition::begin(operation, &json!(parent))), - 200, - ) - .await; + let snapshot = "12121212121212121212121212121212"; + let input = composition::input(&json!(parent)); + let prepared = composition::prepare(&client, &root, snapshot, &input).await; drop(client); first.stop().await; let second = TestDatasetHttp::start_payload(store).await; let root = root.replacen(&origin, &second.origin, 1); let client = Client::new(); - let draft = format!("{root}/compositions/{operation}"); - let prepared = composition::prepare(&client, &draft, "1", "45").await; + assert_eq!( + composition::prepare(&client, &root, snapshot, &input).await, + prepared + ); response( client .put(format!("{root}/names/stable")) @@ -44,25 +41,15 @@ async fn restart_and_head_conflict_preserve_candidates_and_receipts() { 200, ) .await; - let attempt = "13131313131313131313131313131313"; - let mut publish = json!({"attempt":attempt,"snapshot":prepared["preparation"]["snapshot"],"expected_head":old_head["generation"]}); - response(client.post(format!("{draft}/publish")).json(&publish), 409).await; - assert_eq!( - response(client.get(&draft), 200).await["progress"]["state"], - "prepared" - ); - publish["attempt"] = json!("14141414141414141414141414141414"); - publish["expected_head"] = response(client.get(format!("{root}/head")), 200).await["generation"].clone(); - let receipt = response(client.post(format!("{draft}/publish")).json(&publish), 200).await; - composition::cleanup(&client, &draft).await; - assert_eq!( - response(client.post(format!("{draft}/publish")).json(&publish), 200).await, - receipt - ); - assert_eq!( - response(client.get(format!("{root}/commits/{attempt}")), 200).await["receipt"], - Value::Null - ); + let mut publish = json!({"snapshot":snapshot,"expected_head":old_head["generation"]}); + let url = format!("{root}/head"); + let conflict = response(client.put(&url).json(&publish), 409).await; + assert_eq!(conflict["observed_head"]["latest"], parent); + assert_eq!(conflict["observed_head"]["stable"], parent); + response(client.get(format!("{root}/snapshots/{snapshot}/summary")), 200).await; + publish["expected_head"] = conflict["observed_head"]["generation"].clone(); + let current = response(client.put(&url).json(&publish), 200).await; + assert_eq!(response(client.put(&url).json(&publish), 200).await, current); drop(client); second.stop().await; } diff --git a/app/crowdb-access-server/tests/dataset_contract_test.rs b/app/crowdb-access-server/tests/dataset_contract_test.rs index d99aa28a0..e44e4dc57 100644 --- a/app/crowdb-access-server/tests/dataset_contract_test.rs +++ b/app/crowdb-access-server/tests/dataset_contract_test.rs @@ -63,7 +63,7 @@ async fn json_prepare_publish_retry_and_restart_preserve_one_candidate() { 200, ) .await; - assert_eq!(prepared["state"], "prepared"); + assert_eq!(prepared["state"], "published"); assert_eq!(prepared["is_latest"], false); let opened = call(&client, &test, Method::GET, ROOT, None, 200).await; assert_eq!(opened["latest"], Value::Null); @@ -73,7 +73,7 @@ async fn json_prepare_publish_retry_and_restart_preserve_one_candidate() { Method::GET, &format!("{ROOT}/snapshots/{}", prepared["id"].as_str().unwrap()), None, - 404, + 200, ) .await; drop(client); diff --git a/app/crowdb-access-server/tests/dataset_inspection_contract_test.rs b/app/crowdb-access-server/tests/dataset_inspection_contract_test.rs index d9da18ec6..4313bbf41 100644 --- a/app/crowdb-access-server/tests/dataset_inspection_contract_test.rs +++ b/app/crowdb-access-server/tests/dataset_inspection_contract_test.rs @@ -166,7 +166,7 @@ async fn catalog_continuations_do_not_cross_scope_or_repeat_entries() { } #[tokio::test] -async fn prepared_snapshot_is_hidden_from_metadata_and_preparation_status() { +async fn complete_prepared_snapshot_is_visible_without_updating_latest() { let test = TestDatasetHttp::start(Arc::new(TestStore::default())).await; let client = Client::new(); let (root, parent) = fixture(&client, &test).await; @@ -185,11 +185,11 @@ async fn prepared_snapshot_is_hidden_from_metadata_and_preparation_status() { .await; let id = prepared["id"].as_str().unwrap(); let inventory = response(client.get(format!("{root}/snapshot-inventory")), 200).await; - assert_eq!(inventory["entries"].as_array().unwrap().len(), 1); + assert_eq!(inventory["entries"].as_array().unwrap().len(), 2); for action in ["summary", "schema", "samples", "inspection"] { - response(client.get(format!("{root}/snapshots/{id}/{action}")), 404).await; + response(client.get(format!("{root}/snapshots/{id}/{action}")), 200).await; } - response(client.post(format!("{root}/snapshots/{id}/inspection")), 404).await; + response(client.post(format!("{root}/snapshots/{id}/inspection")), 200).await; drop(client); test.stop().await; } diff --git a/app/crowdb-access-server/tests/dataset_preview_contract_test.rs b/app/crowdb-access-server/tests/dataset_preview_contract_test.rs index 468e9c846..6f473e12d 100644 --- a/app/crowdb-access-server/tests/dataset_preview_contract_test.rs +++ b/app/crowdb-access-server/tests/dataset_preview_contract_test.rs @@ -69,7 +69,8 @@ async fn exact_matrix_and_inline_preview_do_not_create_read_sessions() { #[tokio::test] async fn payload_preview_caps_bytes_across_parts_and_distinguishes_missing_tombstone() { - let test = TestDatasetHttp::start_payload(Arc::new(TestStore::default())).await; + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start_payload(store.clone()).await; let client = Client::new(); let root = format!( "{}/api/dataset/v1/namespaces/dataset-ns/datasets/preview", @@ -91,6 +92,8 @@ async fn payload_preview_caps_bytes_across_parts_and_distinguishes_missing_tombs root.replace("/v1/", "/v2/"), published["id"].as_str().unwrap() ); + let upload_reads = store.upload_reads.load(std::sync::atomic::Ordering::Relaxed); + let admissions = store.admission_reads.load(std::sync::atomic::Ordering::Relaxed); let preview = response( client .get(&url) @@ -105,6 +108,14 @@ async fn payload_preview_caps_bytes_across_parts_and_distinguishes_missing_tombs assert_eq!(preview["length"], "100000"); assert_eq!(preview["truncated"], true); assert_eq!(preview["md5"], hex::encode(Md5::digest(&data))); + assert_eq!( + store.admission_reads.load(std::sync::atomic::Ordering::Relaxed) - admissions, + 1 + ); + assert_eq!( + store.upload_reads.load(std::sync::atomic::Ordering::Relaxed) - upload_reads, + 3 + ); for (field, state) in [("missing", "missing"), ("deleted", "tombstone")] { let p = response( client diff --git a/app/crowdb-access-server/tests/dataset_publication_cost_test.rs b/app/crowdb-access-server/tests/dataset_publication_cost_test.rs new file mode 100644 index 000000000..85617fc09 --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_publication_cost_test.rs @@ -0,0 +1,93 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/dataset.rs"] +mod dataset; +use base64::{engine::general_purpose::STANDARD, Engine}; +use dataset::{TestDatasetHttp, TestStore}; +use reqwest::Client; +use serde_json::Value; +use std::sync::{atomic::Ordering, Arc}; + +fn counts(store: &TestStore) -> (u64, u64) { + ( + store.reads.load(Ordering::Relaxed) + store.bounded_reads.load(Ordering::Relaxed), + store.mutations.load(Ordering::Relaxed), + ) +} +async fn call(client: &Client, url: &str, input: &Value) -> Value { + let response = client + .post(url) + .bearer_auth("secret") + .json(input) + .send() + .await + .unwrap(); + let status = response.status().as_u16(); + let body: Value = response.json().await.unwrap(); + assert_eq!(status, 200, "{body}"); + body +} + +#[tokio::test] +async fn inline_publication_and_identical_replay_have_bounded_metadata_cost() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; + let client = Client::new(); + for samples in [1u8, 40] { + let root = format!( + "{}/api/dataset/v1/namespaces/dataset-ns/datasets/publication-cost-{samples}", + test.origin + ); + assert_eq!( + client + .put(&root) + .bearer_auth("secret") + .send() + .await + .unwrap() + .status() + .as_u16(), + 200 + ); + let mut input: Value = serde_json::from_str(include_str!( + "../../../sdk/contract-fixtures/dataset-v1/publication.json" + )) + .unwrap(); + let template = input["manifest"]["samples"][0].clone(); + input["manifest"]["samples"] = (0..samples) + .map(|id| { + let mut sample = template.clone(); + sample["id"] = STANDARD.encode([id]).into(); + sample + }) + .collect(); + let before = counts(&store); + let result = call(&client, &format!("{root}/snapshots/publish"), &input).await; + let after = counts(&store); + assert_eq!( + (after.0 - before.0, after.1 - before.1), + (12, 11 + 3 * u64::from(samples)) + ); + println!( + "cold publication: {} reads, {} mutations", + after.0 - before.0, + after.1 - before.1 + ); + assert_eq!(result["is_latest"], true); + let before = after; + assert_eq!( + call(&client, &format!("{root}/snapshots/publish"), &input).await, + result + ); + let after = counts(&store); + assert_eq!((after.0 - before.0, after.1 - before.1), (11, 0)); + println!( + "identical replay: {} reads, {} mutations", + after.0 - before.0, + after.1 - before.1 + ); + } + drop(client); + test.stop().await; +} diff --git a/app/crowdb-access-server/tests/dataset_publication_payload_test.rs b/app/crowdb-access-server/tests/dataset_publication_payload_test.rs new file mode 100644 index 000000000..8613b7a45 --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_publication_payload_test.rs @@ -0,0 +1,246 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/publication_payload.rs"] +mod common; +use common::{input, response, TestPublicationHttp}; +use crowdb_access_dataset::{FieldLocator, OperationId, SnapshotId, UploadState}; +use md5::{Digest, Md5}; +use reqwest::Client; +use serde_json::json; +use std::sync::atomic::Ordering; + +#[tokio::test] +async fn repeated_payload_fields_use_two_metadata_reads_and_one_claim_per_unique_upload() { + for (fields, unique) in [(1, 1), (40, 1), (40, 3)] { + let test = TestPublicationHttp::start().await; + let client = Client::new(); + let mut uploads = Vec::new(); + for marker in 1..=unique { + uploads.push(test.upload(marker, true).await); + } + let input = input(&uploads, fields, 32); + let before = test.store.upload_reads.load(Ordering::Relaxed); + let claims = test.store.claim_attempts.load(Ordering::Relaxed); + let complete = test.store.complete_attempts.load(Ordering::Relaxed); + let url = format!("{}/snapshots/publish", test.root); + let published = response(client.post(&url).json(&input), 200).await; + assert_eq!( + test.store.upload_reads.load(Ordering::Relaxed) - before, + 2 * u64::from(unique) + ); + assert_eq!( + test.store.claim_attempts.load(Ordering::Relaxed) - claims, + u64::from(unique) + ); + assert_eq!(test.store.complete_attempts.load(Ordering::Relaxed) - complete, 1); + println!( + "v1 payload publication fields={fields} uploads={unique}: upload GET {} -> {}, claim CAS={unique}", + u64::from(fields) + u64::from(unique), 2 * u64::from(unique) + ); + let snapshot = + SnapshotId::from_bytes(&hex::decode(published["id"].as_str().unwrap()).unwrap()).unwrap(); + let manifest = test + .authority + .get_manifest(&test.identity, snapshot) + .await + .unwrap(); + for (index, sample) in manifest.samples.iter().enumerate() { + let marker = u8::try_from(index % usize::from(unique)).unwrap() + 1; + let data = vec![marker; usize::from(marker) + 4]; + let FieldLocator::Chunk { length, md5, .. } = sample.fields[0].value else { + panic!("payload locator missing") + }; + assert_eq!(length, data.len() as u64); + assert_eq!(md5.as_slice(), Md5::digest(&data).as_slice()); + } + let before = test.store.upload_reads.load(Ordering::Relaxed); + let claims = test.store.claim_attempts.load(Ordering::Relaxed); + assert_eq!(response(client.post(&url).json(&input), 200).await, published); + assert_eq!( + test.store.upload_reads.load(Ordering::Relaxed) - before, + 2 * u64::from(unique) + ); + assert_eq!(test.store.claim_attempts.load(Ordering::Relaxed), claims); + drop(client); + test.stop().await; + } +} + +#[tokio::test] +async fn malformed_repeated_fields_are_rejected_before_metadata_or_ownership() { + let test = TestPublicationHttp::start().await; + let client = Client::new(); + let upload = test.upload(1, true).await; + let mut invalid = input(std::slice::from_ref(&upload), 2, 32); + invalid["manifest"]["samples"][1]["fields"]["value"]["upload"] = json!("not-an-upload"); + let before = test.store.mutations(); + let reads = test.store.upload_reads.load(Ordering::Relaxed); + response( + client + .post(format!("{}/snapshots/publish", test.root)) + .json(&invalid), + 400, + ) + .await; + let duplicate = format!( + r#"{{"operation":"{}","parent":null,"manifest":{{"schema":[{{"name":"value","required":true}}],"samples":[{{"id":"AA==","fields":{{"value":{{"kind":"payload","upload":"{upload}"}},"value":{{"kind":"payload","upload":"{upload}"}}}}}}]}}}}"#, + "20".repeat(16) + ); + response( + client + .post(format!("{}/snapshots/publish", test.root)) + .header("content-type", "application/json") + .body(duplicate), + 400, + ) + .await; + assert_eq!(test.store.mutations(), before); + assert_eq!(test.store.upload_reads.load(Ordering::Relaxed), reads); + assert_eq!(test.store.claim_attempts.load(Ordering::Relaxed), 0); + drop(client); + test.stop().await; +} + +#[tokio::test] +async fn incomplete_second_upload_is_not_hidden_by_repeated_valid_fields() { + let test = TestPublicationHttp::start().await; + let client = Client::new(); + let valid = test.upload(1, true).await; + let incomplete = test.upload(2, false).await; + let mut input = input(&[valid], 4, 32); + input["manifest"]["samples"][3]["fields"]["value"]["upload"] = incomplete.into(); + let before = test.store.mutations(); + let reads = test.store.upload_reads.load(Ordering::Relaxed); + response( + client + .post(format!("{}/snapshots/publish", test.root)) + .json(&input), + 409, + ) + .await; + assert_eq!(test.store.upload_reads.load(Ordering::Relaxed) - reads, 2); + assert_eq!(test.store.mutations(), before); + assert_eq!(test.store.claim_attempts.load(Ordering::Relaxed), 0); + drop(client); + test.stop().await; +} + +#[tokio::test] +async fn later_request_rechecks_aborted_upload_instead_of_reusing_cached_metadata() { + let test = TestPublicationHttp::start().await; + let client = Client::new(); + let upload = test.upload(1, true).await; + let first = input(std::slice::from_ref(&upload), 8, 32); + response( + client + .post(format!("{}/snapshots/publish", test.root)) + .json(&first), + 200, + ) + .await; + let location = test.location(&upload); + test.uploads + .retire_owner(&location, OperationId::from_bytes(&[32; 16]).unwrap()) + .await + .unwrap(); + test.uploads.abort(&location).await.unwrap(); + let mut next = input(&[upload], 8, 33); + next["parent"] = hex::encode([32; 16]).into(); + let before = test.store.mutations(); + let reads = test.store.upload_reads.load(Ordering::Relaxed); + response( + client + .post(format!("{}/snapshots/publish", test.root)) + .json(&next), + 409, + ) + .await; + assert_eq!(test.store.upload_reads.load(Ordering::Relaxed) - reads, 1); + assert_eq!(test.store.mutations(), before); + drop(client); + test.stop().await; +} + +#[tokio::test] +async fn claim_cas_still_rejects_invalidation_after_resolution() { + let test = TestPublicationHttp::start().await; + let client = Client::new(); + let upload = test.upload(1, true).await; + let input = input(&[upload], 8, 32); + let reads = test.store.upload_reads.load(Ordering::Relaxed); + test.store.abort_at_claim.store(true, Ordering::Relaxed); + response( + client + .post(format!("{}/snapshots/publish", test.root)) + .json(&input), + 409, + ) + .await; + assert_eq!(test.store.upload_reads.load(Ordering::Relaxed) - reads, 2); + assert_eq!(test.store.claim_attempts.load(Ordering::Relaxed), 1); + assert!( + !test.store.abort_at_claim.load(Ordering::Relaxed), + "claim invalidation must actually fire" + ); + assert_eq!(test.store.complete_attempts.load(Ordering::Relaxed), 0); + assert!(test.authority.latest(&test.identity).await.unwrap().is_none()); + assert!(test + .authority + .get_snapshot(&test.identity, SnapshotId::from_bytes(&[32; 16]).unwrap()) + .await + .is_err()); + drop(client); + test.stop().await; +} + +#[tokio::test] +async fn metadata_and_claim_unknown_outcomes_stop_without_confirmation_or_completion() { + for claim in [false, true] { + let test = TestPublicationHttp::start().await; + let client = Client::new(); + let upload = test.upload(1, true).await; + let input = input(std::slice::from_ref(&upload), 8, 32); + if claim { + test.store.lose_claim_reply.store(true, Ordering::Relaxed); + } else { + test.store.fail_metadata.store(true, Ordering::Relaxed); + } + let reads = test.store.upload_reads.load(Ordering::Relaxed); + let mutations = test.store.mutations(); + response( + client + .post(format!("{}/snapshots/publish", test.root)) + .json(&input), + 503, + ) + .await; + assert!(test.store.unknown.load(Ordering::Relaxed)); + assert!( + !test.store.lose_claim_reply.load(Ordering::Relaxed), + "claim fault must actually fire when armed" + ); + assert_eq!(test.store.operations_after_unknown.load(Ordering::Relaxed), 0); + assert_eq!( + test.store.upload_reads.load(Ordering::Relaxed) - reads, + if claim { 2 } else { 1 } + ); + assert_eq!(test.store.complete_attempts.load(Ordering::Relaxed), 0); + test.store.unknown.store(false, Ordering::Relaxed); + if claim { + assert_eq!( + test.uploads + .metadata(&test.location(&upload)) + .await + .unwrap() + .state, + UploadState::Claimed + ); + assert!(test.authority.latest(&test.identity).await.unwrap().is_none()); + } else { + assert_eq!(test.store.mutations(), mutations); + } + drop(client); + test.stop().await; + } +} diff --git a/app/crowdb-access-server/tests/dataset_read_query_contract_test.rs b/app/crowdb-access-server/tests/dataset_read_query_contract_test.rs new file mode 100644 index 000000000..21f75c019 --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_read_query_contract_test.rs @@ -0,0 +1,110 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. +#[path = "common/dataset_v2.rs"] +mod common; +#[path = "common/dataset.rs"] +mod dataset; +use common::response; +use dataset::{TestDatasetHttp, TestStore}; +use reqwest::Client; +use serde_json::{json, Value}; +use std::sync::Arc; + +#[tokio::test] +async fn split_read_continuation_replays_without_remote_progress_or_cleanup() { + use std::sync::atomic::Ordering; + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; + let client = Client::new(); + let (root, snapshot) = common::fixture(&client, &test.origin).await; + let revision = published_split(&client, &root, &snapshot).await; + let binding = response(client.post(format!("{root}/reads/bind")).json(&plan()), 200).await; + assert_eq!(binding["snapshot"], snapshot); + assert_eq!(binding["split"]["revision"], revision); + let before = store.admission_reads.load(Ordering::Relaxed); + let first = batch(&client, &root, &binding["cursor"]).await; + assert_eq!(store.admission_reads.load(Ordering::Relaxed) - before, 1); + assert!(!first["sample_ids"].as_array().unwrap().is_empty()); + let independent = Client::new(); + assert_eq!(batch(&independent, &root, &binding["cursor"]).await, first); + let second = batch(&client, &root, &first["next"]).await; + assert_ne!(first["sample_ids"], second["sample_ids"]); + assert_eq!(batch(&client, &root, &binding["cursor"]).await, first); + for suffix in ["", "/progress", "/next"] { + let url = format!("{root}/read-plans/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{suffix}"); + response(client.get(&url), 404).await; + response(client.post(&url), 404).await; + response(client.put(&url), 404).await; + response(client.delete(&url), 404).await; + } + drop(independent); + drop(client); + test.stop().await; +} + +#[tokio::test] +async fn unsplit_delivery_has_no_outer_duplicate_admission_reads() { + use std::sync::atomic::Ordering; + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; + let client = Client::new(); + let (root, _) = common::fixture(&client, &test.origin).await; + let mut request = plan(); + request["split"] = Value::Null; + let binding = response(client.post(format!("{root}/reads/bind")).json(&request), 200).await; + let before = store.admission_reads.load(Ordering::Relaxed); + let writes = store.mutations.load(Ordering::Relaxed); + let page = batch(&client, &root, &binding["cursor"]).await; + assert_eq!(page["sample_ids"].as_array().unwrap().len(), 3); + // Selection and delivery share one request-local authority check. + assert_eq!(store.admission_reads.load(Ordering::Relaxed) - before, 1); + assert_eq!(store.mutations.load(Ordering::Relaxed), writes); + drop(client); + test.stop().await; +} +fn plan() -> Value { + json!({"snapshot":"latest","selection":{"kind":"all"},"fields":["value"],"batch_size":3, + "partition":{"worker":0,"workers":1,"rank":0,"world_size":1},"shuffle":null, + "split":{"revision":null,"name":"train/a b"}}) +} +async fn published_split(client: &Client, root: &str, snapshot: &str) -> String { + let prepared = common::prepare(client, root, snapshot, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false).await; + common::publish(client, root, snapshot, &prepared, &Value::Null).await; + prepared["revision"].as_str().unwrap().into() +} +async fn batch(client: &Client, url: &str, cursor: &Value) -> Value { + let response = client + .post(format!("{url}/reads/next")) + .json(&json!({"cursor":cursor})) + .bearer_auth("secret") + .send() + .await + .unwrap(); + let status = response.status(); + let bytes = response.bytes().await.unwrap(); + assert_eq!(status, 200, "{}", String::from_utf8_lossy(&bytes)); + assert_eq!(&bytes[..8], b"CDBDS001"); + assert_eq!(bytes[8], 1); + let length = u32::from_be_bytes(bytes[9..13].try_into().unwrap()) as usize; + serde_json::from_slice(&bytes[13..13 + length]).unwrap() +} + +#[tokio::test] +async fn binding_rejects_unknown_split_and_malformed_cursor() { + let test = TestDatasetHttp::start(Arc::new(TestStore::default())).await; + let client = Client::new(); + let (root, _) = common::fixture(&client, &test.origin).await; + response(client.post(format!("{root}/reads/bind")).json(&plan()), 404).await; + response( + client + .post(format!("{root}/reads/next")) + .json(&json!({"cursor":"invalid"})), + 400, + ) + .await; + let mut input = plan(); + input.as_object_mut().unwrap().remove("split"); + response(client.post(format!("{root}/reads/bind")).json(&input), 400).await; + drop(client); + test.stop().await; +} diff --git a/app/crowdb-access-server/tests/dataset_retirement_contract_test.rs b/app/crowdb-access-server/tests/dataset_retirement_contract_test.rs index e9f5cedf9..d12296245 100644 --- a/app/crowdb-access-server/tests/dataset_retirement_contract_test.rs +++ b/app/crowdb-access-server/tests/dataset_retirement_contract_test.rs @@ -8,58 +8,109 @@ mod composition; #[path = "common/dataset.rs"] mod dataset; use common::response; +use crowdb_access_dataset::{composition::*, split::SplitPolicy, *}; use dataset::{TestDatasetHttp, TestStore}; use reqwest::Client; use serde_json::{json, Value}; use std::sync::Arc; +#[tokio::test] +async fn unreferenced_complete_results_expire_through_the_normal_http_deletion_contract() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start_payload(store).await; + let client = Client::new(); + let (root, latest) = common::fixture(&client, &test.origin).await; + let mut input = composition::input(&Value::Null); + input["samples"] = json!([{"kind":"upsert","id":"YQ==","fields":[{"name":"value","value":{"kind":"inline","data":"Yg=="}}]}]); + let prepared = composition::prepare(&client, &root, "19191919191919191919191919191919", &input).await; + let snapshot = format!("{root}/snapshots/{}", prepared["snapshot"].as_str().unwrap()); + response(client.get(format!("{snapshot}/summary")), 200).await; + test.clock + .store(1000 + 86400, std::sync::atomic::Ordering::Release); + test.authority + .expire_snapshot_candidates_page(&mut SnapshotExpirySweep::default(), &ReadCancellation::new()) + .await + .unwrap(); + response(client.get(format!("{snapshot}/summary")), 404).await; + let marked = response(client.post(format!("{snapshot}/reclaim")), 200).await; + assert_eq!(marked["phase"], "grace_period"); + assert_eq!(marked["reclaim_after"], 1000 + 86400 + 1200); + assert_eq!( + response(client.get(format!("{root}/head")), 200).await["latest"], + latest + ); + let mut cursor = SnapshotReclaimSweep::default(); + test.authority + .reclaim_due_page(&test.uploads, &mut cursor, &ReadCancellation::new()) + .await + .unwrap(); + assert_eq!( + response(client.post(format!("{snapshot}/reclaim")), 200).await["complete"], + false + ); + test.clock + .store(1000 + 86400 + 1200, std::sync::atomic::Ordering::Release); + for _ in 0..100 { + test.authority + .reclaim_due_page(&test.uploads, &mut cursor, &ReadCancellation::new()) + .await + .unwrap(); + if response(client.post(format!("{snapshot}/reclaim")), 200).await["complete"] == true { + drop(client); + test.stop().await; + return; + } + } + panic!("expired complete Snapshot did not finish ordinary reclamation"); +} + #[tokio::test] async fn retirement_respects_retention_and_finishes_split_and_composition_cleanup() { - let test = TestDatasetHttp::start_payload(Arc::new(TestStore::default())).await; + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start_payload(store.clone()).await; let client = Client::new(); let (root, old) = common::fixture(&client, &test.origin).await; - let split_op = "16161616161616161616161616161616"; - let split = format!("{root}/split-drafts/{split_op}"); + let revision = common::prepare(&client, &root, &old, "16161616161616161616161616161616", false).await; + let head = response(client.get(format!("{root}/head")), 200).await; + let mut input = composition::input(&Value::Null); + input["samples"] = json!([{"kind":"upsert","id":"YQ==","fields":[{"name":"value","value":{"kind":"inline","data":"Yg=="}}]}]); + let prepared = composition::prepare(&client, &root, "17171717171717171717171717171717", &input).await; + composition::publish(&client, &root, &prepared, &head["generation"]).await; + let snapshot = format!("{root}/snapshots/{old}"); + response(client.post(format!("{snapshot}/retain")), 200).await; + response(client.post(format!("{snapshot}/reclaim")), 409).await; + response(client.post(format!("{snapshot}/release")), 200).await; + let marked = response(client.post(format!("{snapshot}/reclaim")), 200).await; + assert_eq!(marked["phase"], "grace_period"); + assert_eq!(marked["complete"], false); + assert_eq!(marked["reclaim_after"], 2200); + response(client.get(format!("{snapshot}/summary")), 404).await; + test.clock.store(2200, std::sync::atomic::Ordering::Release); response( client - .post(format!("{root}/snapshots/{old}/split-drafts")) - .json(&common::begin(split_op, &Value::Null)), - 200, + .post(format!("{snapshot}/reclaim")) + .json(&json!({"complete":true,"cursor":{"sample":1000}})), + 400, ) .await; - common::prepare(&client, &split, "1").await; - response(client.delete(&split).json(&json!({"expected_revision":"1"})), 200).await; - for step in 0..100 { - if response(client.post(format!("{split}/cleanup")), 200).await["complete"] == true { - break; - } - assert!(step < 99, "Split cleanup did not terminate"); + let writes = store.mutations.load(std::sync::atomic::Ordering::Relaxed); + for _ in 0..3 { + assert_eq!( + response(client.post(format!("{snapshot}/reclaim")), 200).await["complete"], + false + ); } - let operation = "17171717171717171717171717171717"; - let draft = format!("{root}/compositions/{operation}"); - response( - client - .post(format!("{root}/compositions")) - .json(&composition::begin(operation, &Value::Null)), - 200, - ) - .await; - response(client.put(&draft).json(&json!({"edit":"18181818181818181818181818181818","expected_revision":"1","samples":[{"kind":"upsert","id":"YQ==","fields":[{"name":"value","value":{"kind":"inline","data":"Yg=="}}]}]})),200).await; - let prepared = composition::prepare(&client, &draft, "2", "1").await; - composition::publish( - &client, - &root, - &draft, - &prepared, - "19191919191919191919191919191919", - ) - .await; - composition::cleanup(&client, &draft).await; - let snapshot = format!("{root}/snapshots/{old}"); - response(client.post(format!("{snapshot}/retain")), 200).await; - response(client.post(format!("{snapshot}/reclaim")), 409).await; - response(client.post(format!("{snapshot}/release")), 200).await; + assert_eq!( + store.mutations.load(std::sync::atomic::Ordering::Relaxed), + writes, + "repeating the deletion mark must not advance background cleanup" + ); + let mut cursor = SnapshotReclaimSweep::default(); for step in 0..100 { + test.authority + .reclaim_due_page(&test.uploads, &mut cursor, &ReadCancellation::new()) + .await + .unwrap(); let progress = response(client.post(format!("{snapshot}/reclaim")), 200).await; assert_eq!(progress["physical_cleanup_deferred"], true); if progress["complete"] == true { @@ -68,11 +119,103 @@ async fn retirement_respects_retention_and_finishes_split_and_composition_cleanu } assert!(step < 99, "Snapshot retirement did not terminate"); } + response( + client.get(format!( + "{snapshot}/split-revisions/{}", + revision["revision"].as_str().unwrap() + )), + 404, + ) + .await; response(client.get(format!("{snapshot}/summary")), 404).await; assert_eq!( response(client.get(format!("{root}/head")), 200).await["latest"], - prepared["preparation"]["snapshot"] + prepared["snapshot"] ); drop(client); test.stop().await; } + +#[tokio::test] +async fn legacy_reclaim_marks_deletion_without_bypassing_payload_owner_cleanup() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start_payload(store.clone()).await; + let client = DatasetHttpClient::new(&test.origin, Some("secret".into())); + client.create().await.unwrap(); + let identity = DatasetIdentity::new(NamespacePath::root(), "legacy").unwrap(); + let location = DatasetUploads::location(&identity, OperationId::random()).unwrap(); + let digest = [ + 0x9d, 0xd4, 0xe4, 0x61, 0x26, 0x8c, 0x80, 0x34, 0xf5, 0xc8, 0x56, 0x4e, 0x15, 0x5c, 0x67, 0xa6, + ]; + test.uploads + .create(&location, 1, 1, digest, u64::MAX) + .await + .unwrap(); + test.uploads + .put_part(&location, 0, bytes::Bytes::from_static(b"x"), 1) + .await + .unwrap(); + test.uploads.complete(&location, 2).await.unwrap(); + let snapshot = SnapshotId::random(); + let input = CompositionRequest { + parent: None, + source: CompositionSource::All, + schema: Some(SchemaRecord { + version: 1, + fields: vec![FieldDefinition { + name: "data".into(), + required: true, + }], + }), + samples: vec![SampleChange::Upsert(SampleRecord { + sample_id: vec![1], + fields: vec![FieldRecord { + name: "data".into(), + value: FieldLocator::Chunk { + location: location.clone(), + length: 1, + md5: digest, + }, + }], + })], + splits: Vec::new(), + policy: SplitPolicy::default(), + }; + test.authority + .prepare_composition_resource( + &identity, + snapshot, + &input, + Some(&test.uploads), + &ReadCancellation::new(), + ) + .await + .unwrap(); + assert!(!client.reclaim(snapshot).await.unwrap().reclaimed); + test.clock.store(2200, std::sync::atomic::Ordering::Release); + let writes = store.mutations.load(std::sync::atomic::Ordering::Relaxed); + for _ in 0..3 { + assert!(!client.reclaim(snapshot).await.unwrap().reclaimed); + } + assert_eq!(store.mutations.load(std::sync::atomic::Ordering::Relaxed), writes); + assert_eq!( + test.uploads.metadata(&location).await.unwrap().state, + UploadState::Claimed + ); + let mut cursor = SnapshotReclaimSweep::default(); + for _ in 0..100 { + test.authority + .reclaim_due_page(&test.uploads, &mut cursor, &ReadCancellation::new()) + .await + .unwrap(); + if client.reclaim(snapshot).await.unwrap().reclaimed { + assert_eq!( + test.uploads.metadata(&location).await.unwrap().state, + UploadState::Complete + ); + test.stop().await; + return; + } + } + panic!("legacy deletion did not observe background completion"); +} diff --git a/app/crowdb-access-server/tests/dataset_snapshot_head_contract_test.rs b/app/crowdb-access-server/tests/dataset_snapshot_head_contract_test.rs new file mode 100644 index 000000000..3b2723e87 --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_snapshot_head_contract_test.rs @@ -0,0 +1,163 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/dataset_v2.rs"] +#[allow(dead_code)] +mod common; +#[path = "common/dataset.rs"] +mod dataset; +#[path = "../../../lib/crowdb-access-dataset/tests/common/publication.rs"] +mod publication; +use crowdb_access_dataset::*; +use dataset::{TestDatasetHttp, TestStore}; +use reqwest::Client; +use serde_json::json; +use std::sync::{atomic::Ordering, Arc}; + +fn counts(store: &TestStore) -> (u64, u64) { + ( + store.reads.load(Ordering::Relaxed) + store.bounded_reads.load(Ordering::Relaxed), + store.mutations.load(Ordering::Relaxed), + ) +} +fn delta(store: &TestStore, before: (u64, u64)) -> (u64, u64) { + let now = counts(store); + (now.0 - before.0, now.1 - before.1) +} +async fn fixture() -> ( + Arc, + DatasetAuthority, + DatasetIdentity, + TestDatasetHttp, + String, + Vec, +) { + let store = Arc::new(TestStore::default()); + let authority = DatasetAuthority::new(store.clone()); + let identity = DatasetIdentity::new(NamespacePath::root(), "snapshot-head").unwrap(); + authority.create_dataset(identity.clone()).await.unwrap(); + let mut snapshots = Vec::new(); + for marker in [1, 2] { + snapshots.push( + authority + .prepare_snapshot_candidate( + &identity, + None, + publication::publication_manifest(marker), + OperationId::random(), + ) + .await + .unwrap(), + ); + } + let test = TestDatasetHttp::start(store.clone()).await; + let root = format!( + "{}/api/dataset/v2/namespaces/dataset-ns/datasets/snapshot-head", + test.origin + ); + (store, authority, identity, test, root, snapshots) +} + +#[tokio::test] +async fn head_updates_use_complete_resource_ids_and_return_the_failed_condition() { + let (store, authority, identity, test, root, snapshots) = fixture().await; + let client = Client::new(); + let url = format!("{root}/head"); + let before = counts(&store); + assert_eq!( + common::response(client.get(&url), 200).await, + json!({"latest":null,"stable":null,"generation":null}) + ); + assert_eq!(delta(&store, before), (2, 0)); + let first = json!({"snapshot":snapshots[0].to_string(),"expected_head":null}); + let before = counts(&store); + let head = common::response(client.put(&url).json(&first), 200).await; + assert_eq!(delta(&store, before), (3, 1)); + assert_eq!(head["latest"], snapshots[0].to_string()); + let before = counts(&store); + assert_eq!(common::response(client.put(&url).json(&first), 200).await, head); + assert_eq!(delta(&store, before), (3, 0)); + let mut next = json!({"snapshot":snapshots[1].to_string(),"expected_head":null}); + let before = counts(&store); + let conflict = common::response(client.put(&url).json(&next), 409).await; + assert_eq!(conflict["observed_head"], head); + assert_eq!(delta(&store, before), (3, 0)); + next["expected_head"] = conflict["observed_head"]["generation"].clone(); + let before = counts(&store); + let second = common::response(client.put(&url).json(&next), 200).await; + assert_eq!(delta(&store, before), (3, 1)); + let before = counts(&store); + assert_eq!( + common::response(client.put(&url).json(&first), 409).await["observed_head"], + second + ); + assert_eq!(delta(&store, before), (3, 0)); + authority.begin_reclaim(&identity, snapshots[0]).await.unwrap(); + let before = counts(&store); + common::response(client.put(&url).json(&first), 409).await; + // The terminal content binding rejects publication before another admission lookup. + assert_eq!(delta(&store, before), (2, 0)); + drop(client); + test.stop().await; +} + +#[tokio::test] +async fn invalid_conditions_and_auth_fail_before_storage_without_a_draft_fallback() { + let (store, _, _, test, root, snapshots) = fixture().await; + let client = Client::new(); + let url = format!("{root}/head"); + let valid = json!({"snapshot":snapshots[0].to_string(),"expected_head":null}); + let before = counts(&store); + assert_eq!( + client + .put(&url) + .json(&valid) + .send() + .await + .unwrap() + .status() + .as_u16(), + 401 + ); + for invalid in [ + json!({"snapshot":snapshots[0].to_string()}), + json!({"snapshot":null,"expected_head":null}), + json!({"snapshot":"bad","expected_head":null}), + json!({"snapshot":snapshots[0].to_string(),"expected_head":4}), + json!({"snapshot":snapshots[0].to_string(),"expected_head":"A".repeat(64)}), + json!({"snapshot":snapshots[0].to_string(),"expected_head":null,"attempt":"11".repeat(16)}), + ] { + common::response(client.put(&url).json(&invalid), 400).await; + } + common::response(client.put(&url).body(valid.to_string()), 400).await; + common::response(client.put(format!("{url}?unknown=1")).json(&valid), 400).await; + common::response( + client + .put(&url) + .header("content-type", "application/json") + .body(" ".repeat(4097)), + 413, + ) + .await; + assert_eq!(delta(&store, before), (0, 0)); + let before = counts(&store); + common::response( + client + .put(&url) + .json(&json!({"snapshot":"11".repeat(16),"expected_head":null})), + 404, + ) + .await; + assert_eq!(delta(&store, before), (2, 0)); + let caps = common::response( + client.get(format!("{}/api/dataset/v2/capabilities", test.origin)), + 200, + ) + .await; + assert!(caps["capabilities"] + .as_array() + .unwrap() + .contains(&json!("snapshot-head-update"))); + drop(client); + test.stop().await; +} diff --git a/app/crowdb-access-server/tests/dataset_split_contract_test.rs b/app/crowdb-access-server/tests/dataset_split_contract_test.rs index ad99a2b40..b3d435e23 100644 --- a/app/crowdb-access-server/tests/dataset_split_contract_test.rs +++ b/app/crowdb-access-server/tests/dataset_split_contract_test.rs @@ -8,7 +8,10 @@ use common::response; use dataset::{TestDatasetHttp, TestStore}; use reqwest::Client; use serde_json::{json, Value}; -use std::{collections::BTreeSet, sync::Arc}; +use std::{ + collections::BTreeSet, + sync::{atomic::Ordering, Arc}, +}; #[tokio::test] async fn split_revision_flow_preserves_data_head_and_historical_members() { @@ -17,45 +20,38 @@ async fn split_revision_flow_preserves_data_head_and_historical_members() { let (root, snapshot) = common::fixture(&client, &test.origin).await; let base = format!("{root}/snapshots/{snapshot}"); let head = response(client.get(format!("{root}/head")), 200).await; - let operation = "02020202020202020202020202020202"; - let draft = format!("{root}/split-drafts/{operation}"); - let begin = common::begin(operation, &Value::Null); - response(client.post(format!("{base}/split-drafts")).json(&begin), 200).await; - let prepared = common::prepare(&client, &draft, "1").await; + let prepared = common::prepare( + &client, + &root, + &snapshot, + "02020202020202020202020202020202", + false, + ) + .await; let revision = prepared["revision"].as_str().unwrap(); - response(client.get(format!("{base}/split-revisions/{revision}")), 404).await; assert_eq!( - response(client.get(format!("{base}/split-revisions")), 200).await["entries"], - json!([]) + response(client.get(format!("{base}/split-revisions/{revision}")), 200).await, + prepared ); - let receipt = common::publish( + assert_eq!( + response(client.get(format!("{base}/split-revisions")), 200).await["entries"][0]["revision"], + revision + ); + let before_publish = members( &client, - &draft, - &prepared, - "03030303030303030303030303030303", - Value::Null, + &format!("{base}/split-revisions/{revision}/splits/train%2Fa%20b/members"), ) .await; + let current = common::publish(&client, &root, &snapshot, &prepared, &Value::Null).await; assert_eq!( - common::publish( - &client, - &draft, - &prepared, - "03030303030303030303030303030303", - Value::Null - ) - .await, - receipt + common::publish(&client, &root, &snapshot, &prepared, &Value::Null).await, + current ); assert_eq!(response(client.get(format!("{root}/head")), 200).await, head); assert_eq!( response(client.get(format!("{base}/split-revisions/{revision}")), 200).await, prepared ); - assert_eq!( - response(client.get(&draft), 200).await["progress"]["state"], - "published" - ); let current = response(client.get(format!("{base}/split-head")), 200).await; assert_eq!(current["revision"], revision); let train = members( @@ -68,6 +64,7 @@ async fn split_revision_flow_preserves_data_head_and_historical_members() { &format!("{base}/split-revisions/{revision}/splits/test/members"), ) .await; + assert_eq!(train, before_publish); assert!(train.is_disjoint(&test_ids)); assert_eq!(train.len() + test_ids.len(), 45); assert_eq!( @@ -98,43 +95,36 @@ async fn members(client: &Client, url: &str) -> BTreeSet { } #[tokio::test] -async fn split_drafts_require_explicit_preconditions_and_reject_stale_edits() { - let test = TestDatasetHttp::start(Arc::new(TestStore::default())).await; +async fn retired_split_workflow_writes_never_access_storage() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; let client = Client::new(); let (root, snapshot) = common::fixture(&client, &test.origin).await; - let url = format!("{root}/snapshots/{snapshot}/split-drafts"); - let operation = "04040404040404040404040404040404"; - let mut begin = common::begin(operation, &Value::Null); - begin.as_object_mut().unwrap().remove("expected_revision"); - response(client.post(&url).json(&begin), 400).await; - begin["expected_revision"] = Value::Null; - begin["unsupported"] = json!(true); - response(client.post(&url).json(&begin), 400).await; - begin.as_object_mut().unwrap().remove("unsupported"); - response(client.post(&url).json(&begin), 200).await; - let draft = format!("{root}/split-drafts/{operation}"); - let mut edit = json!({"edit":"05050505050505050505050505050505", "expected_revision":"1", "policy":begin["policy"], "splits":begin["splits"]}); - let edited = response(client.put(&draft).json(&edit), 200).await; - assert_eq!(edited["revision"], "2"); - assert_eq!(response(client.put(&draft).json(&edit), 200).await, edited); - edit["edit"] = json!("06060606060606060606060606060606"); - response(client.put(&draft).json(&edit), 409).await; + let before = ( + store.reads.load(Ordering::Relaxed), + store.bounded_reads.load(Ordering::Relaxed), + store.mutations.load(Ordering::Relaxed), + ); response( client - .post(format!("{draft}/prepare")) - .json(&json!({"expected_revision":"2"})), - 409, + .post(format!("{root}/snapshots/{snapshot}/split-drafts")) + .json(&json!({})), + 501, ) .await; - let prepared = common::prepare(&client, &draft, "2").await; - let publish = json!({"attempt":"07070707070707070707070707070707", "revision":prepared["revision"]}); - response(client.post(format!("{draft}/publish")).json(&publish), 400).await; + let draft = format!("{root}/split-drafts/04040404040404040404040404040404"); + response(client.put(&draft).json(&json!({})), 501).await; + for suffix in ["evaluate", "prepare", "publish", "cleanup"] { + response(client.post(format!("{draft}/{suffix}")).json(&json!({})), 501).await; + } assert_eq!( - response(client.delete(&draft).json(&json!({"expected_revision":"2"})), 200).await["progress"] - ["state"], - "abandoned" + ( + store.reads.load(Ordering::Relaxed), + store.bounded_reads.load(Ordering::Relaxed), + store.mutations.load(Ordering::Relaxed) + ), + before ); - response(client.put(&draft).json(&edit), 409).await; drop(client); test.stop().await; } diff --git a/app/crowdb-access-server/tests/dataset_split_deletion_contract_test.rs b/app/crowdb-access-server/tests/dataset_split_deletion_contract_test.rs new file mode 100644 index 000000000..0cccbfe38 --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_split_deletion_contract_test.rs @@ -0,0 +1,241 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/dataset_v2.rs"] +#[allow(dead_code)] // Shares the Split preparation and publication HTTP fixtures. +mod common; +#[path = "common/dataset.rs"] +mod dataset; + +use common::response; +use crowdb_access_dataset::{composition::*, split::*, *}; +use dataset::{TestDatasetHttp, TestStore}; +use reqwest::Client; +use serde_json::json; +use std::sync::{atomic::Ordering, Arc}; + +fn counts(store: &TestStore) -> (u64, u64) { + ( + store.reads.load(Ordering::Relaxed) + store.bounded_reads.load(Ordering::Relaxed), + store.mutations.load(Ordering::Relaxed), + ) +} + +fn splits() -> Vec { + vec![DraftSplit { + name: SplitName::new("train").unwrap(), + definition: SplitDefinition::Selection(Selection::Explicit(vec![vec![1]])), + origin: MembershipOrigin::Evaluated, + }] +} + +async fn resources( + authority: &DatasetAuthority, + identity: &DatasetIdentity, +) -> (SnapshotId, SplitRevisionId, SplitRevisionId) { + let snapshot = SnapshotId::random(); + authority.create_dataset(identity.clone()).await.unwrap(); + let prepared = authority + .prepare_composition_resource( + identity, + snapshot, + &CompositionRequest { + parent: None, + source: CompositionSource::All, + schema: Some(SchemaRecord { + version: 1, + fields: vec![], + }), + samples: vec![SampleChange::Upsert(SampleRecord { + sample_id: vec![1], + fields: vec![], + })], + splits: splits(), + policy: SplitPolicy::default(), + }, + None, + &ReadCancellation::new(), + ) + .await + .unwrap(); + let revision = SplitRevisionId::random(); + authority + .prepare_split_revision( + identity, + snapshot, + &SplitPreparation { + revision, + policy: SplitPolicy::default(), + splits: splits(), + }, + &ReadCancellation::new(), + ) + .await + .unwrap(); + (snapshot, revision, prepared.initial_split.unwrap()) +} + +#[tokio::test(start_paused = true)] +async fn background_cleanup_without_uploads_discovers_complete_revision_marks() { + let store = Arc::new(TestStore::default()); + let clock = Arc::new(std::sync::atomic::AtomicU64::new(1000)); + let authority = Arc::new(DatasetAuthority::new(store).with_reclaim_clock_for_tests(clock.clone())); + let identity = DatasetIdentity::new(NamespacePath::root(), "worker").unwrap(); + let (snapshot, revision, _) = resources(&authority, &identity).await; + let mark = authority + .delete_split_revision(&identity, snapshot, revision, &ReadCancellation::new()) + .await + .unwrap(); + assert_eq!(mark.reclaim_after, Some(2200)); + let worker = crowdb_access_server::dataset::start_dataset_cleanup_for_tests(authority.clone(), None); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + assert_eq!( + authority + .delete_split_revision(&identity, snapshot, revision, &ReadCancellation::new()) + .await + .unwrap(), + mark + ); + clock.store(2200, Ordering::Release); + for _ in 0..80 { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + let status = authority + .delete_split_revision(&identity, snapshot, revision, &ReadCancellation::new()) + .await + .unwrap(); + if status.complete { + worker.abort(); + let _ = worker.await; + assert_eq!(status.reclaim_after, None); + authority.get_snapshot(&identity, snapshot).await.unwrap(); + return; + } + } + worker.abort(); + let _ = worker.await; + panic!("background worker did not reclaim the marked revision without upload services"); +} + +#[tokio::test] +async fn deletion_is_mark_only_and_retries_keep_the_original_deadline() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; + let client = Client::new(); + let (root, snapshot) = common::fixture(&client, &test.origin).await; + let revision = "ab".repeat(16); + common::prepare(&client, &root, &snapshot, &revision, false).await; + let url = format!("{root}/snapshots/{snapshot}/split-revisions/{revision}"); + let capabilities = response( + client.get(format!("{}/api/dataset/v2/capabilities", test.origin)), + 200, + ) + .await; + assert!(capabilities["capabilities"] + .as_array() + .unwrap() + .contains(&json!("split-revision-delete"))); + let before = counts(&store); + let mark = response(client.delete(&url), 200).await; + let after = counts(&store); + assert_eq!((after.0 - before.0, after.1 - before.1), (4, 3)); + assert_eq!( + mark, + json!({"snapshot":snapshot,"revision":revision,"reclaim_after":"2200","complete":false}) + ); + test.clock.store(1100, Ordering::Release); + let before = counts(&store); + assert_eq!(response(client.delete(&url), 200).await, mark); + let after = counts(&store); + assert_eq!((after.0 - before.0, after.1 - before.1), (1, 0)); + response(client.get(&url), 404).await; + response(client.get(format!("{url}/splits/train%2Fa%20b/members")), 404).await; + response(client.get(format!("{root}/snapshots/{snapshot}/summary")), 200).await; + let other = format!("{root}/snapshots/{}/split-revisions/{revision}", "cd".repeat(16)); + response(client.delete(&other), 404).await; + test.clock.store(2200, Ordering::Release); + let identity = DatasetIdentity::new(NamespacePath::root(), "splits").unwrap(); + let mut cursor = SplitRevisionReclaimCursor::new( + identity, + SnapshotId::from_bytes(&hex::decode(&snapshot).unwrap()).unwrap(), + SplitRevisionId::from_bytes(&hex::decode(&revision).unwrap()).unwrap(), + ); + let mut complete = false; + for _ in 0..32 { + if test + .authority + .advance_split_revision_reclaim(&mut cursor, &ReadCancellation::new()) + .await + .unwrap() + .complete + { + complete = true; + break; + } + } + assert!( + complete, + "complete Split revision did not finish bounded reclamation" + ); + assert_eq!( + response(client.delete(&url), 200).await, + json!({"snapshot":snapshot,"revision":revision,"reclaim_after":null,"complete":true}) + ); + test.stop().await; +} + +#[tokio::test] +async fn initial_revision_remains_protected_after_the_current_head_changes() { + let test = TestDatasetHttp::start(Arc::new(TestStore::default())).await; + let identity = DatasetIdentity::new(NamespacePath::root(), "initial").unwrap(); + let (snapshot, revision, initial) = resources(&test.authority, &identity).await; + let root = format!( + "{}/api/dataset/v2/namespaces/dataset-ns/datasets/initial/snapshots/{snapshot}", + test.origin + ); + let client = Client::new(); + response(client.put(format!("{root}/split-head")) + .json(&json!({"revision":revision.to_string(),"expected_revision":initial.to_string(),"expected_head":null})), 200).await; + response(client.delete(format!("{root}/split-revisions/{initial}")), 409).await; + response(client.get(format!("{root}/split-revisions/{initial}")), 200).await; + assert_eq!( + response(client.get(format!("{root}/split-head")), 200).await["revision"], + revision.to_string() + ); + test.stop().await; +} + +#[tokio::test] +async fn current_revision_is_protected_and_invalid_requests_do_not_touch_storage() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; + let client = Client::new(); + let (root, snapshot) = common::fixture(&client, &test.origin).await; + let revision = common::prepare(&client, &root, &snapshot, &"bc".repeat(16), false).await; + let head = response(client.get(format!("{root}/snapshots/{snapshot}/split-head")), 200).await; + common::publish(&client, &root, &snapshot, &revision, &head).await; + let url = format!( + "{root}/snapshots/{snapshot}/split-revisions/{}", + revision["revision"].as_str().unwrap() + ); + response(client.delete(&url), 409).await; + assert_eq!(response(client.get(&url), 200).await, revision); + let before = counts(&store); + assert_eq!(client.delete(&url).send().await.unwrap().status(), 401); + response(client.delete(format!("{url}?expected_revision=1")), 400).await; + response(client.delete(&url).body("{}"), 400).await; + response( + client.delete(format!( + "{root}/snapshots/bad/split-revisions/{}", + "ab".repeat(16) + )), + 400, + ) + .await; + response( + client.delete(format!("{root}/snapshots/{snapshot}/split-revisions/bad")), + 400, + ) + .await; + assert_eq!(counts(&store), before); + test.stop().await; +} diff --git a/app/crowdb-access-server/tests/dataset_split_head_contract_test.rs b/app/crowdb-access-server/tests/dataset_split_head_contract_test.rs new file mode 100644 index 000000000..3bec10ad9 --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_split_head_contract_test.rs @@ -0,0 +1,109 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/dataset_v2.rs"] +#[allow(dead_code)] +mod common; +#[path = "common/dataset.rs"] +mod dataset; + +use crowdb_access_dataset::{DatasetIdentity, DatasetStore, NamespacePath}; +use dataset::{TestDatasetHttp, TestStore}; +use reqwest::Client; +use serde_json::json; +use std::sync::{atomic::Ordering, Arc}; + +fn counts(store: &TestStore) -> (u64, u64) { + ( + store.reads.load(Ordering::Relaxed) + store.bounded_reads.load(Ordering::Relaxed), + store.mutations.load(Ordering::Relaxed), + ) +} +fn delta(store: &TestStore, before: (u64, u64)) -> (u64, u64) { + let now = counts(store); + (now.0 - before.0, now.1 - before.1) +} + +#[tokio::test] +async fn update_uses_only_complete_results_and_returns_the_failed_head_condition() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; + let client = Client::new(); + let (root, snapshot) = common::fixture(&client, &test.origin).await; + let mut prepared = Vec::new(); + for revision in ["22".repeat(16), "33".repeat(16)] { + prepared.push(common::prepare(&client, &root, &snapshot, &revision, false).await); + } + // Preparing a complete result creates no editing identity. + let identity = DatasetIdentity::new(NamespacePath::root(), "splits").unwrap(); + for prefix in [b"DS\0SD\0".as_slice(), b"DS\0SB\0"] { + assert!(store + .get(&[prefix, &identity.key().unwrap(), &[0x22; 16]].concat()) + .await + .unwrap() + .is_none()); + } + let url = format!("{root}/snapshots/{snapshot}/split-head"); + let first = json!({"revision":prepared[0]["revision"], "expected_revision":null, "expected_head":null}); + let before = counts(&store); + let head = common::response(client.put(&url).json(&first), 200).await; + assert_eq!(delta(&store, before), (5, 1)); + assert_eq!(head["snapshot"], snapshot); + assert_eq!(head["revision"], first["revision"]); + let before = counts(&store); + assert_eq!(common::response(client.put(&url).json(&first), 200).await, head); + assert_eq!(delta(&store, before), (4, 0)); + let second = json!({"revision":prepared[1]["revision"], "expected_revision":null, "expected_head":null}); + let before = counts(&store); + let conflict = common::response(client.put(&url).json(&second), 409).await; + assert_eq!(delta(&store, before), (4, 0)); + assert_eq!(conflict["observed_head"], head); + let mut reviewed = second; + reviewed["expected_revision"] = conflict["observed_head"]["revision"].clone(); + reviewed["expected_head"] = conflict["observed_head"]["generation"].clone(); + let before = counts(&store); + let current = common::response(client.put(&url).json(&reviewed), 200).await; + assert_eq!(delta(&store, before), (4, 1)); + assert_eq!(current["revision"], prepared[1]["revision"]); + assert_ne!(current["generation"], head["generation"]); + assert_eq!( + common::response(client.get(format!("{root}/head")), 200).await["latest"], + snapshot + ); + drop(client); + test.stop().await; +} + +#[tokio::test] +async fn update_requires_explicit_nullable_conditions_and_auth_before_storage() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; + let client = Client::new(); + let (root, snapshot) = common::fixture(&client, &test.origin).await; + let url = format!("{root}/snapshots/{snapshot}/split-head"); + let body = json!({"revision":"22".repeat(16),"expected_revision":null,"expected_head":null}); + let before = counts(&store); + let denied = client.put(&url).json(&body).send().await.unwrap(); + assert_eq!(denied.status(), 401); + denied.bytes().await.unwrap(); + assert_eq!(delta(&store, before), (0, 0)); + for field in ["expected_revision", "expected_head"] { + let mut invalid = body.clone(); + invalid.as_object_mut().unwrap().remove(field); + let before = counts(&store); + common::response(client.put(&url).json(&invalid), 400).await; + assert_eq!(delta(&store, before), (0, 0)); + } + let mut invalid = body.clone(); + invalid["attempt"] = json!("33".repeat(16)); + common::response(client.put(&url).json(&invalid), 400).await; + invalid = body.clone(); + invalid["expected_head"] = json!("G".repeat(64)); + common::response(client.put(&url).json(&invalid), 400).await; + let before = counts(&store); + common::response(client.put(&url).json(&body), 404).await; + // Observe the head before the revision so concurrent retirement fences publication. + assert_eq!(delta(&store, before), (2, 0)); + drop(client); + test.stop().await; +} diff --git a/app/crowdb-access-server/tests/dataset_split_preparation_contract_test.rs b/app/crowdb-access-server/tests/dataset_split_preparation_contract_test.rs new file mode 100644 index 000000000..d7f66443c --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_split_preparation_contract_test.rs @@ -0,0 +1,107 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/dataset_v2.rs"] +#[allow(dead_code)] +mod common; +#[path = "common/dataset.rs"] +mod dataset; +use common::response; +use dataset::{TestDatasetHttp, TestStore}; +use reqwest::Client; +use serde_json::{json, Value}; +use std::sync::{atomic::Ordering, Arc}; + +fn counts(store: &TestStore) -> (u64, u64) { + ( + store.reads.load(Ordering::Relaxed) + store.bounded_reads.load(Ordering::Relaxed), + store.mutations.load(Ordering::Relaxed), + ) +} +fn input() -> Value { + let weights = json!([{"name":"train/a b", "weight":1}, {"name":"test", "weight":1}]); + json!({"policy":{"overlap":"disjoint", "coverage":"require_complete"}, "splits":[ + {"name":"train/a b", "definition":{"kind":"weighted_partition", "seed":"42", "weights":weights,"index":0}, "origin":{"kind":"evaluated"}}, + {"name":"test", "definition":{"kind":"weighted_partition", "seed":"42", "weights":weights,"index":1}, "origin":{"kind":"evaluated"}} + ]}) +} + +#[tokio::test] +async fn preparation_uses_the_resource_id_and_reuses_complete_results_without_a_draft() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; + let client = Client::new(); + let (root, snapshot) = common::fixture(&client, &test.origin).await; + let url = format!("{root}/snapshots/{snapshot}/split-revisions/{}", "ab".repeat(16)); + let before = counts(&store); + let prepared = response(client.put(&url).json(&input()), 200).await; + let after = counts(&store); + assert_eq!((after.0 - before.0, after.1 - before.1), (14, 8)); + assert_eq!(prepared["revision"], "ab".repeat(16)); + assert_eq!(prepared["snapshot"], snapshot); + assert_eq!( + prepared["splits"] + .as_array() + .unwrap() + .iter() + .map(|split| split["count"].as_str().unwrap().parse::().unwrap()) + .sum::(), + 45 + ); + assert_eq!(response(client.get(&url), 200).await, prepared); + assert_eq!( + response(client.get(format!("{root}/snapshots/{snapshot}/split-head")), 200).await["revision"], + Value::Null + ); + let before = counts(&store); + assert_eq!(response(client.put(&url).json(&input()), 200).await, prepared); + let after = counts(&store); + assert_eq!((after.0 - before.0, after.1 - before.1), (3, 0)); + let mut changed = input(); + changed["splits"] = json!([]); + response(client.put(&url).json(&changed), 409).await; + assert_eq!(counts(&store).1, after.1); + assert_eq!(response(client.get(&url), 200).await, prepared); + test.stop().await; +} + +#[tokio::test] +async fn invalid_input_and_missing_auth_reject_before_storage_and_never_use_workflow_routes() { + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; + let client = Client::new(); + let url = format!( + "{}/api/dataset/v2/namespaces/dataset-ns/datasets/absent/snapshots/{}/split-revisions/{}", + test.origin, + "01".repeat(16), + "02".repeat(16) + ); + let denied = client.put(&url).json(&input()).send().await.unwrap(); + assert_eq!(denied.status(), 401); + denied.bytes().await.unwrap(); + for body in [ + json!({}), + json!({"policy":input()["policy"], "splits":[], "operation":"03".repeat(16)}), + json!({"policy":input()["policy"], "splits":[input()["splits"][0], input()["splits"][0]]}), + ] { + response(client.put(&url).json(&body), 400).await; + } + response(client.put(format!("{url}?expected_page=1")).json(&input()), 400).await; + response( + client.put(&url).header("content-type", "text/plain").body("{}"), + 400, + ) + .await; + response( + client + .put(&url) + .header("content-type", "application/json") + .body(vec![b' '; 1024 * 1024 + 1]), + 413, + ) + .await; + assert_eq!(counts(&store), (0, 0)); + response(client.put(&url).json(&input()), 404).await; + assert_eq!(counts(&store).1, 0); + test.stop().await; +} diff --git a/app/crowdb-access-server/tests/dataset_split_recovery_contract_test.rs b/app/crowdb-access-server/tests/dataset_split_recovery_contract_test.rs index da6348d67..11dfbd620 100644 --- a/app/crowdb-access-server/tests/dataset_split_recovery_contract_test.rs +++ b/app/crowdb-access-server/tests/dataset_split_recovery_contract_test.rs @@ -11,139 +11,72 @@ use serde_json::{json, Value}; use std::sync::Arc; #[tokio::test] -async fn evaluation_resumes_after_server_restart_and_stale_publish_keeps_candidate() { +async fn complete_result_retries_after_restart_and_stale_publish_preserves_history() { let client = Client::new(); - let (test, root, snapshot, draft, first_prepared) = restart_preparation(&client).await; - let first_receipt = common::publish( - &client, - &draft, - &first_prepared, - "22222222222222222222222222222222", - Value::Null, - ) - .await; - let head_url = format!("{root}/snapshots/{snapshot}/split-head"); - let head = response(client.get(&head_url), 200).await; - let a = new_prepared( + let store = Arc::new(TestStore::default()); + let test = TestDatasetHttp::start(store.clone()).await; + let (root, snapshot) = common::fixture(&client, &test.origin).await; + let first_id = "11111111111111111111111111111111"; + let first = common::prepare(&client, &root, &snapshot, first_id, false).await; + let old_origin = test.origin.clone(); + test.stop().await; + let test = TestDatasetHttp::start(store).await; + let root = root.replace(&old_origin, &test.origin); + assert_eq!( + common::prepare(&client, &root, &snapshot, first_id, false).await, + first + ); + let head = common::publish(&client, &root, &snapshot, &first, &Value::Null).await; + let a = common::prepare( &client, &root, &snapshot, "33333333333333333333333333333333", - head["revision"].clone(), true, ) .await; - let b = new_prepared( + let b = common::prepare( &client, &root, &snapshot, "44444444444444444444444444444444", - head["revision"].clone(), false, ) .await; - common::publish( - &client, - &a.0, - &a.1, - "55555555555555555555555555555555", - head["generation"].clone(), + let current = common::publish(&client, &root, &snapshot, &a, &head).await; + let url = format!("{root}/snapshots/{snapshot}/split-head"); + let conflict = response(client.put(&url).json(&json!({"revision":b["revision"], "expected_revision":head["revision"], "expected_head":head["generation"]})), 409).await; + assert_eq!(conflict["observed_head"], current); + response( + client + .put(&url) + .json(&json!({"revision":first["revision"], "expected_revision":null, "expected_head":null})), + 409, ) .await; - let current = response(client.get(&head_url), 200).await; - response(client.post(format!("{}/publish", b.0)).json(&json!({"attempt":"66666666666666666666666666666666", "revision":b.1["revision"], "expected_head":head["generation"]})), 409).await; - assert_eq!( - response(client.get(&b.0), 200).await["progress"]["state"], - "prepared" - ); - assert_eq!( - common::publish( - &client, - &draft, - &first_prepared, - "22222222222222222222222222222222", - Value::Null - ) - .await, - first_receipt - ); - assert_eq!(response(client.get(&head_url), 200).await, current); - let historic = response( + assert_eq!(response(client.get(&url), 200).await, current); + for prepared in [&first, &a, &b] { + assert_eq!( + response( + client.get(format!( + "{root}/snapshots/{snapshot}/split-revisions/{}", + prepared["revision"].as_str().unwrap() + )), + 200 + ) + .await, + *prepared + ); + } + assert_eq!(a["splits"], json!([])); + let historical = response( client.get(format!( - "{root}/snapshots/{snapshot}/split-revisions/{}", - first_prepared["revision"].as_str().unwrap() + "{root}/snapshots/{snapshot}/split-revisions/{first_id}/splits/train%2Fa%20b/members" )), 200, ) .await; - assert_eq!(historic, first_prepared); - assert_eq!( - response( - client.get(format!( - "{root}/snapshots/{snapshot}/split-revisions/{}", - a.1["revision"].as_str().unwrap() - )), - 200 - ) - .await["splits"], - json!([]) - ); + assert!(!historical["entries"].as_array().unwrap().is_empty()); drop(client); test.stop().await; } -async fn new_prepared( - client: &Client, - root: &str, - snapshot: &str, - operation: &str, - expected: Value, - empty: bool, -) -> (String, Value) { - let mut begin = common::begin(operation, &expected); - if empty { - begin["splits"] = json!([]); - begin["policy"]["coverage"] = json!("allow_unassigned"); - } - response( - client - .post(format!("{root}/snapshots/{snapshot}/split-drafts")) - .json(&begin), - 200, - ) - .await; - let draft = format!("{root}/split-drafts/{operation}"); - let prepared = common::prepare(client, &draft, "1").await; - (draft, prepared) -} - -async fn restart_preparation(client: &Client) -> (TestDatasetHttp, String, String, String, Value) { - let store = Arc::new(TestStore::default()); - let test = TestDatasetHttp::start(store.clone()).await; - let (root, snapshot) = common::fixture(client, &test.origin).await; - let operation = "11111111111111111111111111111111"; - response( - client - .post(format!("{root}/snapshots/{snapshot}/split-drafts")) - .json(&common::begin(operation, &Value::Null)), - 200, - ) - .await; - let draft = format!("{root}/split-drafts/{operation}"); - let first = response( - client - .post(format!("{draft}/evaluate")) - .json(&json!({"expected_revision":"1", "expected_page":"0"})), - 200, - ) - .await; - assert!(!first["complete"].as_bool().unwrap()); - let old_origin = test.origin.clone(); - test.stop().await; - let test = TestDatasetHttp::start(store).await; - let root = root.replace(&old_origin, &test.origin); - let draft = draft.replace(&old_origin, &test.origin); - let status = response(client.get(&draft), 200).await; - assert_eq!(status["progress"]["next_page"], first["next_page"]); - let first_prepared = common::prepare(client, &draft, "1").await; - (test, root, snapshot, draft, first_prepared) -} diff --git a/app/crowdb-access-server/tests/dataset_split_session_contract_test.rs b/app/crowdb-access-server/tests/dataset_split_session_contract_test.rs deleted file mode 100644 index 170d8c943..000000000 --- a/app/crowdb-access-server/tests/dataset_split_session_contract_test.rs +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright 2026-present Gian -// Licensed under the Apache License, Version 2.0. -#[path = "common/dataset_v2.rs"] -mod common; -#[path = "common/dataset.rs"] -mod dataset; -use common::response; -use dataset::{TestDatasetHttp, TestStore}; -use reqwest::Client; -use serde_json::{json, Value}; -use std::sync::Arc; - -#[tokio::test] -async fn split_plan_pins_revision_and_only_explicit_acknowledgement_advances_delivery() { - let test = TestDatasetHttp::start(Arc::new(TestStore::default())).await; - let client = Client::new(); - let (root, snapshot) = common::fixture(&client, &test.origin).await; - let revision = published_split(&client, &root, &snapshot).await; - let mut input = plan("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); - let prepared = prepare(&client, &root, &mut input).await; - assert_eq!(prepared["snapshot"], snapshot); - assert_eq!(prepared["split"]["revision"], revision); - let id = input["id"].as_str().unwrap(); - let url = format!("{root}/read-plans/{id}"); - let initial = prepared["session"]["acknowledged"].clone(); - assert_eq!( - response(client.get(&url), 200).await["session"]["acknowledged"], - initial - ); - let first = batch(&client, &url).await; - assert!(!first["sample_ids"].as_array().unwrap().is_empty()); - assert_eq!(batch(&client, &url).await, first); - assert_eq!( - response(client.get(format!("{url}/progress")), 200).await["cursor"], - initial - ); - let ack = json!({"expected":initial, "next":first["next"]}); - let accepted = response(client.put(format!("{url}/progress")).json(&ack), 200).await; - assert_eq!(accepted["cursor"], first["next"]); - let second = batch(&client, &url).await; - assert_ne!(first["sample_ids"], second["sample_ids"]); - assert_eq!(response(client.delete(&url), 200).await["closed"], true); - response(client.get(&url), 499).await; - response(client.post(format!("{root}/read-plans")).json(&input), 499).await; - drop(client); - test.stop().await; -} -fn plan(id: &str) -> Value { - json!({"id":id,"snapshot":"latest","selection":{"kind":"all"},"fields":["value"],"batch_size":3,"partition":{"worker":0,"workers":1,"rank":0,"world_size":1},"shuffle":null,"split":{"revision":null,"name":"train/a b"},"expected_page":"0"}) -} -async fn prepare(client: &Client, root: &str, input: &mut Value) -> Value { - for _ in 0..10 { - let result = response(client.post(format!("{root}/read-plans")).json(input), 200).await; - if result["complete"] == true { - return result; - } - input["expected_page"] = result["next_page"].clone(); - } - panic!("read preparation did not finish"); -} -async fn published_split(client: &Client, root: &str, snapshot: &str) -> String { - let op = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - response( - client - .post(format!("{root}/snapshots/{snapshot}/split-drafts")) - .json(&common::begin(op, &Value::Null)), - 200, - ) - .await; - let draft = format!("{root}/split-drafts/{op}"); - let prepared = common::prepare(client, &draft, "1").await; - common::publish( - client, - &draft, - &prepared, - "cccccccccccccccccccccccccccccccc", - Value::Null, - ) - .await; - prepared["revision"].as_str().unwrap().into() -} -async fn batch(client: &Client, url: &str) -> Value { - let response = client - .post(format!("{url}/next")) - .bearer_auth("secret") - .send() - .await - .unwrap(); - let status = response.status(); - let bytes = response.bytes().await.unwrap(); - assert_eq!(status, 200, "{}", String::from_utf8_lossy(&bytes)); - assert_eq!(&bytes[..8], b"CDBDS001"); - assert_eq!(bytes[8], 1); - let length = u32::from_be_bytes(bytes[9..13].try_into().unwrap()) as usize; - serde_json::from_slice(&bytes[13..13 + length]).unwrap() -} - -#[tokio::test] -async fn incomplete_read_can_be_inspected_and_cancelled_without_creating_delivery() { - let test = TestDatasetHttp::start(Arc::new(TestStore::default())).await; - let client = Client::new(); - let (root, _) = common::fixture(&client, &test.origin).await; - let mut input = plan("dddddddddddddddddddddddddddddddd"); - input["split"] = Value::Null; - let first = response(client.post(format!("{root}/read-plans")).json(&input), 200).await; - assert_eq!(first["complete"], false); - assert_eq!(first["session"], Value::Null); - let url = format!("{root}/read-plans/{}", input["id"].as_str().unwrap()); - assert_eq!(response(client.get(&url), 200).await, first); - response(client.post(format!("{url}/next")), 409).await; - response(client.get(format!("{url}/progress")), 409).await; - assert_eq!(response(client.delete(&url), 200).await["closed"], true); - input["expected_page"] = first["next_page"].clone(); - response(client.post(format!("{root}/read-plans")).json(&input), 499).await; - input["id"] = json!("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); - input.as_object_mut().unwrap().remove("split"); - response(client.post(format!("{root}/read-plans")).json(&input), 400).await; - drop(client); - test.stop().await; -} diff --git a/app/crowdb-access-server/tests/dataset_v2_contract_test.rs b/app/crowdb-access-server/tests/dataset_v2_contract_test.rs index 2fdbad4a9..f6fed5b2b 100644 --- a/app/crowdb-access-server/tests/dataset_v2_contract_test.rs +++ b/app/crowdb-access-server/tests/dataset_v2_contract_test.rs @@ -34,11 +34,15 @@ async fn capabilities_use_existing_auth_and_match_configured_operations() { value["capabilities"], serde_json::json!([ "head-inspection", + "snapshot-head-update", "bounded-inspection", "inspection-preparation", "dataset-management", "split-revisions", - "split-reads" + "split-revision-delete", + "split-preparation", + "split-head-update", + "stateless-reads" ]) ); let response = client.post(&endpoint).bearer_auth("secret").send().await.unwrap(); diff --git a/app/crowdb-access-server/tests/dataset_v2_inspection_preparation_test.rs b/app/crowdb-access-server/tests/dataset_v2_inspection_preparation_test.rs new file mode 100644 index 000000000..1a5ebd829 --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_v2_inspection_preparation_test.rs @@ -0,0 +1,150 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. +#[path = "common/dataset.rs"] +mod dataset; +use crowdb_access_dataset::*; +use dataset::{TestDatasetHttp, TestStore}; +use reqwest::Client; +use serde_json::{json, Value}; +use std::sync::{atomic::Ordering, Arc}; + +#[derive(serde::Serialize)] +struct TestVersioned { + version: u16, + payload: T, +} +fn scoped(prefix: &[u8], identity: &DatasetIdentity, snapshot: SnapshotId) -> Vec { + [prefix, &identity.key().unwrap(), snapshot.as_bytes()].concat() +} +async fn fixture(opaque: bool) -> (Arc, TestDatasetHttp, String, Vec, Vec) { + let store = Arc::new(TestStore::default()); + let authority = DatasetAuthority::new(store.clone()); + let identity = DatasetIdentity::new(NamespacePath::root(), "legacy-inspection").unwrap(); + authority.create_dataset(identity.clone()).await.unwrap(); + let snapshot = SnapshotId::random(); + let manifest = ManifestRecord { + version: 1, + snapshot, + schema: SchemaRecord { + version: 1, + fields: vec![FieldDefinition { + name: "value".into(), + required: false, + }], + }, + samples: (0..3) + .map(|id| SampleRecord { + sample_id: vec![id], + fields: vec![FieldRecord { + name: "value".into(), + value: FieldLocator::Tombstone, + }], + }) + .collect(), + }; + let publication = SnapshotPublication { + snapshot, + parent: None, + operation: OperationId::random(), + manifest: if opaque { + b"opaque".to_vec() + } else { + bincode::serialize(&manifest).unwrap() + }, + state: PublicationState::Published, + }; + store + .compare_exchange( + &scoped(b"DS\0S\0", &identity, snapshot), + None, + &bincode::serialize(&TestVersioned { + version: 1, + payload: SnapshotRecord { publication }, + }) + .unwrap(), + ) + .await + .unwrap(); + let mut head = b"DS\0H\0".to_vec(); + head.extend(identity.key().unwrap()); + store + .compare_exchange( + &head, + None, + &bincode::serialize(&TestVersioned { + version: 1, + payload: HeadRecord { + latest: Some(snapshot), + stable: None, + }, + }) + .unwrap(), + ) + .await + .unwrap(); + let mut progress = scoped(b"DS\0I\0", &identity, snapshot); + progress.push(b'P'); + let old = serde_json::to_vec(&json!({ "version": 1, "source_sha256": vec![0; 32], "progress": { "snapshot": snapshot, "revision": 7, "rows_written": 0, "state": "Preparing" }, + "position": { "schema": 0, "sample": 0, "sample_written": false, "field": 0, "field_value": false, "rows": 0 } })).unwrap(); + store.compare_exchange(&progress, None, &old).await.unwrap(); + let test = TestDatasetHttp::start(store.clone()).await; + let endpoint = format!( + "{}/api/dataset/v2/namespaces/dataset-ns/datasets/legacy-inspection/snapshots/{snapshot}", + test.origin + ); + (store, test, endpoint, progress, old) +} +async fn response(request: reqwest::RequestBuilder, expected: u16) -> Value { + let response = request.bearer_auth("secret").send().await.unwrap(); + let status = response.status().as_u16(); + let value: Value = response.json().await.unwrap(); + assert_eq!(status, expected, "{value}"); + value +} + +#[tokio::test] +async fn inspection_post_finishes_once_without_rewriting_legacy_progress() { + let (store, test, endpoint, progress, old) = fixture(false).await; + let client = Client::new(); + let status = response(client.get(format!("{endpoint}/inspection")), 200).await; + assert_eq!(status["state"], "preparing"); + assert_eq!(status["revision"], "7"); + let before = store.mutations.load(Ordering::Relaxed); + let ready = response(client.post(format!("{endpoint}/inspection")), 200).await; + assert_eq!(ready["state"], "ready"); + assert_eq!(ready["revision"], "0"); + assert_eq!(ready["rows_written"], "0"); + assert_eq!(store.mutations.load(Ordering::Relaxed) - before, 13); + assert_eq!( + response(client.post(format!("{endpoint}/inspection")), 200).await, + ready + ); + assert_eq!( + response(client.get(format!("{endpoint}/inspection")), 200).await, + ready + ); + assert_eq!(store.mutations.load(Ordering::Relaxed) - before, 13); + assert_eq!(store.get(&progress).await.unwrap().unwrap().bytes, old); + assert_eq!( + response(client.get(format!("{endpoint}/samples")), 200).await["entries"] + .as_array() + .unwrap() + .len(), + 3 + ); + drop(client); + test.stop().await; +} + +#[tokio::test] +async fn unsupported_legacy_manifest_fails_explicitly_without_new_progress() { + let (store, test, endpoint, progress, old) = fixture(true).await; + let client = Client::new(); + let before = store.mutations.load(Ordering::Relaxed); + let error = response(client.post(format!("{endpoint}/inspection")), 422).await; + assert_eq!(error["code"], "inspection_unsupported"); + assert_eq!(store.mutations.load(Ordering::Relaxed), before); + assert_eq!(store.get(&progress).await.unwrap().unwrap().bytes, old); + drop(client); + test.stop().await; +} diff --git a/app/crowdb-access-server/tests/iceberg_commit_recovery_test.rs b/app/crowdb-access-server/tests/iceberg_commit_recovery_test.rs index 393319e89..2b9a2c9c8 100644 --- a/app/crowdb-access-server/tests/iceberg_commit_recovery_test.rs +++ b/app/crowdb-access-server/tests/iceberg_commit_recovery_test.rs @@ -104,8 +104,22 @@ async fn table_publication_recovers_before_and_after_every_durable_write() { ); // The native tree persists data and its owner-bound directory before publication. assert_eq!(count, 6); + } else if matches!(kind, "create" | "publish-stage") { + assert_eq!( + labels, + BTreeSet::from([ + "file-block".to_owned(), + "journal-or-fence".to_owned(), + "file-location-authority".to_owned(), + "head-1".to_owned(), + "name-Reserved".to_owned(), + "name-Published".to_owned(), + ]) + ); + assert_eq!(count, 8); } else { - assert!(labels.iter().any(|label| label.starts_with("create-"))); + assert_eq!(count, 0, "stage must not create process or resource records"); + assert!(labels.is_empty()); } if kind != "stage" { assert!( @@ -147,6 +161,14 @@ async fn baseline(stack: &common::TestCommitFixture, directory: &Path, kind: &st let status = response.status(); let body = response.text().await.unwrap(); assert_eq!(status, 200, "{body}"); + if kind == "stage" { + assert!( + !child.marker.exists(), + "pure stage must not perform durable writes" + ); + case.replay(&format!("http://{}", child.address)).await; + return 0; + } let marker: Value = serde_json::from_slice(&std::fs::read(&child.marker).unwrap()).unwrap(); usize::try_from(marker["index"].as_u64().unwrap()).unwrap() } diff --git a/app/crowdb-access-server/tests/iceberg_file_delete_test.rs b/app/crowdb-access-server/tests/iceberg_file_delete_test.rs new file mode 100644 index 000000000..255b00a88 --- /dev/null +++ b/app/crowdb-access-server/tests/iceberg_file_delete_test.rs @@ -0,0 +1,217 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use std::sync::atomic::Ordering; + +use crowdb_access_iceberg::{ + file::{FileContent, FileRepository}, + gc::{CandidatePhase, GcPhase, GcRepository, GcTask}, + key::FileId, + record::StorageRecord, +}; +use crowdb_access_server::iceberg::{mark_unreferenced_file_for_tests, FileS3ErrorCode}; + +#[path = "common/iceberg_file_delete.rs"] +mod fixture; +use fixture::{TestDeletion, DELETED_MS}; + +#[tokio::test] +async fn file_delete_only_marks_authority_and_replay_keeps_the_first_deadline() { + let fixture = TestDeletion::new(false).await; + fixture.store.deny_gc.store(true, Ordering::SeqCst); + fixture.store.reset_counts(); + fixture.mark(DELETED_MS).await.unwrap(); + assert_eq!(fixture.store.counts(), (4, 1)); + assert_eq!(fixture.store.gc_requests.load(Ordering::SeqCst), 0); + assert_eq!(fixture.blocks.notifications.load(Ordering::SeqCst), 0); + let StorageRecord::DeletedFile(deleted) = fixture.authority() else { + panic!("logical deletion must remain the durable discovery fact"); + }; + assert_eq!(deleted.file, fixture.file); + assert_eq!(deleted.deleted_ms, DELETED_MS); + + fixture.store.reset_counts(); + fixture.mark(DELETED_MS + 60_000).await.unwrap(); + assert_eq!(fixture.store.counts(), (2, 0)); + assert_eq!(fixture.store.gc_requests.load(Ordering::SeqCst), 0); + assert_eq!(fixture.authority(), StorageRecord::DeletedFile(deleted.clone())); + + fixture.store.deny_gc.store(false, Ordering::SeqCst); + let gc = GcRepository::new(fixture.store.clone()); + gc.admit_multipart(fixture.context, DELETED_MS, fixture.limits) + .await + .unwrap(); + fixture.store.reset_counts(); + gc.claim_deleted_file(fixture.context, &deleted, DELETED_MS, fixture.limits) + .await + .unwrap(); + assert_eq!(fixture.store.counts(), (6, 2)); + eprintln!("file logical delete: 10 GET + 3 CAS -> 4 GET + 1 CAS; replay: 8 GET -> 2 GET; reference scan unchanged"); +} + +#[tokio::test] +async fn restart_discovers_only_deleted_file_and_observes_the_original_grace_period() { + for native in [false, true] { + let fixture = TestDeletion::new(native).await; + fixture.mark(DELETED_MS).await.unwrap(); + assert!(fixture.candidate().await.is_none()); + let mut task = fixture.discover().await; + let candidate = fixture.candidate().await.unwrap(); + assert_eq!(candidate.file, fixture.file); + assert_eq!(candidate.not_before_ms, DELETED_MS + GcTask::PURGE_DELAY_MS); + for _ in 0..30 { + task = fixture + .step_after_restart(&task, candidate.not_before_ms - 1) + .await + .unwrap(); + if task.phase == GcPhase::Complete { + break; + } + } + assert_eq!(task.phase, GcPhase::Complete, "{task:?}"); + assert_eq!(fixture.candidate().await.unwrap(), candidate); + assert!(matches!(fixture.authority(), StorageRecord::DeletedFile(_))); + assert_eq!(fixture.blocks.notifications.load(Ordering::SeqCst), 0); + let previous = task; + let next_period_ms = DELETED_MS + 30 * 60_000; + task = GcRepository::new(fixture.store.clone()) + .admit_multipart(fixture.context, next_period_ms, fixture.limits) + .await + .unwrap(); + assert_eq!(task.identity, previous.identity); + assert_eq!(task.created_ms, previous.created_ms); + assert_eq!(task.not_before_ms, previous.not_before_ms); + assert_eq!(task.sweep_round, previous.sweep_round); + for _ in 0..100 { + task = fixture.step_after_restart(&task, next_period_ms).await.unwrap(); + if task.phase == GcPhase::Complete { + break; + } + } + assert_eq!(task.phase, GcPhase::Complete, "{task:?}"); + assert_eq!(task.deleted, 1); + assert_eq!(task.sweep_round, previous.sweep_round + 1); + assert_eq!(fixture.blocks.notifications.load(Ordering::SeqCst), 1); + assert_eq!(fixture.blocks.accepted.load().len(), 1); + assert_eq!(fixture.candidate().await.unwrap().phase, CandidatePhase::Complete); + assert!(FileRepository::new(fixture.store.clone()) + .deleted(fixture.context, &fixture.file.location) + .await + .unwrap() + .is_none()); + } +} + +#[tokio::test] +async fn immediate_reupload_preserves_the_old_cleanup_without_background_discovery() { + for native in [false, true] { + let fixture = TestDeletion::new(native).await; + fixture.mark(DELETED_MS).await.unwrap(); + let files = FileRepository::new(fixture.store.clone()); + let mut replacement = fixture.file.clone(); + replacement.file = FileId::random(); + let FileContent::Chunks { root: Some(root) } = &mut replacement.content else { + panic!("expected one physical range"); + }; + root.chunk.low = 8; + fixture.store.reset_counts(); + files.publish(fixture.context, &replacement).await.unwrap(); + assert_eq!(fixture.store.counts(), (9, 5)); + let candidate = fixture.candidate().await.unwrap(); + assert_eq!(candidate.file, fixture.file); + assert_eq!(candidate.not_before_ms, DELETED_MS + GcTask::PURGE_DELAY_MS); + let task = fixture.discover().await; + fixture.finish(task).await; + assert_eq!( + files.load(fixture.context, &replacement.location).await.unwrap(), + Some(replacement) + ); + assert_eq!(fixture.blocks.notifications.load(Ordering::SeqCst), 1); + assert_eq!(fixture.blocks.accepted.load()[0].chunk.low, 7); + } +} + +#[tokio::test] +async fn rejected_or_unknown_logical_marks_return_error_and_replay_from_authority() { + let fixture = TestDeletion::new(false).await; + fixture.store.mark_failure.store(1, Ordering::SeqCst); + assert!(fixture.mark(DELETED_MS).await.is_err()); + assert_eq!( + fixture.authority(), + StorageRecord::File(Box::new(fixture.file.clone())) + ); + assert_eq!(fixture.blocks.notifications.load(Ordering::SeqCst), 0); + + fixture.store.mark_failure.store(2, Ordering::SeqCst); + assert!(fixture.mark(DELETED_MS + 1).await.is_err()); + let StorageRecord::DeletedFile(deleted) = fixture.authority() else { + panic!("applied mark with lost reply must remain discoverable"); + }; + assert_eq!(deleted.deleted_ms, DELETED_MS + 1); + fixture.mark(DELETED_MS + 60_000).await.unwrap(); + assert_eq!(fixture.authority(), StorageRecord::DeletedFile(deleted)); + assert_eq!(fixture.blocks.notifications.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn failed_or_unknown_chunk_notification_keeps_the_candidate_and_file_evidence() { + let fixture = TestDeletion::new(false).await; + fixture.mark(DELETED_MS).await.unwrap(); + let mut task = fixture.discover().await; + fixture.blocks.denied.store(true, Ordering::SeqCst); + for _ in 0..40 { + match fixture + .step_after_restart(&task, DELETED_MS + GcTask::PURGE_DELAY_MS) + .await + { + Ok(next) => task = next, + Err(_) => break, + } + } + assert_eq!(fixture.blocks.notifications.load(Ordering::SeqCst), 1); + assert!(fixture.blocks.accepted.load().is_empty()); + assert_ne!(task.phase, GcPhase::Complete); + assert_ne!(fixture.candidate().await.unwrap().phase, CandidatePhase::Complete); + assert!(matches!(fixture.authority(), StorageRecord::DeletedFile(_))); + + fixture.blocks.denied.store(false, Ordering::SeqCst); + fixture.blocks.lose_reply.store(true, Ordering::SeqCst); + assert!(fixture + .step_after_restart(&task, DELETED_MS + GcTask::PURGE_DELAY_MS) + .await + .is_err()); + assert_eq!(fixture.blocks.accepted.load().len(), 1); + assert_ne!(fixture.candidate().await.unwrap().phase, CandidatePhase::Complete); + assert!(matches!(fixture.authority(), StorageRecord::DeletedFile(_))); + fixture.finish(task).await; + assert_eq!(fixture.blocks.accepted.load().len(), 2); + assert_eq!( + fixture.blocks.accepted.load()[0], + fixture.blocks.accepted.load()[1] + ); +} + +#[tokio::test] +async fn uncertain_or_retained_reference_inventory_cannot_mark_a_live_file() { + let fixture = TestDeletion::new(false).await; + let files = FileRepository::new(fixture.store.clone()); + let result = + mark_unreferenced_file_for_tests(&files, fixture.context, &fixture.file.location, DELETED_MS, None) + .await; + assert_eq!(result, Err(FileS3ErrorCode::SlowDown)); + let retained = [fixture.file.location.clone()]; + let result = mark_unreferenced_file_for_tests( + &files, + fixture.context, + &fixture.file.location, + DELETED_MS, + Some(&retained), + ) + .await; + assert_eq!(result, Err(FileS3ErrorCode::Conflict)); + assert_eq!( + fixture.authority(), + StorageRecord::File(Box::new(fixture.file.clone())) + ); + assert_eq!(fixture.blocks.notifications.load(Ordering::SeqCst), 0); +} diff --git a/app/crowdb-access-server/tests/iceberg_file_response_test.rs b/app/crowdb-access-server/tests/iceberg_file_response_test.rs index b8338b147..b3509842b 100644 --- a/app/crowdb-access-server/tests/iceberg_file_response_test.rs +++ b/app/crowdb-access-server/tests/iceberg_file_response_test.rs @@ -132,6 +132,10 @@ fn malformed_pages_timestamps_and_escape_input_fail_before_success() { let body = String::from_utf8(response.into_body()).unwrap(); assert!(body.contains("InvalidPart")); assert!(body.contains("/x&<y>")); + let restart = MultipartResponses::error(FileS3ErrorCode::RestartMultipart, "/file", "req").unwrap(); + let restart = String::from_utf8(restart.into_body()).unwrap(); + assert!(restart.contains("InvalidRequest")); + assert!(restart.contains("abort and create a new multipart upload")); assert!(MultipartResponses::error(FileS3ErrorCode::InternalError, &"x".repeat(2049), "req").is_err()); for (code, status) in [ (FileS3ErrorCode::AccessDenied, StatusCode::FORBIDDEN), @@ -139,6 +143,7 @@ fn malformed_pages_timestamps_and_escape_input_fail_before_success() { (FileS3ErrorCode::InvalidPart, StatusCode::BAD_REQUEST), (FileS3ErrorCode::EntityTooLarge, StatusCode::PAYLOAD_TOO_LARGE), (FileS3ErrorCode::InvalidRequest, StatusCode::BAD_REQUEST), + (FileS3ErrorCode::RestartMultipart, StatusCode::BAD_REQUEST), (FileS3ErrorCode::InternalError, StatusCode::INTERNAL_SERVER_ERROR), ] { assert_eq!( diff --git a/app/crowdb-access-server/tests/iceberg_gc_budget_test.rs b/app/crowdb-access-server/tests/iceberg_gc_budget_test.rs index 379018af5..7a04ebe52 100644 --- a/app/crowdb-access-server/tests/iceberg_gc_budget_test.rs +++ b/app/crowdb-access-server/tests/iceberg_gc_budget_test.rs @@ -85,7 +85,7 @@ impl FileBlockStore for TestBlocks { async fn reclaim(&self, _root: &ChunkRoot) -> Result { self.deletes.fetch_add(1, Ordering::Relaxed); - Ok(ReclaimOutcome::Reclaimed) + Ok(ReclaimOutcome::Accepted) } } @@ -109,7 +109,7 @@ async fn chunk_io_budget_rejects_work_before_dispatch_and_resets_per_step() { assert!(matches!(blocks.reclaim(&root).await, Err(FileIoError::Bounds))); assert_eq!(inner.deletes.load(Ordering::Relaxed), 0); budget.reset(); - assert_eq!(blocks.reclaim(&root).await.unwrap(), ReclaimOutcome::Reclaimed); + assert_eq!(blocks.reclaim(&root).await.unwrap(), ReclaimOutcome::Accepted); assert_eq!(inner.deletes.load(Ordering::Relaxed), 1); } diff --git a/app/crowdb-access-server/tests/iceberg_gc_scheduler/timing.rs b/app/crowdb-access-server/tests/iceberg_gc_scheduler/timing.rs new file mode 100644 index 000000000..186155afa --- /dev/null +++ b/app/crowdb-access-server/tests/iceberg_gc_scheduler/timing.rs @@ -0,0 +1,384 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::fixture::{ + self, scheduler, setup, + timing::{cleared_catalog, now_ms, replace_task, IDLE_MS}, +}; +use crowdb_access_iceberg::{ + gc::{GcLimits, GcPhase, GcRepository}, + key::{CatalogId, OperationId}, +}; +use crowdb_access_server::iceberg::TestGcSweep; +use std::{ + sync::{atomic::Ordering, Arc}, + time::Duration, +}; + +#[tokio::test] +async fn default_backlog_uses_one_second_windows_until_idle() { + let (store, context, initial) = setup().await; + let mut sweep = scheduler(store.clone(), context); + let mut previous = initial; + let mut steps = 0; + for _ in 0..200 { + let window = sweep.window_for_tests(IDLE_MS).await; + assert!(!window.failed, "{window:?}"); + let task = GcRepository::new(store.clone()) + .task(context.catalog, previous.identity) + .await + .unwrap() + .unwrap(); + assert!( + task.revision <= previous.revision + 1, + "more than one worker step: {previous:?} -> {task:?}" + ); + steps += usize::from(task.revision > previous.revision); + previous = task; + if !window.busy { + assert_eq!(window.delay_ms, IDLE_MS); + assert_eq!(previous.phase, GcPhase::Complete); + assert!(steps > 4, "only {steps} steps"); + return; + } + assert_eq!(window.delay_ms, 1_000); + } + panic!("backlog did not finish: {previous:?}"); +} + +#[tokio::test] +async fn terminal_paused_and_future_rows_finish_a_round_without_busy_polling() { + let (store, context, task) = setup().await; + let deadline = now_ms() + 120_000; + let mut future = task.clone(); + future.retry_at_ms = deadline; + replace_task(&store, &future).await; + for (phase, paused) in [ + (GcPhase::Complete, false), + (GcPhase::Discover, true), + (GcPhase::Quarantined, false), + ] { + let mut extra = task.clone(); + extra.identity = OperationId::random(); + extra.phase = phase; + extra.paused = paused; + extra.quarantined_from = (phase == GcPhase::Quarantined).then_some(GcPhase::Discover); + fixture::put( + store.as_ref(), + &extra.key().encode().unwrap(), + &crowdb_access_iceberg::record::StorageRecord::GcTask(Box::new(extra)) + .encode() + .unwrap(), + ) + .await; + } + let mut sweep = scheduler(store.clone(), context); + for _ in 0..10 { + let window = sweep.window_for_tests(IDLE_MS).await; + assert!(!window.failed, "{window:?}"); + if !window.busy { + assert_eq!(window.retry_at_ms, Some(deadline)); + assert!((110_000..=120_000).contains(&window.delay_ms)); + assert_eq!( + GcRepository::new(store) + .task(context.catalog, task.identity) + .await + .unwrap() + .unwrap(), + future + ); + return; + } + assert_eq!(window.delay_ms, 1_000); + } + panic!("nonrunnable rows kept sweep busy"); +} + +#[tokio::test] +async fn persistent_purge_and_clear_markers_do_not_reopen_terminal_work() { + let (store, active, mut retired) = cleared_catalog().await; + retired.phase = GcPhase::Complete; + replace_task(&store, &retired).await; + let marker = fixture::purge(&store, active, 25).await; + let repository = GcRepository::new(store.clone()); + let mut purge = repository + .admit_purge(active, &marker, now_ms(), GcLimits::default()) + .await + .unwrap(); + purge.phase = GcPhase::Complete; + replace_task(&store, &purge).await; + let mut multipart = repository + .admit_multipart(active, now_ms(), GcLimits::default()) + .await + .unwrap(); + multipart.paused = true; + replace_task(&store, &multipart).await; + let mut sweep = scheduler(store.clone(), active); + for _ in 0..30 { + let window = sweep.window_for_tests(IDLE_MS).await; + assert!(!window.failed, "{window:?}"); + if !window.busy { + assert_eq!(window.delay_ms, IDLE_MS); + assert_eq!( + repository + .task(retired.context.catalog, retired.identity) + .await + .unwrap() + .unwrap(), + retired + ); + assert_eq!( + repository + .task(active.catalog, purge.identity) + .await + .unwrap() + .unwrap(), + purge + ); + assert_eq!( + repository + .task(active.catalog, multipart.identity) + .await + .unwrap() + .unwrap(), + multipart + ); + assert!(store + .inner + .values + .load() + .contains_key(&marker.key().encode().unwrap())); + return; + } + } + panic!("persistent terminal marker kept sweep busy"); +} + +#[tokio::test] +async fn retired_worker_rescans_system_lane_and_respects_future_retention() { + let (store, active, initial) = cleared_catalog().await; + let mut sweep = scheduler(store.clone(), active); + let repository = GcRepository::new(store); + for _ in 0..100 { + let window = sweep.window_for_tests(IDLE_MS).await; + assert!(!window.failed, "{window:?}"); + let task = repository + .task(initial.context.catalog, initial.identity) + .await + .unwrap() + .unwrap(); + if !window.busy { + assert!( + task.revision > initial.revision + 3, + "old catalog did not advance: {task:?}" + ); + assert!(task.retry_at_ms > now_ms(), "retention was skipped: {task:?}"); + assert_eq!(window.retry_at_ms, Some(task.retry_at_ms)); + assert_eq!(window.delay_ms, IDLE_MS); + return; + } + assert_eq!(window.delay_ms, 1_000); + } + panic!("retired sweep failed to reach its grace boundary"); +} + +#[tokio::test(start_paused = true)] +async fn ready_catalog_round_robin_does_not_sleep_behind_an_idle_or_failed_scope() { + let (store, context, mut task) = setup().await; + task.paused = true; + replace_task(&store, &task).await; + let mut sweep = scheduler(store, context); + let mut idle = sweep.window_for_tests(IDLE_MS).await; + while idle.busy { + idle = sweep.window_for_tests(IDLE_MS).await; + } + let a = CatalogId::random(); + let b = CatalogId::random(); + let mut schedule = TestGcSweep::schedule_for_tests(&[a], b); + assert_eq!(schedule.ready_for_tests(), Some(a)); + schedule.finish_for_tests(a, idle, IDLE_MS); + assert_eq!(schedule.ready_for_tests(), Some(b)); + let mut busy = idle; + busy.busy = true; + busy.progressed = true; + schedule.finish_for_tests(b, busy, IDLE_MS); + assert_eq!(schedule.ready_for_tests(), None); + assert_eq!(schedule.wait_ms_for_tests(), 1_000); + tokio::time::advance(Duration::from_millis(1_000)).await; + assert_eq!(schedule.ready_for_tests(), Some(b)); + busy.failed = true; + busy.progressed = false; + schedule.finish_for_tests(b, busy, IDLE_MS); + tokio::time::advance(Duration::from_millis(1_000)).await; + schedule.finish_for_tests(b, busy, IDLE_MS); + assert_eq!(schedule.wait_ms_for_tests(), 2_000); + let configured: Vec<_> = (0..64).map(|_| CatalogId::random()).collect(); + for _ in 0..100 { + schedule.retain_scopes_for_tests(&configured, CatalogId::random()); + assert_eq!(schedule.scopes_for_tests(), 65); + } + schedule.retain_scopes_for_tests(&configured, configured[0]); + assert_eq!(schedule.scopes_for_tests(), 64); +} + +#[tokio::test] +async fn repeated_bad_row_backs_off_and_empty_catalog_is_idle() { + let (store, context, mut task) = setup().await; + task.paused = true; + replace_task(&store, &task).await; + let poison = crowdb_access_iceberg::key::IcebergKey::Catalog { + catalog: context.catalog, + scope: crowdb_access_iceberg::key::CatalogScope::GcTask, + suffix: vec![1; 16], + } + .encode() + .unwrap(); + fixture::put(&store, &poison, b"broken").await; + let mut sweep = TestGcSweep::new( + store.clone(), + Arc::new(fixture::TestBlocks), + context.catalog, + None, + GcLimits { + page_items: 1, + ..GcLimits::default() + }, + ) + .unwrap(); + let mut delay = 0; + for _ in 0..10 { + let window = sweep.window_for_tests(IDLE_MS).await; + assert!(window.failed, "{window:?}"); + assert!(window.delay_ms >= delay, "{window:?}, previous delay {delay}"); + delay = window.delay_ms; + } + assert_eq!(delay, 60_000); + let mut repaired = task; + repaired.identity = OperationId::from_bytes(&[1; 16]).unwrap(); + replace_task(&store, &repaired).await; + let mut recovered = false; + for _ in 0..8 { + let window = sweep.window_for_tests(IDLE_MS).await; + if !window.busy && !window.failed { + assert_eq!(window.delay_ms, IDLE_MS); + recovered = true; + break; + } + } + assert!(recovered, "a repaired row never cleared its error backoff"); + let mut empty = TestGcSweep::new( + store, + Arc::new(fixture::TestBlocks), + CatalogId::random(), + None, + GcLimits::default(), + ) + .unwrap(); + let window = empty.window_for_tests(IDLE_MS).await; + assert!(!window.busy && !window.failed, "{window:?}"); + assert_eq!(window.delay_ms, IDLE_MS); +} + +#[tokio::test] +async fn several_tasks_share_one_worker_step_and_the_original_io_quota_each_window() { + let (store, context, mut multipart) = setup().await; + multipart.paused = true; + replace_task(&store, &multipart).await; + let repository = GcRepository::new(store.clone()); + let mut tasks = Vec::new(); + for identity in [21, 22, 23] { + let marker = fixture::purge(&store, context, identity).await; + tasks.push( + repository + .admit_purge(context, &marker, now_ms(), GcLimits::default()) + .await + .unwrap(), + ); + } + let mut sweep = scheduler(store.clone(), context); + let mut per_task = [0; 3]; + for _ in 0..40 { + store.requests.store(0, Ordering::SeqCst); + let window = sweep.window_for_tests(IDLE_MS).await; + assert!(!window.failed, "{window:?}"); + let requests = store.requests.load(Ordering::SeqCst); + assert!(requests <= 128 + 16, "per-window quota grew: {requests}"); + let mut steps = 0; + for (index, previous) in tasks.iter_mut().enumerate() { + let current = repository + .task(context.catalog, previous.identity) + .await + .unwrap() + .unwrap(); + assert!( + current.revision <= previous.revision + 1, + "{previous:?} -> {current:?}" + ); + let advanced = usize::from(current.revision != previous.revision); + per_task[index] += advanced; + steps += advanced; + *previous = current; + } + assert!(steps <= 1, "more than one worker in a window: {steps}"); + if per_task.iter().all(|steps| *steps >= 4) { + return; + } + } + panic!("task lane was unfair: {per_task:?}"); +} + +#[tokio::test] +async fn cancelled_unpublished_purge_is_not_an_error_or_permanent_busy_lane() { + use crowdb_access_iceberg::{ + catalog::CatalogStore, + operation::mutation_identity, + record::StorageRecord, + table::{storage_head_key, TableLifecycle}, + }; + let (store, context, mut multipart) = setup().await; + multipart.paused = true; + replace_task(&store, &multipart).await; + let mut marker = fixture::purge(&store, context, 29).await; + let mut ready = marker.head.clone(); + ready.lifecycle = TableLifecycle::Ready; + ready.pending_operation = None; + marker.head.operation_fence += 1; + marker.dropped_ms = 0; + for (key, record) in [ + ( + marker.key().encode().unwrap(), + StorageRecord::TablePurgeTask(Box::new(marker.clone())), + ), + ( + storage_head_key(ready.storage_location()).encode().unwrap(), + StorageRecord::TableHead(Box::new(ready)), + ), + ] { + let before = store.get(&key).await.unwrap().unwrap(); + let bytes = record.encode().unwrap(); + store + .compare_exchange( + &key, + Some(&before.bytes), + &bytes, + mutation_identity(&key, Some(&before.bytes), &bytes), + ) + .await + .unwrap(); + } + let mut sweep = scheduler(store.clone(), context); + for _ in 0..10 { + let window = sweep.window_for_tests(IDLE_MS).await; + assert!(!window.failed, "{window:?}"); + if !window.busy { + assert_eq!(window.delay_ms, IDLE_MS); + assert!(store + .get(&marker.key().encode().unwrap()) + .await + .unwrap() + .is_none()); + return; + } + } + panic!("cancelled purge marker kept the lane busy"); +} diff --git a/app/crowdb-access-server/tests/iceberg_gc_scheduler_test.rs b/app/crowdb-access-server/tests/iceberg_gc_scheduler_test.rs new file mode 100644 index 000000000..eea0bd814 --- /dev/null +++ b/app/crowdb-access-server/tests/iceberg_gc_scheduler_test.rs @@ -0,0 +1,234 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use crowdb_access_iceberg::{ + catalog::CatalogStore, + gc::{GcLimits, GcRepository}, + key::{CatalogId, CatalogScope, IcebergKey, OperationId, SystemScope}, + operation::{ledger_key, mutation_identity}, +}; +use crowdb_access_server::iceberg::TestGcSweep; +use std::{ + sync::{atomic::Ordering, Arc}, + time::Duration, +}; + +#[path = "common/iceberg_gc_scheduler.rs"] +mod fixture; +use fixture::{advance, assert_progress, purge, purge_task_key, put, scheduler, setup, TestBlocks}; + +#[path = "iceberg_gc_scheduler/timing.rs"] +mod timing; + +#[tokio::test] +async fn corrupt_task_row_cannot_block_a_later_healthy_task_forever() { + let (store, context, task) = setup().await; + let poison = IcebergKey::Catalog { + catalog: context.catalog, + scope: CatalogScope::GcTask, + suffix: vec![1; 16], + } + .encode() + .unwrap(); + store + .compare_exchange( + &poison, + None, + b"broken", + mutation_identity(&poison, None, b"broken"), + ) + .await + .unwrap(); + let mut sweep = TestGcSweep::new( + store.clone(), + Arc::new(TestBlocks), + context.catalog, + Some(context), + GcLimits::default(), + ) + .unwrap(); + for _ in 0..8 { + if let Err(error) = sweep.advance_for_tests().await { + eprintln!("scheduler retained error: {error}"); + } + } + let current = GcRepository::new(store.clone()) + .task(context.catalog, task.identity) + .await + .unwrap() + .unwrap(); + assert!( + current.revision > task.revision, + "healthy task never advanced past poison: {current:?}" + ); + assert!(store.get(&poison).await.unwrap().is_some()); +} + +#[tokio::test] +async fn malformed_long_raw_key_is_retained_and_skipped_by_local_position() { + let (store, context, task) = setup().await; + let mut poison = IcebergKey::catalog_range(context.catalog).start; + poison.push(CatalogScope::GcTask as u8); + poison.resize(4097, 1); + put(store.as_ref(), &poison, b"bad-key").await; + let mut sweep = scheduler(store.clone(), context); + sweep.advance_for_tests().await.unwrap_err(); + assert_eq!(sweep.position_for_tests(3), poison); + advance(&mut sweep, 5).await; + assert_progress(store.clone(), &task).await; + assert!(store.get(&poison).await.unwrap().is_some()); +} + +#[tokio::test] +async fn purge_and_retired_poison_do_not_block_task_or_later_purge() { + let (store, context, task) = setup().await; + let bad = purge(store.as_ref(), context, 1).await; + let poison = bad.key().encode().unwrap(); + let original = store.get(&poison).await.unwrap().unwrap(); + store + .compare_exchange( + &poison, + Some(&original.bytes), + b"broken", + mutation_identity(&poison, Some(&original.bytes), b"broken"), + ) + .await + .unwrap(); + let healthy = purge(store.as_ref(), context, 2).await; + let ledger = ledger_key(SystemScope::ManagementOperation, OperationId::random()) + .unwrap() + .encode() + .unwrap(); + put(store.as_ref(), &ledger, b"bad-ledger").await; + let mut sweep = scheduler(store.clone(), context); + assert!(sweep.advance_for_tests().await.is_err()); + assert_eq!(sweep.position_for_tests(0), poison); + assert_eq!(sweep.position_for_tests(2), ledger); + assert_progress(store.clone(), &task).await; + advance(&mut sweep, 4).await; + assert!(store.get(&purge_task_key(&healthy)).await.unwrap().is_some()); + assert_eq!(store.get(&poison).await.unwrap().unwrap().bytes, b"broken"); + assert_eq!(store.get(&ledger).await.unwrap().unwrap().bytes, b"bad-ledger"); +} + +#[tokio::test] +async fn failed_action_keeps_position_retries_next_round_and_restart_rediscovers() { + let (store, context, task) = setup().await; + let marker = purge(store.as_ref(), context, 3).await; + let marker_key = marker.key().encode().unwrap(); + store.fault_at(marker_key.clone()); + store.read_failure.store(1, Ordering::SeqCst); + let mut sweep = scheduler(store.clone(), context); + sweep.advance_for_tests().await.unwrap_err(); + assert_eq!(sweep.position_for_tests(0), marker_key); + assert!(store.get(&purge_task_key(&marker)).await.unwrap().is_none()); + assert_progress(store.clone(), &task).await; + advance(&mut sweep, 3).await; + assert!(store.get(&purge_task_key(&marker)).await.unwrap().is_some()); + // A new worker deliberately loses all hints, then recovers solely from durable marker/head. + let mut restarted = scheduler(store.clone(), context); + advance(&mut restarted, 2).await; + assert!(store.get(&purge_task_key(&marker)).await.unwrap().is_some()); + advance(&mut sweep, 4).await; + assert!(store.get(&marker_key).await.unwrap().is_some()); +} + +#[tokio::test] +async fn unknown_applied_admission_keeps_fact_and_healthy_lanes_continue() { + let (store, context, task) = setup().await; + let marker = purge(store.as_ref(), context, 4).await; + store.fault_at(purge_task_key(&marker)); + store.write_failure.store(2, Ordering::SeqCst); + let mut sweep = scheduler(store.clone(), context); + advance(&mut sweep, 6).await; + assert!(store.get(&purge_task_key(&marker)).await.unwrap().is_some()); + assert!(store + .get(&marker.key().encode().unwrap()) + .await + .unwrap() + .is_some()); + assert_progress(store.clone(), &task).await; +} + +#[tokio::test] +async fn cancellation_during_action_retains_observed_key_and_rotates_lanes() { + let (store, context, task) = setup().await; + let marker = purge(store.as_ref(), context, 5).await; + store.fault_at(purge_task_key(&marker)); + store.pause_write.store(true, Ordering::SeqCst); + let mut sweep = scheduler(store.clone(), context); + { + let window = sweep.advance_for_tests(); + tokio::pin!(window); + tokio::select! { + result = &mut window => panic!("action did not pause: {result:?}"), + () = store.entered.notified() => {}, + () = tokio::time::sleep(Duration::from_secs(3)) => panic!("action not reached"), + } + } + assert_eq!(sweep.position_for_tests(0), marker.key().encode().unwrap()); + // It stays paused: the next rotating window must still run the healthy task lane first. + advance(&mut sweep, 1).await; + assert_progress(store.clone(), &task).await; + store.pause_write.store(false, Ordering::SeqCst); + advance(&mut sweep, 4).await; + assert!(store.get(&purge_task_key(&marker)).await.unwrap().is_some()); +} + +#[tokio::test] +async fn timeout_in_first_lane_does_not_starve_other_lanes() { + let (store, context, task) = setup().await; + store + .pause_scope + .store(CatalogScope::Reclamation as usize, Ordering::SeqCst); + let limits = GcLimits { + step_ms: 1, + ..GcLimits::default() + }; + let mut sweep = TestGcSweep::new( + store.clone(), + Arc::new(TestBlocks), + context.catalog, + Some(context), + limits, + ) + .unwrap(); + let error = sweep.advance_for_tests().await.unwrap_err(); + eprintln!("expected scheduler deadline: {error}"); + assert!(sweep.position_for_tests(0).is_empty()); + assert!(sweep.advance_for_tests().await.is_err()); + assert_progress(store.clone(), &task).await; +} + +#[tokio::test] +async fn unvalidated_page_never_advances_and_another_catalog_is_independent() { + let (store, context, task) = setup().await; + store.invalid_page.store(true, Ordering::SeqCst); + let mut sweep = scheduler(store.clone(), context); + sweep.advance_for_tests().await.unwrap_err(); + assert!(sweep.position_for_tests(3).is_empty()); + let poisoned_catalog = CatalogId::random(); + let poison = IcebergKey::Catalog { + catalog: poisoned_catalog, + scope: CatalogScope::GcTask, + suffix: vec![1; 16], + } + .encode() + .unwrap(); + put(store.as_ref(), &poison, b"broken").await; + let mut failed = TestGcSweep::new( + store.clone(), + Arc::new(TestBlocks), + poisoned_catalog, + None, + GcLimits::default(), + ) + .unwrap(); + failed.advance_for_tests().await.unwrap_err(); + let mut healthy = scheduler(store.clone(), context); + advance(&mut healthy, 1).await; + assert_progress(store.clone(), &task).await; + assert!(store.get(&poison).await.unwrap().is_some()); + advance(&mut sweep, 2).await; + assert_progress(store, &task).await; +} diff --git a/app/crowdb-access-server/tests/iceberg_namespace_http_test.rs b/app/crowdb-access-server/tests/iceberg_namespace_http_test.rs index 43eef58fa..09477f4ed 100644 --- a/app/crowdb-access-server/tests/iceberg_namespace_http_test.rs +++ b/app/crowdb-access-server/tests/iceberg_namespace_http_test.rs @@ -242,6 +242,8 @@ async fn complete_list_exhaustion_never_returns_a_truncated_success() { mutation_revision: 1, lifecycle: NamespaceLifecycle::Ready, pending_operation: None, + creating_child: None, + rename: None, properties: NamespaceProperties::default(), }; values.insert( diff --git a/app/crowdb-access-server/tests/iceberg_namespace_sdk_test.rs b/app/crowdb-access-server/tests/iceberg_namespace_sdk_test.rs index be4c017a4..0ae8613f8 100644 --- a/app/crowdb-access-server/tests/iceberg_namespace_sdk_test.rs +++ b/app/crowdb-access-server/tests/iceberg_namespace_sdk_test.rs @@ -58,6 +58,8 @@ fn install(test: &TestTableHttp, count: usize, padding: usize, stale: bool) { mutation_revision: 1, lifecycle: NamespaceLifecycle::Ready, pending_operation: None, + creating_child: None, + rename: None, properties: NamespaceProperties::default(), }; values.insert( diff --git a/app/crowdb-access-server/tests/iceberg_namespace_write_http_test.rs b/app/crowdb-access-server/tests/iceberg_namespace_write_http_test.rs index e73cd62a0..c7f157968 100644 --- a/app/crowdb-access-server/tests/iceberg_namespace_write_http_test.rs +++ b/app/crowdb-access-server/tests/iceberg_namespace_write_http_test.rs @@ -1,11 +1,19 @@ #[path = "common/iceberg_store.rs"] mod common; -use crowdb_access_iceberg::catalog::{CatalogRepository, CatalogStore, ClearBounds, ManagementPrivilege}; -use crowdb_access_iceberg::key::{IcebergKey, OperationId, SystemScope}; +use async_trait::async_trait; +use crowdb_access_iceberg::catalog::{ + CasOutcome, CatalogRepository, CatalogStore, ClearBounds, ManagementPrivilege, StoreError, StoredValue, +}; +use crowdb_access_iceberg::key::{IcebergKey, NamespaceId, OperationId, SystemScope}; +use crowdb_access_iceberg::namespace::{ChildScan, NamespaceMappingState, NamespaceStore}; use crowdb_access_iceberg::operation::{ledger_key, ManagementAction, ManagementRequest, RequestIdentity}; +use crowdb_access_iceberg::record::StorageRecord; use crowdb_access_iceberg::wire::BearerAuthenticator; use crowdb_access_server::iceberg::{serve, IcebergHttpService}; +use crowdb_chunk_kv_client::MultiScanPage; +use crowdb_protocol::chunk_kv::ClientRequestId; +use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -43,7 +51,7 @@ async fn setup() -> ( BearerAuthenticator::new(&"r".repeat(32), &"w".repeat(32), &"m".repeat(32), &"c".repeat(32)).unwrap(); let service = Arc::new( IcebergHttpService::new(repository, auth, Duration::from_secs(2)) - .with_namespaces(store.clone()) + .with_namespaces(Arc::new(TestNamespaceReplyStore { inner: store.clone() })) .unwrap(), ); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -59,6 +67,58 @@ async fn setup() -> ( (store, address, stop, server) } +struct TestNamespaceReplyStore { + inner: Arc, +} + +#[async_trait] +impl CatalogStore for TestNamespaceReplyStore { + async fn get(&self, key: &[u8]) -> Result, StoreError> { + self.inner.get(key).await + } + + async fn compare_exchange( + &self, + key: &[u8], + expected: Option<&[u8]>, + value: &[u8], + identity: ClientRequestId, + ) -> Result { + let result = self + .inner + .compare_exchange(key, expected, value, identity) + .await?; + // The shared fixture's third mode targets a historical create receipt. + // Lose the acknowledgement of the final name publication instead. + if matches!(&result, CasOutcome::Applied(_)) + && matches!(IcebergKey::decode(key).and_then(|key| StorageRecord::decode(&key, value)), Ok(StorageRecord::NamespaceMapping(mapping)) if mapping.state == NamespaceMappingState::Published) + && self + .inner + .lose_reply_kind + .compare_exchange(3, 0, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + return Err(StoreError::Response); + } + Ok(result) + } +} + +#[async_trait] +impl NamespaceStore for TestNamespaceReplyStore { + async fn scan_children(&self, scan: ChildScan) -> Result { + self.inner.scan_children(scan).await + } + async fn delete_mapping( + &self, + key: &[u8], + expected: &[u8], + identity: ClientRequestId, + ) -> Result { + self.inner.delete_mapping(key, expected, identity).await + } +} + async fn fresh_key(store: &common::TestStore) -> String { for _ in 0..32 { let mut bytes = *OperationId::random().as_bytes(); @@ -119,7 +179,7 @@ async fn send( } #[tokio::test] -async fn namespace_writes_use_distinct_credentials_and_replay_success_and_terminal_errors() { +async fn namespace_writes_use_distinct_credentials_and_current_resources_without_create_receipts() { let (store, address, stop, server) = setup().await; let body = r#"{"namespace":["parent"],"properties":{"owner":"original"}}"#; let before = store.values.load().len(); @@ -136,9 +196,12 @@ async fn namespace_writes_use_distinct_credentials_and_replay_success_and_termin let created = send(address, "POST", "/v1/namespaces", "w", Some(&key), body).await; assert_eq!(created.0, 200); assert_eq!( - send(address, "POST", "/v1/namespaces", "w", Some(&key), body).await, - created + serde_json::from_str::(&created.1).unwrap(), + serde_json::from_str::(body).unwrap() ); + let original = namespace_id(&store, "parent"); + assert_already_exists(&send(address, "POST", "/v1/namespaces", "w", Some(&key), body).await); + assert_eq!(namespace_id(&store, "parent"), original); assert_eq!( send( address, @@ -150,19 +213,55 @@ async fn namespace_writes_use_distinct_credentials_and_replay_success_and_termin ) .await .0, - 409 + 200 + ); + assert_eq!( + serde_json::from_str::(&namespace_response(address, "different").await.1).unwrap(), + serde_json::json!({"namespace": ["different"], "properties": {}}) ); let duplicate_key = fresh_key(&store).await; let duplicate = send(address, "POST", "/v1/namespaces", "w", Some(&duplicate_key), body).await; - assert_eq!(duplicate.0, 409); + assert_already_exists(&duplicate); verify_property_replay(&store, address).await; - verify_drop_replay(&store, address, &duplicate_key, &duplicate, body).await; + verify_drop_replay(&store, address, &duplicate_key, body).await; stop.send(()).unwrap(); server.await.unwrap(); } +fn assert_already_exists(response: &(u16, String)) { + assert_eq!(response.0, 409); + let body: serde_json::Value = serde_json::from_str(&response.1).unwrap(); + assert_eq!(body["error"]["code"], 409); + assert_eq!(body["error"]["type"], "AlreadyExistsException"); +} + +fn namespace_id(store: &common::TestStore, name: &str) -> NamespaceId { + store + .values + .load() + .iter() + .find_map(|(key, value)| { + match IcebergKey::decode(key).and_then(|key| StorageRecord::decode(&key, &value.bytes)) { + Ok(StorageRecord::NamespaceMapping(mapping)) + if mapping.parent.is_none() && mapping.name == name => + { + Some(mapping.namespace) + } + _ => None, + } + }) + .unwrap() +} + +async fn namespace_response(address: std::net::SocketAddr, name: &str) -> (u16, String) { + let response = send(address, "GET", &format!("/v1/namespaces/{name}"), "r", None, "").await; + assert_eq!(response.0, 200); + response +} + async fn verify_property_replay(store: &common::TestStore, address: std::net::SocketAddr) { let update_key = fresh_key(store).await; + let records = store.values.load().len(); let changes = r#"{"removals":["owner","missing"],"updates":{"value":"kept"}}"#; let updated = send( address, @@ -174,17 +273,28 @@ async fn verify_property_replay(store: &common::TestStore, address: std::net::So ) .await; assert_eq!(updated.0, 200); + let repeated = send( + address, + "POST", + "/v1/namespaces/parent/properties", + "w", + Some(&update_key), + changes, + ) + .await; + assert_eq!(repeated.0, 200); assert_eq!( - send( - address, - "POST", - "/v1/namespaces/parent/properties", - "w", - Some(&update_key), - changes - ) - .await, - updated + serde_json::from_str::(&repeated.1).unwrap(), + serde_json::json!({"removed": [], "updated": ["value"], "missing": ["missing", "owner"]}) + ); + assert_eq!( + store.values.load().len(), + records, + "property updates must not create journals or response records" + ); + assert_eq!( + serde_json::from_str::(&namespace_response(address, "parent").await.1).unwrap(), + serde_json::json!({"namespace": ["parent"], "properties": {"value": "kept"}}) ); assert_eq!( send( @@ -205,9 +315,9 @@ async fn verify_drop_replay( store: &common::TestStore, address: std::net::SocketAddr, duplicate_key: &str, - duplicate: &(u16, String), body: &str, ) { + let original = namespace_id(store, "parent"); let drop_key = fresh_key(store).await; assert_eq!( send( @@ -222,13 +332,17 @@ async fn verify_drop_replay( .0, 204 ); + let recreated = send(address, "POST", "/v1/namespaces", "w", Some(duplicate_key), body).await; + assert_eq!(recreated.0, 200); assert_eq!( - send(address, "POST", "/v1/namespaces", "w", Some(duplicate_key), body).await, - *duplicate + serde_json::from_str::(&recreated.1).unwrap(), + serde_json::from_str::(body).unwrap() ); + let replacement = namespace_id(store, "parent"); + assert_ne!(replacement, original); assert_eq!( send(address, "POST", "/v1/namespaces", "w", None, body).await.0, - 200 + 409 ); assert_eq!( send( @@ -247,12 +361,25 @@ async fn verify_drop_replay( send(address, "GET", "/v1/namespaces/parent", "r", None, "") .await .0, - 200 + 404 + ); + assert_eq!( + send( + address, + "DELETE", + "/v1/namespaces/parent", + "w", + Some(&drop_key), + "" + ) + .await + .0, + 404 ); } #[tokio::test] -async fn malformed_and_missing_parent_results_are_retained_before_any_later_retry() { +async fn failed_requests_without_a_resource_operation_can_retry_after_the_cause_changes() { let (store, address, stop, server) = setup().await; let key = fresh_key(&store).await; let body = r#"{"namespace":["missing","child"]}"#; @@ -272,8 +399,10 @@ async fn malformed_and_missing_parent_results_are_retained_before_any_later_retr 200 ); assert_eq!( - send(address, "POST", "/v1/namespaces", "w", Some(&key), body).await, - failed + send(address, "POST", "/v1/namespaces", "w", Some(&key), body) + .await + .0, + 200 ); let invalid_key = fresh_key(&store).await; let malformed = send(address, "POST", "/v1/namespaces", "w", Some(&invalid_key), "{").await; @@ -282,6 +411,19 @@ async fn malformed_and_missing_parent_results_are_retained_before_any_later_retr send(address, "POST", "/v1/namespaces", "w", Some(&invalid_key), "{").await, malformed ); + assert_eq!( + send( + address, + "POST", + "/v1/namespaces", + "w", + Some(&invalid_key), + r#"{"namespace":["corrected"]}"# + ) + .await + .0, + 200 + ); assert_eq!( send(address, "POST", "/v1/namespaces", "w", Some("invalid"), "{}") .await @@ -290,7 +432,7 @@ async fn malformed_and_missing_parent_results_are_retained_before_any_later_retr ); assert_eq!( send(address, "POST", "/v1/namespaces", "w", None, body).await.0, - 200 + 409 ); assert_eq!( send(address, "DELETE", "/v1/namespaces/missing", "w", None, "") @@ -303,7 +445,7 @@ async fn malformed_and_missing_parent_results_are_retained_before_any_later_retr } #[tokio::test] -async fn colliding_uuidv7_headers_have_independent_durable_http_replay() { +async fn colliding_uuidv7_headers_do_not_create_shared_http_binding_state() { let (store, address, stop, server) = setup().await; let now = u64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis()).unwrap(); let mut seen = std::collections::BTreeMap::new(); @@ -349,35 +491,53 @@ async fn colliding_uuidv7_headers_have_independent_durable_http_replay() { .await; assert_eq!(first_response.0, 200); assert_eq!(second_response.0, 200); - assert_eq!( - send( + let first_id = namespace_id(&store, "first"); + let second_id = namespace_id(&store, "second"); + assert_ne!(first_id, second_id); + assert_already_exists( + &send( address, "POST", "/v1/namespaces", "w", Some(&first_key), - first_body + first_body, ) .await, - first_response ); - assert_eq!( - send( + assert_already_exists( + &send( address, "POST", "/v1/namespaces", "w", Some(&second_key), - second_body + second_body, ) .await, - second_response + ); + assert_eq!(namespace_id(&store, "first"), first_id); + assert_eq!(namespace_id(&store, "second"), second_id); + assert_eq!( + serde_json::from_str::(&namespace_response(address, "first").await.1).unwrap(), + serde_json::from_str::(&first_response.1).unwrap() + ); + assert_eq!( + serde_json::from_str::(&namespace_response(address, "second").await.1).unwrap(), + serde_json::from_str::(&second_response.1).unwrap() ); let overflow = IcebergKey::System { scope: SystemScope::RetryOverflow, suffix: second.as_bytes().to_vec(), }; - assert!(store.values.load().contains_key(&overflow.encode().unwrap())); + assert!(!store.values.load().contains_key(&overflow.encode().unwrap())); + assert!(store.values.load().keys().all(|key| !matches!( + IcebergKey::decode(key), + Ok(IcebergKey::System { + scope: SystemScope::RetryBinding | SystemScope::RetryOverflow, + .. + }) + ))); stop.send(()).unwrap(); server.await.unwrap(); } @@ -403,25 +563,42 @@ async fn server_errors_leave_recoverable_publication_and_large_final_responses() .0, 503 ); - let resumed = send(address, "POST", "/v1/namespaces", "w", Some(&key), &body).await; - assert_eq!(resumed.0, 200); - assert!(resumed.1.len() > 16 * 1024); assert_eq!( - send(address, "POST", "/v1/namespaces", "w", Some(&key), &body).await, - resumed + store.lose_reply_kind.load(Ordering::SeqCst), + 0, + "mode {mode} must hit its durable write" ); + let candidate = namespace_id(&store, &namespace); + let visibility = send( + address, + "GET", + &format!("/v1/namespaces/{namespace}"), + "r", + None, + "", + ) + .await; + assert_eq!(visibility.0, if mode == 3 { 200 } else { 404 }); + let resumed = send(address, "POST", "/v1/namespaces", "w", Some(&key), &body).await; + assert_eq!(resumed.0, if mode == 1 { 200 } else { 409 }); + if mode == 1 { + assert!(resumed.1.len() > 16 * 1024); + assert_eq!( + serde_json::from_str::(&resumed.1).unwrap(), + serde_json::from_str::(&body).unwrap() + ); + } else { + assert_already_exists(&resumed); + } + let selected = namespace_id(&store, &namespace); + assert_eq!(selected == candidate, mode != 1); + assert_already_exists(&send(address, "POST", "/v1/namespaces", "w", Some(&key), &body).await); + assert_eq!(namespace_id(&store, &namespace), selected); + let loaded = namespace_response(address, &namespace).await; + assert!(loaded.1.len() > 16 * 1024); assert_eq!( - send( - address, - "GET", - &format!("/v1/namespaces/{namespace}"), - "r", - None, - "" - ) - .await - .0, - 200 + serde_json::from_str::(&loaded.1).unwrap(), + serde_json::from_str::(&body).unwrap() ); } stop.send(()).unwrap(); diff --git a/app/crowdb-access-server/tests/iceberg_native_injection_http_test.rs b/app/crowdb-access-server/tests/iceberg_native_injection_http_test.rs index 966dcf8cc..e254279bf 100644 --- a/app/crowdb-access-server/tests/iceberg_native_injection_http_test.rs +++ b/app/crowdb-access-server/tests/iceberg_native_injection_http_test.rs @@ -270,6 +270,49 @@ async fn setup() -> ( (stack, repository, table, client, server) } +async fn assert_full_retry( + client: &signed::TestFileClient, + path: &str, + query: &str, + bytes: &[u8], + previous_uri: &str, +) -> crowdb_access_iceberg::file::FileLocation { + let replay = client.send(Method::PUT, path, query, bytes, false).await; + assert_eq!(replay.status(), 200); + let repeated: serde_json::Value = replay.json().await.unwrap(); + let repeated_uri = repeated["location"].as_str().unwrap(); + assert_ne!(repeated_uri, previous_uri); + let repeated_location = repeated_uri + .parse::() + .unwrap(); + let repeated_bytes = client + .send( + Method::GET, + &format!("/iceberg/{}", repeated_location.object_key()), + "", + b"", + false, + ) + .await; + assert_eq!(repeated_bytes.status(), 200); + assert_eq!(repeated_bytes.bytes().await.unwrap(), bytes); + repeated_location +} + +async fn list_native_files(client: &signed::TestFileClient, table: TableLocation) -> String { + let response = client + .send( + Method::GET, + "/iceberg", + &format!("list-type=2&prefix={}", table.storage_path().unwrap()), + b"", + false, + ) + .await; + assert_eq!(response.status(), 200); + response.text().await.unwrap() +} + #[tokio::test] async fn direct_http_reads_skip_file_kv_after_upload_restart_rotation_and_logical_delete() { let (stack, repository, table, mut client, server) = setup().await; @@ -290,9 +333,7 @@ async fn direct_http_reads_skip_file_kv_after_upload_restart_rotation_and_logica let location = uri.parse::().unwrap(); let direct = format!("/iceberg/{}", location.object_key()); assert!(location.relative_key().starts_with("f/v1/part.")); - let replay = client.send(Method::PUT, &path, &query, bytes, false).await; - assert_eq!(replay.status(), 200); - assert_eq!(replay.text().await.unwrap(), body); + let repeated = assert_full_retry(&client, &path, &query, bytes, &uri).await; let file = FileRepository::new(stack.store()) .load(context, &location) .await @@ -323,17 +364,9 @@ async fn direct_http_reads_skip_file_kv_after_upload_restart_rotation_and_logica assert_eq!(head.headers()["content-length"], bytes.len().to_string()); let tag = head.headers()["etag"].to_str().unwrap().to_owned(); assert_conditional_ranges(&client, &direct, &tag).await; - let response = client - .send( - Method::GET, - "/iceberg", - &format!("list-type=2&prefix={}", table.storage_path().unwrap()), - b"", - false, - ) - .await; - let list = response.text().await.unwrap(); - assert!(list.contains("f/v1/part."), "{list}"); + let list = list_native_files(&client, table).await; + assert!(list.contains(&location.object_key()), "{list}"); + assert!(list.contains(&repeated.object_key()), "{list}"); assert_eq!(kv.native_file_reads.load(Ordering::SeqCst), 0); kv.deny_native_file_reads.store(false, Ordering::SeqCst); FileRepository::new(kv.clone()) @@ -345,16 +378,9 @@ async fn direct_http_reads_skip_file_kv_after_upload_restart_rotation_and_logica let response = client.send(Method::GET, &direct, "", b"", false).await; assert_eq!(response.status(), 200); assert_eq!(response.bytes().await.unwrap(), bytes.as_slice()); - let response = client - .send( - Method::GET, - "/iceberg", - &format!("list-type=2&prefix={}", table.storage_path().unwrap()), - b"", - false, - ) - .await; - assert!(!response.text().await.unwrap().contains("f/v1/part.")); + let list = list_native_files(&client, table).await; + assert!(!list.contains(&location.object_key()), "{list}"); + assert!(list.contains(&repeated.object_key()), "{list}"); assert_eq!(kv.native_file_reads.load(Ordering::SeqCst), 0); let crowdb_access_iceberg::file::FileContent::Chunks { root: Some(root) } = &file.content else { panic!() diff --git a/app/crowdb-access-server/tests/iceberg_route_test.rs b/app/crowdb-access-server/tests/iceberg_route_test.rs index fd1d17247..133be899c 100644 --- a/app/crowdb-access-server/tests/iceberg_route_test.rs +++ b/app/crowdb-access-server/tests/iceberg_route_test.rs @@ -286,7 +286,6 @@ async fn discovery_uses_installed_routes_and_unsupported_paths_leave_no_record() assert_eq!(snapshot.routes[2][0].requests, 1); assert_eq!(snapshot.routes[2][0].request_bytes, bytes); assert!(snapshot.routes[2][0].response_bytes > 0); - assert_eq!(snapshot.retry_new, 1); assert_eq!(snapshot.routes[1][0].requests, 1); assert_eq!(snapshot.routes[1][0].response_bytes, 0); } diff --git a/app/crowdb-access-server/tests/iceberg_table_acceptance_test.rs b/app/crowdb-access-server/tests/iceberg_table_acceptance_test.rs index 17aeb501b..1fe3fc7dc 100644 --- a/app/crowdb-access-server/tests/iceberg_table_acceptance_test.rs +++ b/app/crowdb-access-server/tests/iceberg_table_acceptance_test.rs @@ -13,7 +13,7 @@ use crowdb_access_iceberg::{ catalog::StoredValue, key::{CatalogScope, IcebergKey, OperationId, StorageKey, TableId, TableStorageKey, TableStorageScope}, record::StorageRecord, - table::{head_key, name_key, TableHead, TableLifecycle, TableMapping, TableMappingState}, + table::{name_key, storage_head_key, TableHead, TableLifecycle, TableMapping, TableMappingState}, }; use fixture::TestTableHttp; use reqwest::Method; @@ -95,7 +95,7 @@ fn mapping(test: &TestTableHttp, head: &TableHead, name: &str, state: TableMappi test.put( &name_key(head.catalog, head.namespace, name).unwrap(), &StorageRecord::TableMapping(TableMapping { - storage_namespace: None, + storage_namespace: head.storage_location().storage_namespace, catalog: head.catalog, namespace: head.namespace, name: name.into(), @@ -119,8 +119,8 @@ async fn mixed_mapping_pages_and_exists_expose_only_current_table_heads() { let (mut dropped, _) = test.install("dropped").await; dropped.lifecycle = TableLifecycle::Tombstone; dropped.pending_operation = Some(OperationId::random()); - test.put( - &head_key(dropped.catalog, dropped.table), + test.put_storage( + &storage_head_key(dropped.storage_location()), &StorageRecord::TableHead(Box::new(dropped)), ); let mut token = String::new(); @@ -199,10 +199,10 @@ async fn unsupported_table_operations_do_not_change_any_table_or_file_authority( } #[tokio::test] -async fn lost_drop_publication_reply_replays_once_and_preserves_files_for_both_purge_modes() { +async fn lost_drop_reply_retries_the_current_name_and_preserves_exact_purge_ownership() { for purge in [false, true] { let test = TestTableHttp::writable().await; - create(&test).await; + let original = create(&test).await; let before = authority(&test); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -212,41 +212,14 @@ async fn lost_drop_publication_reply_replays_once_and_preserves_files_for_both_p let path = format!("{}{TABLE}?purgeRequested={purge}", test.endpoint()); let client = reqwest::Client::new(); test.store.lose_reply_kind.store(5, Ordering::SeqCst); - let failed = client - .delete(&path) - .bearer_auth("w".repeat(32)) - .header("idempotency-key", &identity) - .send() - .await - .unwrap(); + let failed = delete(&client, &path, &identity).await; value(failed, 503).await; assert_eq!(test.request(Method::HEAD, TABLE, "r", None).await.status(), 404); for _ in 0..2 { - let response = client - .delete(&path) - .bearer_auth("w".repeat(32)) - .header("idempotency-key", &identity) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 204); - assert!(response.bytes().await.unwrap().is_empty()); + value(delete(&client, &path, &identity).await, 404).await; } let after = authority(&test); - for (key, value) in before { - if matches!( - StorageKey::decode(&key).unwrap(), - StorageKey::Legacy(IcebergKey::Catalog { - scope: CatalogScope::File | CatalogScope::FileLocation, - .. - }) | StorageKey::NativeTable(TableStorageKey { - scope: TableStorageScope::File, - .. - }) - ) { - assert_eq!(after.get(&key), Some(&value)); - } - } + assert_files_preserved(&before, &after); let tasks = after .keys() .filter(|key| { @@ -260,6 +233,54 @@ async fn lost_drop_publication_reply_replays_once_and_preserves_files_for_both_p }) .count(); assert_eq!(tasks, usize::from(purge)); + let replacement = create(&test).await; + assert_ne!( + original["metadata"]["table-uuid"], + replacement["metadata"]["table-uuid"] + ); + let replacement_authority: BTreeMap<_, _> = authority(&test) + .into_iter() + .filter(|(key, stored)| after.get(key) != Some(stored)) + .collect(); + assert!(replacement_authority.keys().any(|key| is_file(key))); + assert_eq!( + value(test.request(Method::GET, TABLE, "r", None).await, 200).await["metadata"], + replacement["metadata"] + ); + let response = delete(&client, &path, &identity).await; + assert_eq!(response.status(), 204); + assert!(response.bytes().await.unwrap().is_empty()); + assert_files_preserved(&replacement_authority, &authority(&test)); + value(delete(&client, &path, &identity).await, 404).await; test.finish().await; } } + +async fn delete(client: &reqwest::Client, path: &str, identity: &str) -> reqwest::Response { + client + .delete(path) + .bearer_auth("w".repeat(32)) + .header("idempotency-key", identity) + .send() + .await + .unwrap() +} + +fn is_file(key: &[u8]) -> bool { + matches!( + StorageKey::decode(key).unwrap(), + StorageKey::Legacy(IcebergKey::Catalog { + scope: CatalogScope::File | CatalogScope::FileLocation, + .. + }) | StorageKey::NativeTable(TableStorageKey { + scope: TableStorageScope::File, + .. + }) + ) +} + +fn assert_files_preserved(before: &BTreeMap, StoredValue>, after: &BTreeMap, StoredValue>) { + for (key, value) in before.iter().filter(|(key, _)| is_file(key)) { + assert_eq!(after.get(key), Some(value)); + } +} diff --git a/app/crowdb-access-server/tests/iceberg_table_credentials_test.rs b/app/crowdb-access-server/tests/iceberg_table_credentials_test.rs index 21613ac96..9f445dcb8 100644 --- a/app/crowdb-access-server/tests/iceberg_table_credentials_test.rs +++ b/app/crowdb-access-server/tests/iceberg_table_credentials_test.rs @@ -8,8 +8,11 @@ mod common; mod fixture; use crowdb_access_iceberg::{ - catalog::{CatalogRepository, ClearBounds}, + catalog::{CatalogRepository, CatalogStore, ClearBounds}, file::{FileGrantIssuer, FileOperation, TableLocation}, + operation::mutation_identity, + record::StorageRecord, + table::storage_head_key, wire::BearerAuthenticator, }; use fixture::TestTableHttp; @@ -98,7 +101,37 @@ async fn draft(fixture: &TestTableHttp) -> Value { } #[tokio::test] -async fn same_name_drafts_refresh_only_the_exact_original_writer_scope() { +async fn unpublished_credentials_reject_a_storage_layout_different_from_catalog_authority() { + let fixture = TestTableHttp::vending().await; + let repository = CatalogRepository::new(fixture.store.clone(), ClearBounds::default()).unwrap(); + assert_eq!( + repository.status().await.unwrap().1.storage_layout, + crowdb_access_iceberg::catalog::CatalogStorageLayout::Native + ); + let staged = draft(&fixture).await; + let path = staged["config"]["client.refresh-credentials-endpoint"] + .as_str() + .unwrap(); + let (route, query) = path.split_once('?').unwrap(); + let wrong = format!( + "{route}?{}", + query + .split('&') + .filter(|field| !field.starts_with("storage-namespace-id=")) + .collect::>() + .join("&") + ); + assert_ne!(wrong, path); + assert_eq!(fixture.request(Method::GET, path, "w", None).await.status(), 200); + let response = fixture.request(Method::GET, &wrong, "w", None).await; + let status = response.status(); + let body = response.text().await.unwrap(); + assert_eq!(status, 404, "{body}"); + fixture.finish().await; +} + +#[tokio::test] +async fn client_prepared_tables_refresh_from_complete_identity_without_a_retained_draft() { let fixture = TestTableHttp::vending().await; let auth = BearerAuthenticator::new(&"r".repeat(32), &"w".repeat(32), &"m".repeat(32), &"c".repeat(32)).unwrap(); @@ -142,9 +175,10 @@ async fn same_name_drafts_refresh_only_the_exact_original_writer_scope() { assert_eq!(grant.grant().table, table.table); assert!(grant.grant().operations.allows(FileOperation::Put)); let wrong = path.replace("/events/", "/other/"); + // A table's storage identity is client-owned before first publication. assert_eq!( fixture.request(Method::GET, &wrong, "w", None).await.status(), - 404 + 200 ); assert_eq!( fixture @@ -171,22 +205,21 @@ async fn same_name_drafts_refresh_only_the_exact_original_writer_scope() { .status(), 404 ); - expire_first(&fixture, &first, &second).await; + retire_first(&fixture, &first, &second).await; fixture.finish().await; } -async fn expire_first(fixture: &TestTableHttp, first: &Value, second: &Value) { +async fn retire_first(fixture: &TestTableHttp, first: &Value, second: &Value) { let first_table: TableLocation = format!("{}/", first["metadata"]["location"].as_str().unwrap()) .parse() .unwrap(); - let creator = crowdb_access_iceberg::commit::TableCreator::new( - fixture.store.clone(), - std::sync::Arc::new(blocks::TestFileBlocks::default()), - ); - assert!(creator - .expire_stage(fixture.context, first_table.table, i64::MAX) + let key = storage_head_key(first_table).encode().unwrap(); + let bytes = StorageRecord::RetiredTable(first_table).encode().unwrap(); + fixture + .store + .compare_exchange(&key, None, &bytes, mutation_identity(&key, None, &bytes)) .await - .unwrap()); + .unwrap(); assert_eq!( fixture .request( diff --git a/app/crowdb-access-server/tests/iceberg_table_http_test.rs b/app/crowdb-access-server/tests/iceberg_table_http_test.rs index 5235a0aba..cfacc7888 100644 --- a/app/crowdb-access-server/tests/iceberg_table_http_test.rs +++ b/app/crowdb-access-server/tests/iceberg_table_http_test.rs @@ -270,8 +270,8 @@ async fn corrupt_authority_cannot_become_not_modified() { let fixture = TestTableHttp::new().await; let (mut head, _) = fixture.install("events").await; head.metadata_digest[0] ^= 1; - fixture.put( - &crowdb_access_iceberg::table::head_key(head.catalog, head.table), + fixture.put_storage( + &crowdb_access_iceberg::table::storage_head_key(head.storage_location()), &crowdb_access_iceberg::record::StorageRecord::TableHead(Box::new(head)), ); assert_eq!( diff --git a/app/crowdb-access-server/tests/iceberg_table_lifecycle_test.rs b/app/crowdb-access-server/tests/iceberg_table_lifecycle_test.rs index 1fd30c9d6..38524916b 100644 --- a/app/crowdb-access-server/tests/iceberg_table_lifecycle_test.rs +++ b/app/crowdb-access-server/tests/iceberg_table_lifecycle_test.rs @@ -59,7 +59,7 @@ async fn delete(test: &TestTableHttp, path: &str, role: &str, identity: &str) -> } #[tokio::test] -async fn drop_enforces_writer_and_replays_without_deleting_recreated_table() { +async fn drop_enforces_writer_and_retries_against_the_recreated_table() { for purge in [false, true] { let test = TestTableHttp::writable().await; let before = value(test.post(TABLES, "w", None, &create()).await, 200).await; @@ -73,10 +73,7 @@ async fn drop_enforces_writer_and_replays_without_deleting_recreated_table() { let after = value(test.post(TABLES, "w", None, &create()).await, 200).await; assert_ne!(before["metadata"]["table-uuid"], after["metadata"]["table-uuid"]); empty(delete(&test, &path, "w", &identity).await).await; - assert_eq!( - value(test.request(Method::GET, TABLE, "r", None).await, 200).await, - after - ); + value(test.request(Method::GET, TABLE, "r", None).await, 404).await; value( delete( &test, @@ -85,7 +82,7 @@ async fn drop_enforces_writer_and_replays_without_deleting_recreated_table() { &identity, ) .await, - 409, + 404, ) .await; test.finish().await; @@ -117,7 +114,7 @@ async fn rename_preserves_metadata_supports_cross_namespace_and_never_aliases_ol ) .await; value(test.post(TABLES, "w", None, &create()).await, 200).await; - empty(test.post(RENAME, "w", Some(&identity), &rename).await).await; + value(test.post(RENAME, "w", Some(&identity), &rename).await, 409).await; value( test.post("/v1/namespaces", "w", None, &json!({"namespace":["destination"]})) .await, diff --git a/app/crowdb-access-server/tests/iceberg_table_sdk_test.rs b/app/crowdb-access-server/tests/iceberg_table_sdk_test.rs index c5c8f5ab6..10838958e 100644 --- a/app/crowdb-access-server/tests/iceberg_table_sdk_test.rs +++ b/app/crowdb-access-server/tests/iceberg_table_sdk_test.rs @@ -66,7 +66,7 @@ async fn official_rest_catalog_reads_fixture_generations_without_fileio() { #[tokio::test] #[ignore = "requires Maven and pinned Apache Iceberg Java dependencies"] -async fn official_staged_catalog_preserves_exact_draft_credential_refresh_uri() { +async fn official_staged_catalog_preserves_complete_client_credential_refresh_uri() { let status = tokio::task::spawn_blocking(|| { let maven = std::env::var_os("CROWDB_ICEBERG_E2E_MVN").unwrap_or_else(|| "mvn".into()); std::process::Command::new("timeout") diff --git a/app/crowdb-access-server/tests/iceberg_table_write_test.rs b/app/crowdb-access-server/tests/iceberg_table_write_test.rs index 5319cbf90..4cb94c2b4 100644 --- a/app/crowdb-access-server/tests/iceberg_table_write_test.rs +++ b/app/crowdb-access-server/tests/iceberg_table_write_test.rs @@ -77,7 +77,7 @@ async fn direct_v1_to_v3_upgrade_requires_both_persisted_edges() { } #[tokio::test] -async fn create_and_update_replay_exact_results_and_enforce_independent_writer() { +async fn create_retries_resolve_by_name_and_updates_enforce_independent_writer() { let fixture = TestTableHttp::writable().await; for role in ["r", "m", "c"] { value(fixture.post(TABLES, role, None, &create(false)).await, 403).await; @@ -89,17 +89,27 @@ async fn create_and_update_replay_exact_results_and_enforce_independent_writer() ) .await; assert_eq!(created["metadata"]["schemas"][0]["fields"][0]["id"], 1); + value( + fixture.post(TABLES, "w", Some(&identity), &create(false)).await, + 409, + ) + .await; assert_eq!( - value( - fixture.post(TABLES, "w", Some(&identity), &create(false)).await, - 200 - ) - .await, + value(fixture.request(Method::GET, TABLE, "r", None).await, 200).await, created ); let mut changed = create(false); changed["name"] = json!("other"); - value(fixture.post(TABLES, "w", Some(&identity), &changed).await, 409).await; + let other = value(fixture.post(TABLES, "w", Some(&identity), &changed).await, 200).await; + assert_ne!(other["metadata"]["table-uuid"], created["metadata"]["table-uuid"]); + let operation: crowdb_access_iceberg::key::OperationId = identity.parse().unwrap(); + assert!( + crowdb_access_iceberg::commit::TableCreateJournal::new(fixture.store.clone()) + .load(fixture.context, operation) + .await + .unwrap() + .is_none() + ); let identity = key(); let update = json!({"requirements":[{"type":"assert-table-uuid","uuid":created["metadata"]["table-uuid"]}], "updates":[{"action":"set-properties","updates":{"owner":"writer"}}]}); @@ -179,8 +189,9 @@ async fn staged_create_is_invisible_until_standard_assert_create_commit() { let identity = key(); let committed = value(fixture.post(TABLE, "w", Some(&identity), &body).await, 200).await; assert_eq!(committed["metadata"]["table-uuid"], metadata["table-uuid"]); + value(fixture.post(TABLE, "w", Some(&identity), &body).await, 409).await; assert_eq!( - value(fixture.post(TABLE, "w", Some(&identity), &body).await, 200).await, + value(fixture.request(Method::GET, TABLE, "r", None).await, 200).await, committed ); assert_eq!( @@ -232,7 +243,7 @@ async fn concurrent_identical_commit_keys_cannot_rebase_or_finalize_a_transient_ } #[tokio::test] -async fn foreign_create_location_is_a_replayable_client_error() { +async fn foreign_create_location_fails_each_complete_request_without_publication() { let fixture = TestTableHttp::writable().await; let mut body = create(false); body["location"] = json!("s3://external/table"); diff --git a/app/crowdb-access-server/tests/iceberg_upload_fallback/failure.rs b/app/crowdb-access-server/tests/iceberg_upload_fallback/failure.rs new file mode 100644 index 000000000..c4fc74d41 --- /dev/null +++ b/app/crowdb-access-server/tests/iceberg_upload_fallback/failure.rs @@ -0,0 +1,242 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::{ + body::{body, owned, TestFrames, TestWriter}, + common::{admission, TARGET}, +}; +use crowdb_access_server::{ + iceberg::{write_iceberg_fallback_body_for_tests, FileS3ErrorCode}, + upload_flow::body_encoding::UploadBody, +}; +use hyper::{ + body::{Bytes, Frame}, + HeaderMap, +}; +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; + +#[tokio::test] +async fn pause_bounds_body_polling_and_keeps_feed_and_digest_groups() { + let frames = TestFrames::new((0..5).map(|value| Bytes::from(vec![value; TARGET])).collect()); + let polls = frames.polls.clone(); + let mut body = UploadBody::new(frames, &HeaderMap::new(), None, 64 << 20).unwrap(); + let admission = admission(64 << 20); + let mut writer = TestWriter { + pause: true, + ..Default::default() + }; + let (blocked, resume, calls) = ( + writer.blocked.clone(), + writer.resume.clone(), + writer.calls.clone(), + ); + let mut transfer = Box::pin(write_iceberg_fallback_body_for_tests( + &mut body, + &mut writer, + &admission, + 2, + None, + )); + assert!(futures::poll!(&mut transfer).is_pending()); + assert_eq!(calls.load(Ordering::Relaxed), 1); + assert!(blocked.load(Ordering::Relaxed)); + assert_eq!(polls.load(Ordering::Relaxed), 3); + assert!(futures::poll!(&mut transfer).is_pending()); + assert_eq!(polls.load(Ordering::Relaxed), 3); + blocked.store(false, Ordering::Relaxed); + resume.notify_one(); + let (_, _, metrics) = transfer.await.unwrap(); + assert_eq!(writer.buffers.len(), 5); + for (index, buffer) in writer.buffers.iter().enumerate() { + assert_eq!(buffer.len(), TARGET); + assert!(buffer.iter().all(|byte| usize::from(*byte) == index)); + } + assert_eq!(metrics.digest_enqueues, 5); + assert_eq!(metrics.writer_feeds, 5); + assert!(metrics.write_flow_pauses > 0); + assert_eq!(metrics.queued_buffers_peak, 2); +} + +#[tokio::test] +async fn cancelling_a_paused_flow_releases_all_body_digest_and_writer_owners() { + let live = Arc::new(AtomicUsize::new(0)); + let frames = TestFrames::new((0..5).map(|_| owned(TARGET, &live)).collect()); + let polls = frames.polls.clone(); + let mut body = UploadBody::new(frames, &HeaderMap::new(), None, 64 << 20).unwrap(); + let admission = admission(64 << 20); + let mut writer = TestWriter { + pause: true, + ..Default::default() + }; + let mut transfer = Box::pin(write_iceberg_fallback_body_for_tests( + &mut body, + &mut writer, + &admission, + 2, + None, + )); + assert!(futures::poll!(&mut transfer).is_pending()); + assert_eq!(polls.load(Ordering::Relaxed), 3); + drop(transfer); + drop(writer); + drop(body); + tokio::time::timeout(Duration::from_secs(2), async { + while live.load(Ordering::Relaxed) != 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(live.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn write_failure_stops_before_polling_another_target() { + let frames = TestFrames::new((0..5).map(|_| Bytes::from(vec![37; TARGET])).collect()); + let polls = frames.polls.clone(); + let mut body = UploadBody::new(frames, &HeaderMap::new(), None, 64 << 20).unwrap(); + let mut writer = TestWriter { + reject: true, + ..Default::default() + }; + assert_eq!( + write_iceberg_fallback_body_for_tests(&mut body, &mut writer, &admission(64 << 20), 1, None) + .await + .unwrap_err(), + FileS3ErrorCode::SlowDown + ); + assert_eq!(writer.calls.load(Ordering::Relaxed), 1); + assert_eq!(polls.load(Ordering::Relaxed), 1); + assert!(writer.buffers.is_empty()); +} + +#[tokio::test] +async fn admission_rejects_the_offending_frame_before_handoff() { + let frames = TestFrames::new(vec![Bytes::from(vec![37; TARGET]), Bytes::from(vec![83; TARGET])]); + let polls = frames.polls.clone(); + let mut body = UploadBody::new(frames, &HeaderMap::new(), None, 64 << 20).unwrap(); + let mut writer = TestWriter::default(); + assert_eq!( + write_iceberg_fallback_body_for_tests(&mut body, &mut writer, &admission(TARGET as u64), 2, None) + .await + .unwrap_err(), + FileS3ErrorCode::EntityTooLarge + ); + assert_eq!(polls.load(Ordering::Relaxed), 2); + assert!(writer + .buffers + .iter() + .all(|bytes| bytes.iter().all(|byte| *byte == 37))); +} + +#[tokio::test] +async fn failed_or_non_data_terminal_frames_do_not_flush_the_pending_tail() { + for trailers in [false, true] { + let mut frames = TestFrames::new(vec![Bytes::from_static(b"tail")]); + frames.frames.push_back(if trailers { + Ok(Frame::trailers(HeaderMap::new())) + } else { + Err(std::io::Error::other("injected terminal failure")) + }); + let mut body = UploadBody::new(frames, &HeaderMap::new(), None, 100).unwrap(); + let mut writer = TestWriter::default(); + assert_eq!( + write_iceberg_fallback_body_for_tests(&mut body, &mut writer, &admission(100), 2, None) + .await + .unwrap_err(), + FileS3ErrorCode::InvalidRequest + ); + assert!(writer.buffers.is_empty()); + } +} + +#[tokio::test] +async fn a_late_body_error_preserves_the_full_feed_without_flushing_its_short_tail() { + let full = Bytes::from(vec![37; TARGET]); + let mut frames = TestFrames::new(vec![full.clone(), Bytes::from_static(b"tail")]); + frames + .frames + .push_back(Err(std::io::Error::other("injected error after a full feed"))); + let mut body = UploadBody::new(frames, &HeaderMap::new(), None, 64 << 20).unwrap(); + let mut writer = TestWriter::default(); + assert_eq!( + write_iceberg_fallback_body_for_tests(&mut body, &mut writer, &admission(64 << 20), 1, None) + .await + .unwrap_err(), + FileS3ErrorCode::InvalidRequest + ); + assert_eq!(writer.buffers, vec![full.clone()]); + assert_eq!(writer.buffers[0].as_ptr(), full.as_ptr()); +} + +#[tokio::test] +async fn length_digest_and_crc_failures_keep_the_existing_error_contracts() { + for (declared, expected) in [ + (Some(2), FileS3ErrorCode::InvalidRequest), + (Some(4), FileS3ErrorCode::EntityTooLarge), + ] { + let mut body = body(vec![Bytes::from_static(b"abc")], declared); + let mut writer = TestWriter::default(); + assert_eq!( + write_iceberg_fallback_body_for_tests(&mut body, &mut writer, &admission(100), 2, None) + .await + .unwrap_err(), + expected + ); + assert!(writer.buffers.is_empty()); + } + for md5 in [false, true] { + let mut headers = HeaderMap::new(); + headers.insert("content-length", "3".parse().unwrap()); + if md5 { + headers.insert("content-md5", "1B2M2Y8AsgTpgAmY7PhCfg==".parse().unwrap()); + } + let mut body = UploadBody::new( + TestFrames::new(vec![Bytes::from_static(b"abc")]), + &headers, + None, + 100, + ) + .unwrap(); + let mut writer = TestWriter::default(); + assert_eq!( + write_iceberg_fallback_body_for_tests( + &mut body, + &mut writer, + &admission(100), + 2, + (!md5).then_some([0; 32]) + ) + .await + .unwrap_err(), + if md5 { + FileS3ErrorCode::BadDigest + } else { + FileS3ErrorCode::InvalidRequest + } + ); + assert_eq!(writer.buffers, vec![Bytes::from_static(b"abc")]); + } + let mut headers = HeaderMap::new(); + headers.insert("x-amz-checksum-crc32c", "4waSgw==".parse().unwrap()); + for valid in [false, true] { + let bytes = Bytes::from_static(if valid { b"123456789" } else { b"123456788" }); + let mut body = UploadBody::new(TestFrames::new(vec![bytes]), &headers, None, 100).unwrap(); + let mut writer = TestWriter::default(); + let result = + write_iceberg_fallback_body_for_tests(&mut body, &mut writer, &admission(100), 2, None).await; + if valid { + result.unwrap(); + assert_eq!(writer.buffers.len(), 1); + } else { + assert_eq!(result.unwrap_err(), FileS3ErrorCode::BadDigest); + assert!(writer.buffers.is_empty()); + } + } +} diff --git a/app/crowdb-access-server/tests/iceberg_upload_fallback_test.rs b/app/crowdb-access-server/tests/iceberg_upload_fallback_test.rs new file mode 100644 index 000000000..79eea8f20 --- /dev/null +++ b/app/crowdb-access-server/tests/iceberg_upload_fallback_test.rs @@ -0,0 +1,135 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/s3_fallback.rs"] +#[allow(dead_code)] // Shares Body, writer and owner fixtures without the S3 checksum wrapper. +mod body; +#[path = "common/iceberg_fallback.rs"] +mod common; +#[path = "iceberg_upload_fallback/failure.rs"] +mod failure; + +use common::{checked, TARGET}; +use hyper::body::Bytes; + +#[tokio::test] +async fn known_small_and_one_complete_target_retain_original_owners() { + for length in [1, 4096, TARGET - 1, TARGET] { + let original = Bytes::from(vec![37; length]); + let (writer, _) = checked(vec![original.clone()], Some(length)).await; + assert_eq!(writer.buffers.len(), 1); + assert_eq!( + writer.buffers[0].as_ptr(), + original.as_ptr(), + "full target was copied: {length}" + ); + } +} + +#[tokio::test] +async fn unknown_target_boundaries_copy_only_incomplete_tail_views() { + for length in [TARGET - 1, TARGET, TARGET + 1, 1024 * 1024] { + let original = Bytes::from(vec![41; length]); + let (writer, _) = checked(vec![original.clone()], None).await; + let expected = if length <= TARGET { + vec![length] + } else { + vec![TARGET, length - TARGET] + }; + assert_eq!( + writer.buffers.iter().map(Bytes::len).collect::>(), + expected + ); + if length >= TARGET { + assert_eq!(writer.buffers[0].as_ptr(), original.as_ptr()); + } + if length != TARGET { + let offset = if length < TARGET { 0 } else { TARGET }; + assert_ne!( + writer.buffers.last().unwrap().as_ptr(), + original.slice(offset..).as_ptr() + ); + } + } +} + +#[tokio::test] +async fn aligned_full_views_keep_their_owners_and_empty_inputs_add_no_feeds() { + let original = Bytes::from(vec![59; 4 * TARGET + 17]); + let frames = (0..original.len()) + .step_by(TARGET) + .map(|start| original.slice(start..(start + TARGET).min(original.len()))) + .collect(); + let (writer, _) = checked(frames, None).await; + assert_eq!( + writer.buffers.iter().map(Bytes::len).collect::>(), + [TARGET, TARGET, TARGET, TARGET, 17] + ); + for (index, buffer) in writer.buffers[..4].iter().enumerate() { + assert_eq!(buffer.as_ptr(), original.slice(index * TARGET..).as_ptr()); + } + assert_ne!(writer.buffers[4].as_ptr(), original.slice(4 * TARGET..).as_ptr()); + for declared in [None, Some(0)] { + let (writer, metrics) = checked(vec![Bytes::new(), Bytes::new()], declared).await; + assert!(writer.buffers.is_empty()); + assert_eq!(metrics.digest_enqueues, 0); + } +} + +#[tokio::test] +async fn a_four_mib_body_keeps_sixteen_frame_groups_across_non_aligned_input_windows() { + let original = Bytes::from(vec![73; (4 << 20) + 17]); + for declared in [None, Some(original.len())] { + let (writer, metrics) = checked(vec![original.clone()], declared).await; + assert_eq!( + writer.buffers.iter().map(Bytes::len).collect::>(), + [TARGET, TARGET, TARGET, TARGET, original.len() - 4 * TARGET] + ); + assert_eq!(writer.buffers[0].as_ptr(), original.as_ptr()); + for (index, buffer) in writer.buffers.iter().enumerate().skip(1) { + assert_ne!(buffer.as_ptr(), original.slice(index * TARGET..).as_ptr()); + } + assert_eq!(metrics.body_frames, 5); + assert_eq!(metrics.digest_enqueues, 5); + } +} + +#[tokio::test] +async fn fragmented_payload_is_copied_into_the_same_target_groups() { + let original = Bytes::from(vec![83; 2 * TARGET + 37]); + let frames = (0..original.len()) + .step_by(997) + .map(|start| original.slice(start..(start + 997).min(original.len()))) + .collect(); + let (writer, _) = checked(frames, None).await; + assert_eq!( + writer.buffers.iter().map(Bytes::len).collect::>(), + [TARGET, TARGET, 37] + ); + for (index, buffer) in writer.buffers.iter().enumerate() { + assert_ne!(buffer.as_ptr(), original.slice(index * TARGET..).as_ptr()); + } +} + +#[tokio::test] +async fn coalesced_fragments_do_not_prevent_a_later_aligned_owner_handoff() { + let first = Bytes::from(vec![17; TARGET - 17]); + let direct = Bytes::from(vec![59; TARGET]); + let (writer, _) = checked( + vec![ + first.clone(), + Bytes::from(vec![29; 34]), + Bytes::from(vec![37; TARGET - 17]), + Bytes::new(), + direct.clone(), + ], + None, + ) + .await; + assert_eq!( + writer.buffers.iter().map(Bytes::len).collect::>(), + [TARGET, TARGET, TARGET] + ); + assert_ne!(writer.buffers[0].as_ptr(), first.as_ptr()); + assert_eq!(writer.buffers[2].as_ptr(), direct.as_ptr()); +} diff --git a/app/crowdb-access-server/tests/rdma_datapath_test.rs b/app/crowdb-access-server/tests/rdma_datapath_test.rs new file mode 100644 index 000000000..bef27d17a --- /dev/null +++ b/app/crowdb-access-server/tests/rdma_datapath_test.rs @@ -0,0 +1,195 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/rdma_datapath/allocator.rs"] +mod allocator; +#[path = "common/rdma_datapath/http.rs"] +mod http; + +use std::sync::Arc; +use std::time::Duration; + +use crowdb_chunk_client::{ChunkIoClient, RoutedDiskWriter}; +use crowdb_diskio_client::{DiskId, DiskioClient, DiskioClientConfig, TestDiskRoute}; +use crowdb_protocol::chunkdb::rpc::ChunkType; +use crowdb_rpc_ffi::{RdmaConfig, RdmaWireLayout}; +use futures::StreamExt; +use sha2::{Digest, Sha256}; + +fn test_rdma_config(enabled: bool) -> RdmaConfig { + RdmaConfig { + enabled, + allow_tcp_fallback: false, + listen_enabled: false, + wire_layout: RdmaWireLayout::ControlData, + wire_slot_capacity: (1 << 20) + 1024, + sq_depth: 4, + send_batch_size: std::env::var("CROWDB_RDMA_SEND_BATCH_SIZE") + .map_or(1, |value| value.parse().unwrap()), + receive_batch_size: std::env::var("CROWDB_RDMA_RECEIVE_BATCH_SIZE") + .map_or(1, |value| value.parse().unwrap()), + aggregation_enabled: std::env::var("CROWDB_RDMA_AGGREGATION_ENABLED").as_deref() == Ok("1"), + rq_depth: 4, + posted_receive_target: 4, + cq_depth: 16, + send_arena_bytes: 4 << 20, + receive_arena_bytes: 4 * ((1 << 20) + 1024), + device: std::env::var("CROWDB_RDMA_DEVICE").unwrap_or_default(), + source_address: std::env::var("CROWDB_RDMA_SOURCE_ADDR").unwrap_or_default(), + ..RdmaConfig::default() + } +} + +fn test_client() -> Arc { + let endpoint = std::env::var("CROWDB_RDMA_DATAPATH_ENDPOINT").unwrap(); + let enabled = std::env::var("CROWDB_RDMA_DATAPATH_TRANSPORT").unwrap() == "rdma"; + Arc::new( + DiskioClient::connect_for_tests( + (1..=2) + .map(|high| TestDiskRoute { + disk_id: DiskId::new(high, 0), + rack_id: 1, + node_id: 1, + disk_group_id: 1, + instance_id: 1, + endpoint: endpoint.clone(), + }) + .collect(), + DiskioClientConfig { + rdma: Some(test_rdma_config(enabled)), + normal_connections_per_endpoint: 1, + priority_connections_per_endpoint: 1, + rpc_workers: 1, + ..DiskioClientConfig::default() + }, + ) + .unwrap(), + ) +} + +async fn test_download(http: &reqwest::Client, url: &str, bytes: &[u8]) -> (usize, Option, bool) { + let get = http.get(url).send().await.unwrap(); + assert_eq!(get.status(), 200); + let fault = std::env::var("CROWDB_RDMA_DATAPATH_FAULT").as_deref() == Ok("1"); + let mut download = get.bytes_stream(); + let mut downloaded = Vec::new(); + let mut download_error = None; + while let Some(result) = download.next().await { + match result { + Ok(data) => downloaded.extend_from_slice(&data), + Err(error) => { + download_error = Some(error.to_string()); + break; + } + } + } + if fault { + assert!(download_error.is_some()); + assert!(!downloaded.is_empty() && downloaded.len() < bytes.len()); + assert_eq!(downloaded, bytes[..downloaded.len()]); + } else { + assert!(download_error.is_none(), "{download_error:?}"); + assert_eq!(downloaded, bytes); + } + (downloaded.len(), download_error, fault) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires an owned DiskIO process; run tools/rdma/run-datapath.py"] +async fn http_chunkio_round_trip_uses_selected_transport_and_exact_framed_bytes() { + let client = test_client(); + let chunks = Arc::new(ChunkIoClient::from_parts( + Arc::new(allocator::TestAllocator::default()), + Arc::new(RoutedDiskWriter::from_client(Arc::clone(&client))), + ChunkType::S3, + )); + let handler = Arc::new(http::TestHttp::new(Arc::clone(&chunks))); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(crowdb_access_server::s3::serve( + listener, + handler.clone(), + async { + let _ = stopped.await; + }, + )); + let bytes: Vec<_> = (0..3 * (1 << 20) + 12_345) + .map(|index| u8::try_from(index % 251).unwrap()) + .collect(); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + let url = format!("http://{address}/datapath/object"); + let put = http + .put(&url) + .body(bytes.clone()) + .send() + .await + .unwrap_or_else(|error| { + panic!( + "HTTP PUT failed: {error}; allocator={:?}; transport={}", + handler.allocator.metrics_snapshot(), + String::from_utf8_lossy(&client.transport_status_json().unwrap()) + ); + }); + assert_eq!(put.status(), 200); + let etag = put.headers()["etag"].to_str().unwrap().to_owned(); + let (downloaded_bytes, download_error, fault) = test_download(&http, &url, &bytes).await; + let locations = handler.locations.load_full(); + assert!( + locations.len() >= 2, + "corpus must cross chunk and message boundaries" + ); + let mut cursor = 0; + for location in locations.iter() { + assert_eq!(location.logical_offset, cursor); + cursor += location.logical_length; + } + assert_eq!(cursor, u64::try_from(bytes.len()).unwrap()); + let status: serde_json::Value = serde_json::from_slice(&client.transport_status_json().unwrap()).unwrap(); + if std::env::var("CROWDB_RDMA_DATAPATH_TRANSPORT").unwrap() == "rdma" { + let rdma = &status["rdma"]; + assert!( + rdma["direct_payload_bytes"].as_u64().unwrap() >= 2 * cursor, + "{rdma}" + ); + for field in [ + "copied_payload_bytes", + "fallback_count", + "cq_errors", + "protocol_errors", + "send_errors", + "receive_post_errors", + ] { + assert_eq!(rdma[field], 0, "{field}: {status}"); + } + } else { + assert!(status["rdma"].is_null()); + } + assert_eq!(client.status().retries, 0); + let metrics = handler.allocator.metrics_snapshot(); + assert!(metrics.peak_retained_bytes <= 2 << 20); + assert!(metrics.allocations >= 4); + let result = serde_json::json!({ + "effective_uid": std::fs::read_to_string("/proc/self/status").unwrap().lines() + .find_map(|line| line.strip_prefix("Uid:")).unwrap().split_whitespace().nth(1).unwrap().parse::().unwrap(), + "payload_bytes": bytes.len(), "sha256": format!("{:x}", Sha256::digest(&bytes)), + "etag": etag, "locations": *locations, "transport": status, + "http_native": {"direct_bytes": metrics.direct_bytes, "prefix_copy_bytes": metrics.prefix_copy_bytes, + "peak_retained_bytes": metrics.peak_retained_bytes, "allocations": metrics.allocations, + "backpressure_events": metrics.backpressure_events}, + "errors": u64::from(fault), "expected_errors": u64::from(fault), "verification_errors": 0, + "downloaded_bytes": downloaded_bytes, "download_error": download_error, + "topology": std::env::var("CROWDB_RDMA_DATAPATH_TOPOLOGY").unwrap(), + }); + std::fs::write( + std::env::var("CROWDB_RDMA_DATAPATH_RESULT").unwrap(), + serde_json::to_vec_pretty(&result).unwrap(), + ) + .unwrap(); + shutdown.send(()).unwrap(); + server.await.unwrap().unwrap(); + chunks.shutdown_small_writes().await.unwrap(); +} diff --git a/app/crowdb-access-server/tests/s3_upload_fallback_test.rs b/app/crowdb-access-server/tests/s3_upload_fallback_test.rs new file mode 100644 index 000000000..7f53c0259 --- /dev/null +++ b/app/crowdb-access-server/tests/s3_upload_fallback_test.rs @@ -0,0 +1,257 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/s3_fallback.rs"] +mod common; +use common::{body, checked, owned, TestFrames, TestWriter, TARGET}; +use crowdb_access_s3::streaming::{PutErrorCode, PutOutcome}; +use crowdb_access_server::s3::test_support::write_fallback_body_for_tests; +use crowdb_access_server::upload_flow::body_encoding::UploadBody; +use hyper::body::{Bytes, Frame}; +use hyper::HeaderMap; +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; + +#[tokio::test] +async fn one_small_body_frame_retains_its_original_owner() { + let original = Bytes::from(vec![37; 4096]); + let writer = checked(vec![original.clone()], Some(original.len())).await; + assert_eq!(writer.buffers.len(), 1); + assert_eq!(writer.buffers[0], original); + assert_eq!( + writer.buffers[0].as_ptr(), + original.as_ptr(), + "the full target was copied" + ); +} + +#[tokio::test] +async fn exact_targets_preserve_source_views_and_a_short_unknown_tail_still_coalesces() { + for declared in [None, Some(2 * TARGET + 37)] { + let original = Bytes::from(vec![83; 2 * TARGET + 37]); + let writer = checked(vec![original.clone()], declared).await; + assert_eq!( + writer.buffers.iter().map(Bytes::len).collect::>(), + [TARGET, TARGET, 37] + ); + assert_eq!(writer.buffers[0].as_ptr(), original.as_ptr()); + assert_eq!(writer.buffers[1].as_ptr(), original.slice(TARGET..).as_ptr()); + assert_ne!(writer.buffers[2].as_ptr(), original.slice(2 * TARGET..).as_ptr()); + } +} + +#[tokio::test] +async fn tiny_empty_and_known_small_inputs_keep_their_original_feed_units() { + for length in [0, 1, 4096, 64 * 1024, TARGET] { + let original = Bytes::from(vec![73; length]); + let writer = checked(vec![Bytes::new(), original.clone(), Bytes::new()], Some(length)).await; + if length == 0 { + assert!(writer.buffers.is_empty()); + } else { + assert_eq!(writer.buffers.len(), 1); + assert_eq!(writer.buffers[0].as_ptr(), original.as_ptr()); + } + } +} + +#[tokio::test] +async fn fragmented_input_keeps_target_grouping_instead_of_feeding_tiny_fragments() { + let original = Bytes::from(vec![41; 2 * TARGET + 37]); + let fragments: Vec<_> = (0..original.len()) + .step_by(997) + .map(|start| original.slice(start..(start + 997).min(original.len()))) + .collect(); + let writer = checked(fragments, None).await; + assert_eq!( + writer.buffers.iter().map(Bytes::len).collect::>(), + [TARGET, TARGET, 37] + ); + assert_ne!(writer.buffers[0].as_ptr(), original.as_ptr()); + assert_ne!(writer.buffers[1].as_ptr(), original.slice(TARGET..).as_ptr()); +} + +#[tokio::test] +async fn unaligned_fragments_coalesce_before_a_later_full_owner_is_forwarded() { + let first = Bytes::from(vec![17; TARGET - 17]); + let middle = Bytes::from(vec![29; 34]); + let third = Bytes::from(vec![37; TARGET - 17]); + let direct = Bytes::from(vec![59; TARGET]); + let writer = checked(vec![first.clone(), middle, third, direct.clone()], None).await; + assert_eq!( + writer.buffers.iter().map(Bytes::len).collect::>(), + [TARGET, TARGET, TARGET] + ); + assert_ne!(writer.buffers[0].as_ptr(), first.as_ptr()); + assert_eq!(writer.buffers[2].as_ptr(), direct.as_ptr()); +} + +#[tokio::test] +async fn pause_retains_the_same_bounded_body_polling_and_resumes_without_reordering() { + let frames = TestFrames::new((0..5).map(|n| Bytes::from(vec![n; TARGET])).collect()); + let polls = frames.polls.clone(); + let mut body = UploadBody::new(frames, &HeaderMap::new(), None, 64 << 20).unwrap(); + let mut writer = TestWriter { + pause: true, + ..Default::default() + }; + let blocked = writer.blocked.clone(); + let resume = writer.resume.clone(); + let calls = writer.calls.clone(); + let mut transfer = Box::pin(write_fallback_body_for_tests( + &mut body, + &mut writer, + 2, + None, + None, + None, + )); + assert!(futures::poll!(&mut transfer).is_pending()); + assert_eq!(calls.load(Ordering::Relaxed), 1); + assert!(blocked.load(Ordering::Relaxed)); + assert_eq!(polls.load(Ordering::Relaxed), 3); + assert!(futures::poll!(&mut transfer).is_pending()); + assert_eq!(polls.load(Ordering::Relaxed), 3); + blocked.store(false, Ordering::Relaxed); + resume.notify_one(); + transfer.await.unwrap(); + assert_eq!(writer.buffers.len(), 5); + for (n, buffer) in writer.buffers.iter().enumerate() { + assert_eq!(buffer.len(), TARGET); + assert!(buffer.iter().all(|byte| usize::from(*byte) == n)); + } +} + +#[tokio::test] +async fn cancelling_a_paused_handoff_releases_queued_digest_and_source_owners() { + let live = Arc::new(AtomicUsize::new(0)); + let frames = TestFrames::new((0..5).map(|_| owned(TARGET, &live)).collect()); + let polls = frames.polls.clone(); + let mut body = UploadBody::new(frames, &HeaderMap::new(), None, 64 << 20).unwrap(); + let mut writer = TestWriter { + pause: true, + ..Default::default() + }; + let mut transfer = Box::pin(write_fallback_body_for_tests( + &mut body, + &mut writer, + 2, + None, + None, + None, + )); + assert!(futures::poll!(&mut transfer).is_pending()); + assert_eq!(polls.load(Ordering::Relaxed), 3); + drop(transfer); + drop(writer); + drop(body); + tokio::time::timeout(Duration::from_secs(2), async { + while live.load(Ordering::Relaxed) != 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(live.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn writer_failure_stops_body_polling_at_the_first_full_target() { + let frames = TestFrames::new((0..5).map(|_| Bytes::from(vec![11; TARGET])).collect()); + let polls = frames.polls.clone(); + let mut body = UploadBody::new(frames, &HeaderMap::new(), None, 64 << 20).unwrap(); + let mut writer = TestWriter { + reject: true, + ..Default::default() + }; + assert!(matches!( + write_fallback_body_for_tests(&mut body, &mut writer, 1, None, None, None).await, + Err(PutOutcome::Error { + code: PutErrorCode::ChunkWrite, + .. + }) + )); + assert_eq!(writer.calls.load(Ordering::Relaxed), 1); + assert_eq!(polls.load(Ordering::Relaxed), 1); + assert!(writer.buffers.is_empty()); +} + +#[tokio::test] +async fn body_failure_and_non_data_frames_do_not_flush_an_incomplete_tail() { + for trailer in [false, true] { + let mut frames = TestFrames::new(vec![Bytes::from_static(b"tail")]); + frames.frames.push_back(if trailer { + Ok(Frame::trailers(HeaderMap::new())) + } else { + Err(std::io::Error::other("injected body failure")) + }); + let mut body = UploadBody::new(frames, &HeaderMap::new(), None, 64 << 20).unwrap(); + let mut writer = TestWriter::default(); + assert!(matches!( + write_fallback_body_for_tests(&mut body, &mut writer, 2, None, None, None).await, + Err(PutOutcome::Error { + code: PutErrorCode::BodyRead, + .. + }) + )); + assert!(writer.buffers.is_empty()); + } +} + +#[tokio::test] +async fn length_mismatches_and_actual_md5_sha256_crc_fail_closed() { + for declared in [Some(2), Some(4)] { + let mut body = body(vec![Bytes::from_static(b"abc")], declared); + let mut writer = TestWriter::default(); + assert!(matches!( + write_fallback_body_for_tests(&mut body, &mut writer, 2, None, None, None).await, + Err(PutOutcome::Error { + code: PutErrorCode::BodyRead, + .. + }) + )); + assert!(writer.buffers.is_empty()); + } + for md5 in [false, true] { + let mut body = body(vec![Bytes::from_static(b"abc")], Some(3)); + let mut writer = TestWriter::default(); + let sha = "0".repeat(64); + let result = write_fallback_body_for_tests( + &mut body, + &mut writer, + 2, + md5.then_some("1B2M2Y8AsgTpgAmY7PhCfg=="), + (!md5).then_some(sha.as_str()), + None, + ) + .await; + assert!(matches!(result, Err(PutOutcome::Error { code, .. }) + if code == if md5 { PutErrorCode::BadDigest } else { PutErrorCode::PayloadMismatch })); + assert_eq!(writer.buffers, vec![Bytes::from_static(b"abc")]); + } + let mut headers = HeaderMap::new(); + headers.insert("x-amz-checksum-crc32c", "4waSgw==".parse().unwrap()); + for valid in [false, true] { + let bytes = Bytes::from_static(if valid { b"123456789" } else { b"123456788" }); + let mut body = UploadBody::new(TestFrames::new(vec![bytes]), &headers, None, 100).unwrap(); + let mut writer = TestWriter::default(); + let result = write_fallback_body_for_tests(&mut body, &mut writer, 2, None, None, None).await; + if valid { + assert_eq!(result.unwrap().0, "25f9e794323b453885f5181f1b624d0b"); + assert_eq!(writer.buffers.len(), 1); + } else { + assert!(matches!( + result, + Err(PutOutcome::Error { + code: PutErrorCode::BadDigest, + .. + }) + )); + assert!(writer.buffers.is_empty()); + } + } +} diff --git a/app/crowdb-chunk-kv-server/Cargo.toml b/app/crowdb-chunk-kv-server/Cargo.toml index d441698d8..706a65d1b 100644 --- a/app/crowdb-chunk-kv-server/Cargo.toml +++ b/app/crowdb-chunk-kv-server/Cargo.toml @@ -36,6 +36,8 @@ tracing = { workspace = true } [dev-dependencies] crowdb-test-harness = { path = "../../lib/crowdb-test-harness" } +crowdb-chunk-kv-client = { path = "../../lib/crowdb-chunk-kv-client" } +sha2 = "0.10" crowdb-kv = { path = "../../lib/crowdb-kv" } crowdb-chunk-kv-server = { path = ".", features = ["test-util"] } crowdb-chunk-kv = { path = "../../lib/crowdb-chunk-kv", features = ["test-util"] } diff --git a/app/crowdb-chunk-kv-server/src/config.rs b/app/crowdb-chunk-kv-server/src/config.rs index ae19dd380..ad44e2e38 100644 --- a/app/crowdb-chunk-kv-server/src/config.rs +++ b/app/crowdb-chunk-kv-server/src/config.rs @@ -39,6 +39,7 @@ pub struct ChunkKvServerConfig { pub max_split_catchup_lag_records: u64, pub shutdown_drain_timeout_ms: u64, pub rpc_workers: u32, + pub rpc_rdma: Option, pub storage: StorageConfig, pub bootstrap_partition: Option, pub monitor: DomainMonitorDescriptor, @@ -60,6 +61,7 @@ impl Default for ChunkKvServerConfig { max_split_catchup_lag_records: 1_024, shutdown_drain_timeout_ms: 30_000, rpc_workers: 2, + rpc_rdma: None, storage: StorageConfig::default(), bootstrap_partition: None, monitor: default_monitor(), @@ -130,6 +132,15 @@ impl ChunkKvServerConfig { )); } self.storage.validate()?; + if self + .rpc_rdma + .as_ref() + .is_some_and(|config| config.enabled && config.control_data_capacity().is_none()) + { + return Err(ConfigError::Invalid( + "rpc_rdma requires control_data with a supported data capacity".into(), + )); + } if let Some(bootstrap) = &self.bootstrap_partition { bootstrap.validate()?; } diff --git a/app/crowdb-chunk-kv-server/src/control_store.rs b/app/crowdb-chunk-kv-server/src/control_store.rs index 6b25ad1f8..0bb88108d 100644 --- a/app/crowdb-chunk-kv-server/src/control_store.rs +++ b/app/crowdb-chunk-kv-server/src/control_store.rs @@ -141,7 +141,7 @@ impl Group0Kv for CrowdbKvClient { fn map_client_error(error: KvClientError) -> Group0KvError { match error { - KvClientError::CasFailed { current_revision } => Group0KvError::CasFailed { current_revision }, + KvClientError::CasFailed { current_revision, .. } => Group0KvError::CasFailed { current_revision }, KvClientError::OutcomeUnknown => Group0KvError::OutcomeUnknown, other => Group0KvError::Unavailable(other.to_string()), } diff --git a/app/crowdb-chunk-kv-server/src/main.rs b/app/crowdb-chunk-kv-server/src/main.rs index bfed44c12..7a2eeb751 100644 --- a/app/crowdb-chunk-kv-server/src/main.rs +++ b/app/crowdb-chunk-kv-server/src/main.rs @@ -453,12 +453,26 @@ async fn main() -> std::process::ExitCode { let materialization_interval = std::time::Duration::from_millis(config.catalog_refresh_interval_ms); let materialization_task = tokio::spawn(async move { let mut interval = tokio::time::interval(materialization_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); interval.tick().await; + let mut pending = false; loop { - interval.tick().await; - if let Err(error) = materialization_service.materialize_split_overlays().await { - warn!(%error, "chunk KV split-overlay materialization pass failed"); + tokio::select! { + biased; + _ = interval.tick() => { + if let Err(error) = materialization_service.materialize_split_overlays().await { + warn!(%error, "chunk KV split-overlay materialization pass failed"); + } + } + () = std::future::ready(()), if pending => tokio::task::yield_now().await, } + pending = match materialization_service.maintain_serving_metadata().await { + Ok(pending) => pending, + Err(error) => { + warn!(%error, "chunk KV extent metadata maintenance failed; retaining watermark for next pass"); + false + } + }; } }); let heartbeat_service = Arc::clone(&service); @@ -506,11 +520,13 @@ async fn main() -> std::process::ExitCode { } }); - let rpc_server = Arc::new(crowdb_rpc_ffi::RpcServer::with_engines( - None, - 1, - config.rpc_workers, - )); + let rpc_server = match ChunkKvRpcService::create_server(config.rpc_workers, config.rpc_rdma.as_ref()) { + Ok(server) => server, + Err(error) => { + error!(%error, "data RPC transport configuration failed"); + return std::process::ExitCode::FAILURE; + } + }; if let Err(error) = rpc_server.listen(&rpc_addr.ip().to_string(), i32::from(rpc_addr.port())) { error!(%rpc_addr, %error, "data RPC bind failed"); return std::process::ExitCode::FAILURE; diff --git a/app/crowdb-chunk-kv-server/src/rpc.rs b/app/crowdb-chunk-kv-server/src/rpc.rs index 8fab5ade7..da3f7ef17 100644 --- a/app/crowdb-chunk-kv-server/src/rpc.rs +++ b/app/crowdb-chunk-kv-server/src/rpc.rs @@ -18,13 +18,18 @@ use crowdb_protocol::chunk_kv_group_wire::{ use crowdb_protocol::chunk_kv_ordered_wire::{ decode_bounded_scan_request, decode_scan_request, decode_seek_request, }; -use crowdb_protocol::chunk_kv_wire::{decode_point_request, encode_point_response}; +use crowdb_protocol::chunk_kv_wire::decode_point_request; use crowdb_protocol::fb::FBMsgType; use crowdb_rpc_ffi::{Buffer, RpcServer, ServerRequest}; use tokio::runtime::Handle; use crate::ChunkKvService; +mod response; +mod transport; + +use response::{submit_group_response, submit_response}; + pub struct ChunkKvRpcService { service: Arc, runtime: Handle, @@ -74,7 +79,6 @@ impl ChunkKvRpcService { let server = Arc::clone(server); self.runtime.spawn(async move { let rpc_request_id = request.request_id; - let rpc_create_nano = request.rpc_create_nano; let response = match decode_point_request(request.control()) { Ok(envelope) if envelope.rpc_request_id == rpc_request_id => { service @@ -84,13 +88,7 @@ impl ChunkKvRpcService { Ok(_) => invalid_response("RPC frame and control request IDs differ"), Err(error) => invalid_response(&error.to_string()), }; - submit_response( - &server, - request.conn_handle, - rpc_request_id, - rpc_create_nano, - &response, - ); + submit_response(&server, &request, &response); }); } @@ -99,7 +97,6 @@ impl ChunkKvRpcService { let server = Arc::clone(server); self.runtime.spawn(async move { let rpc_request_id = request.request_id; - let rpc_create_nano = request.rpc_create_nano; let response = match decode_seek_request(request.control()) { Ok(envelope) if envelope.rpc_request_id == rpc_request_id => { service @@ -109,13 +106,7 @@ impl ChunkKvRpcService { Ok(_) => invalid_response("RPC frame and control request IDs differ"), Err(error) => invalid_response(&error.to_string()), }; - submit_response( - &server, - request.conn_handle, - rpc_request_id, - rpc_create_nano, - &response, - ); + submit_response(&server, &request, &response); }); } @@ -124,7 +115,6 @@ impl ChunkKvRpcService { let server = Arc::clone(server); self.runtime.spawn(async move { let rpc_request_id = request.request_id; - let rpc_create_nano = request.rpc_create_nano; let response = match decode_scan_request(request.control()) { Ok(envelope) if envelope.rpc_request_id == rpc_request_id => { service @@ -134,13 +124,7 @@ impl ChunkKvRpcService { Ok(_) => invalid_response("RPC frame and control request IDs differ"), Err(error) => invalid_response(&error.to_string()), }; - submit_response( - &server, - request.conn_handle, - rpc_request_id, - rpc_create_nano, - &response, - ); + submit_response(&server, &request, &response); }); } @@ -149,7 +133,6 @@ impl ChunkKvRpcService { let server = Arc::clone(server); self.runtime.spawn(async move { let id = request.request_id; - let created = request.rpc_create_nano; let response = match decode_bounded_scan_request(request.control()) { Ok((envelope, budget)) if envelope.rpc_request_id == id => { service @@ -159,7 +142,7 @@ impl ChunkKvRpcService { Ok(_) => invalid_response("RPC frame and control request IDs differ"), Err(error) => invalid_response(&error.to_string()), }; - submit_response(&server, request.conn_handle, id, created, &response); + submit_response(&server, &request, &response); }); } @@ -178,14 +161,17 @@ impl ChunkKvRpcService { Ok(_) => invalid_multi_get("RPC frame and control request IDs differ"), Err(error) => invalid_multi_get(&error.to_string()), }; - if let Ok(bytes) = encode_multi_get_response(id, create_nano, &response) { - submit_group_response( + match encode_multi_get_response(id, create_nano, &response) { + Ok(bytes) => submit_group_response( &server, - request.conn_handle, - id, + &request, Buffer::from_vec(bytes), FBMsgType::EChunkKvMultiGetResponse.0 as u16, - ); + ), + Err(error) => { + tracing::error!(request_id = id, %error, "Chunk-KV multi-get response encoding failed; closing connection"); + request.close_connection(); + } } }); } @@ -205,14 +191,17 @@ impl ChunkKvRpcService { Ok(_) => invalid_batch("RPC frame and control request IDs differ"), Err(error) => invalid_batch(&error.to_string()), }; - if let Ok(bytes) = encode_batch_mutation_response(id, create_nano, &response) { - submit_group_response( + match encode_batch_mutation_response(id, create_nano, &response) { + Ok(bytes) => submit_group_response( &server, - request.conn_handle, - id, + &request, Buffer::from_vec(bytes), FBMsgType::EChunkKvBatchMutationResponse.0 as u16, - ); + ), + Err(error) => { + tracing::error!(request_id = id, %error, "Chunk-KV batch response encoding failed; closing connection"); + request.close_connection(); + } } }); } @@ -256,38 +245,6 @@ fn invalid_response(message: &str) -> ChunkKvResponse { } } -fn submit_response( - server: &RpcServer, - conn_handle: *mut std::ffi::c_void, - rpc_request_id: u64, - rpc_create_nano: u64, - response: &ChunkKvResponse, -) { - let (bytes, offset) = encode_point_response(rpc_request_id, rpc_create_nano, response); - let buffer = Buffer::from_vec_offset(bytes, offset); - unsafe { - let _ = server.submit_response_buffer( - conn_handle, - buffer, - None, - FBMsgType::EChunkKvPointResponse.0 as u16, - rpc_request_id, - ); - } -} - -fn submit_group_response( - server: &RpcServer, - conn_handle: *mut std::ffi::c_void, - rpc_request_id: u64, - buffer: Buffer, - message_type: u16, -) { - unsafe { - let _ = server.submit_response_buffer(conn_handle, buffer, None, message_type, rpc_request_id); - } -} - fn wall_time_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/app/crowdb-chunk-kv-server/src/rpc/response.rs b/app/crowdb-chunk-kv-server/src/rpc/response.rs new file mode 100644 index 000000000..1d6ed7fbc --- /dev/null +++ b/app/crowdb-chunk-kv-server/src/rpc/response.rs @@ -0,0 +1,40 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use crowdb_protocol::chunk_kv::ChunkKvResponse; +use crowdb_protocol::chunk_kv_wire::encode_point_response; +use crowdb_protocol::fb::FBMsgType; +use crowdb_rpc_ffi::{Buffer, RpcServer, ServerRequest}; + +pub(super) fn submit_response(server: &RpcServer, request: &ServerRequest, response: &ChunkKvResponse) { + let (bytes, offset) = encode_point_response(request.request_id, request.rpc_create_nano, response); + submit_group_response( + server, + request, + Buffer::from_vec_offset(bytes, offset), + FBMsgType::EChunkKvPointResponse.0 as u16, + ); +} + +pub(super) fn submit_group_response( + server: &RpcServer, + request: &ServerRequest, + buffer: Buffer, + message_type: u16, +) { + // SAFETY: request owns the native Frame and its retained connection; + // the borrowed server and response owner live through this submission. + let result = unsafe { + server.submit_response_buffer( + request.conn_handle, + buffer, + None, + message_type, + request.request_id, + ) + }; + if let Err(error) = result { + tracing::error!(request_id = request.request_id, %error, "Chunk-KV response submission failed; closing connection"); + request.close_connection(); + } +} diff --git a/app/crowdb-chunk-kv-server/src/rpc/transport.rs b/app/crowdb-chunk-kv-server/src/rpc/transport.rs new file mode 100644 index 000000000..056ee2f7b --- /dev/null +++ b/app/crowdb-chunk-kv-server/src/rpc/transport.rs @@ -0,0 +1,30 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use std::sync::Arc; + +use crowdb_rpc_ffi::{RdmaConfig, RdmaWireLayout, RpcError, RpcServer}; + +use super::ChunkKvRpcService; + +impl ChunkKvRpcService { + /// Create the service's aggregate-capacity listener before binding it. + /// + /// # Errors + /// Rejects invalid classes/queues or unavailable explicitly enabled RDMA. + pub fn create_server(workers: u32, rdma: Option<&RdmaConfig>) -> Result, RpcError> { + let server = if let Some(rdma) = rdma { + if rdma.enabled && rdma.control_data_capacity().is_none() { + return Err(RpcError::InvalidArg); + } + let server = RpcServer::with_engines(None, 1, workers.max(1)); + let mut native = rdma.clone(); + native.listen_enabled = native.enabled; + server.configure_rdma(&native)?; + server + } else { + RpcServer::with_rdma_layout(None, 1, workers.max(1), RdmaWireLayout::ControlData, 262_144)? + }; + Ok(Arc::new(server)) + } +} diff --git a/app/crowdb-chunk-kv-server/src/server.rs b/app/crowdb-chunk-kv-server/src/server.rs index b96acd103..957c8c332 100644 --- a/app/crowdb-chunk-kv-server/src/server.rs +++ b/app/crowdb-chunk-kv-server/src/server.rs @@ -35,6 +35,7 @@ mod drain; mod group; use assignments::{local_partition_for_entry, partition_matches_entry}; mod load_sampling; +mod maintenance; mod observation; mod prepared_transfer; mod readiness; @@ -493,59 +494,6 @@ impl ChunkKvService { std::future::ready(Ok(observation)) } - /// Runs one bounded ownership-materialization pass for every local split child. - /// - /// Returns child identities whose own checkpoint no longer depends on the - /// split-parent suffix. - /// - /// # Errors - /// - /// Returns the first materialization or checkpoint error. - pub async fn materialize_split_overlays(&self) -> Result, ChunkKvError> { - let catalog = self.catalog.load_full(); - let partitions = self.partitions.load_full(); - let mut completed = Vec::new(); - for entry in &catalog.entries { - if entry.owner.instance_id != self.instance_id - || entry.state != ChunkKvRangeCatalogPartitionState::Serving - || entry.artifact.tail_overlay.is_none() - || self - .independently_recoverable - .load() - .contains(&(entry.partition_id, entry.owner_epoch)) - { - continue; - } - let Some(partition) = partitions.get(&entry.partition_id) else { - continue; - }; - if partition.snapshot().lifecycle != crowdb_chunk_kv::PartitionLifecycle::Serving { - continue; - } - if !partition - .materialize_split_ownership(entry.owner_epoch) - .await? - .complete - { - continue; - } - let checkpoint = partition.checkpoint(entry.owner_epoch).await?; - let Some(overlay) = entry.artifact.tail_overlay.as_ref() else { - continue; - }; - if checkpoint.applied_seq < overlay.cutover_seq { - return Err(ChunkKvError::ApplyStateUnknown); - } - self.independently_recoverable.rcu(|current| { - let mut next = (**current).clone(); - next.insert((entry.partition_id, entry.owner_epoch)); - Arc::new(next) - }); - completed.push(entry.partition_id); - } - Ok(completed) - } - /// Validates and atomically activates a newer complete catalog snapshot. /// /// # Errors diff --git a/app/crowdb-chunk-kv-server/src/server/maintenance.rs b/app/crowdb-chunk-kv-server/src/server/maintenance.rs new file mode 100644 index 000000000..840b24eea --- /dev/null +++ b/app/crowdb-chunk-kv-server/src/server/maintenance.rs @@ -0,0 +1,108 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +//! Catalog-fenced extent metadata and split-overlay maintenance. + +use super::{ + local_partition_for_entry, Arc, ChunkKvError, ChunkKvRangeCatalogPartitionState, ChunkKvService, Id128, +}; + +impl ChunkKvService { + /// Runs one bounded ownership-materialization pass for every local split child. + /// + /// Returns child identities whose own checkpoint no longer depends on the + /// split-parent suffix. + /// + /// # Errors + /// + /// Returns the first materialization or checkpoint error. + pub async fn materialize_split_overlays(&self) -> Result, ChunkKvError> { + let catalog = self.catalog.load_full(); + let partitions = self.partitions.load_full(); + let mut completed = Vec::new(); + for entry in &catalog.entries { + if entry.owner.instance_id != self.instance_id + || entry.state != ChunkKvRangeCatalogPartitionState::Serving + || entry.artifact.tail_overlay.is_none() + || self + .independently_recoverable + .load() + .contains(&(entry.partition_id, entry.owner_epoch)) + { + continue; + } + let Some(partition) = partitions.get(&entry.partition_id) else { + continue; + }; + if partition.snapshot().lifecycle != crowdb_chunk_kv::PartitionLifecycle::Serving { + continue; + } + if !partition + .materialize_split_ownership(entry.owner_epoch) + .await? + .complete + { + continue; + } + let checkpoint = partition.checkpoint(entry.owner_epoch).await?; + let Some(overlay) = entry.artifact.tail_overlay.as_ref() else { + continue; + }; + if checkpoint.applied_seq < overlay.cutover_seq { + return Err(ChunkKvError::ApplyStateUnknown); + } + self.independently_recoverable.rcu(|current| { + let mut next = (**current).clone(); + next.insert((entry.partition_id, entry.owner_epoch)); + Arc::new(next) + }); + completed.push(entry.partition_id); + } + Ok(completed) + } + + /// Reclaims a bounded extent metadata batch for each independently recoverable owner. + /// + /// Returns true while any owner has a metadata backlog. Catalog transitions, + /// overlays, expired grants and mismatched local handles cannot authorize GC. + /// + /// # Errors + /// Returns a storage maintenance failure. + pub async fn maintain_serving_metadata(&self) -> Result { + let catalog = self.catalog.load_full(); + let partitions = self.partitions.load_full(); + let mut pending = false; + let mut first_error = None; + for entry in &catalog.entries { + if entry.owner.instance_id != self.instance_id + || entry.state != ChunkKvRangeCatalogPartitionState::Serving + || entry.artifact.tail_overlay.is_some() + || entry.transition_id.is_some() + || self + .authority + .authorize( + catalog.generation, + entry.partition_id, + entry.owner_epoch, + self.monotonic_ms(), + ) + .is_err() + { + continue; + } + let Some(partition) = partitions + .get(&entry.partition_id) + .and_then(|partition| local_partition_for_entry(partition, entry)) + else { + continue; + }; + match partition.maintain_serving_metadata(entry.owner_epoch).await { + Ok(backlog) => pending |= backlog, + Err(error) => { + first_error.get_or_insert(error); + } + } + } + first_error.map_or(Ok(pending), Err) + } +} diff --git a/app/crowdb-chunk-kv-server/src/storage.rs b/app/crowdb-chunk-kv-server/src/storage.rs index ccd64353a..239539f99 100644 --- a/app/crowdb-chunk-kv-server/src/storage.rs +++ b/app/crowdb-chunk-kv-server/src/storage.rs @@ -642,17 +642,14 @@ impl ChunkKvStorage { ) .await?; let checkpoint = source - .checkpoint(transition.source_epoch) - .await - .map_err(|error| storage_plan_error(&error.to_string()))?; - source - .retain_generation_pin( + .checkpoint_with_pin( + transition.source_epoch, crowdb_chunk_kv::TransitionId { high: transition.transition_id.high, low: transition.transition_id.low, }, - checkpoint.root_manifest_generation, ) + .await .map_err(|error| storage_plan_error(&error.to_string()))?; let frontier = source .capture_journal_frontier(transition.source_epoch) diff --git a/app/crowdb-chunk-kv-server/tests/common/rdma_chunk_kv.rs b/app/crowdb-chunk-kv-server/tests/common/rdma_chunk_kv.rs new file mode 100644 index 000000000..efded3d34 --- /dev/null +++ b/app/crowdb-chunk-kv-server/tests/common/rdma_chunk_kv.rs @@ -0,0 +1,210 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use crowdb_chunk_kv_client::{ChunkKvRpcTransport, ChunkKvRpcTransportConfig, ChunkKvTransport}; +use crowdb_chunk_kv_server::ChunkKvRpcService; +use crowdb_rpc_ffi::{RdmaConfig, RpcServer}; +use serde_json::{json, Value}; + +#[path = "rdma_chunk_kv/corpus.rs"] +mod corpus; +#[path = "rdma_chunk_kv/service.rs"] +mod service; + +pub(super) struct TestChunkKvProfile { + endpoint: String, + output: PathBuf, + profile: String, +} + +impl TestChunkKvProfile { + pub(super) fn load() -> Self { + Self { + endpoint: std::env::var("CROWDB_RDMA_CHUNK_KV_ENDPOINT").unwrap(), + output: PathBuf::from(std::env::var("CROWDB_RDMA_CHUNK_KV_OUTPUT").unwrap()), + profile: std::env::var("CROWDB_RDMA_CHUNK_KV_PROFILE").unwrap(), + } + } + + fn config(&self, capacity: u32, server: bool) -> RdmaConfig { + RdmaConfig { + enabled: self.profile != "tcp" && !(server && self.profile == "fallback"), + allow_tcp_fallback: self.profile == "fallback", + listen_enabled: server, + cq_count: 1, + cq_depth: 16, + sq_depth: 2, + send_batch_size: std::env::var("CROWDB_RDMA_SEND_BATCH_SIZE") + .map_or(1, |value| value.parse().unwrap()), + receive_batch_size: std::env::var("CROWDB_RDMA_RECEIVE_BATCH_SIZE") + .map_or(1, |value| value.parse().unwrap()), + aggregation_enabled: std::env::var("CROWDB_RDMA_AGGREGATION_ENABLED").as_deref() == Ok("1"), + rq_depth: 2, + posted_receive_target: 2, + device: std::env::var("CROWDB_RDMA_DEVICE").unwrap_or_default(), + source_address: self.endpoint.rsplit_once(':').unwrap().0.into(), + ..RdmaConfig::default() + } + .with_control_data_capacity(capacity) + .unwrap() + } + + fn write(&self, name: &str, value: &Value) { + let path = self.output.join(name); + let temporary = path.with_extension("tmp"); + std::fs::write(&temporary, serde_json::to_vec_pretty(value).unwrap()).unwrap(); + std::fs::rename(temporary, path).unwrap(); + } + + fn status(server: &RpcServer) -> Value { + serde_json::from_slice(&server.transport_status_json().unwrap()).unwrap() + } + + pub(super) async fn server(&self) { + let service = service::create(&self.endpoint).await; + let handlers = Arc::new(ChunkKvRpcService::new( + service.clone(), + tokio::runtime::Handle::current(), + )); + let rpc = ChunkKvRpcService::create_server(1, Some(&self.config(262_144, true))).unwrap(); + let (host, port) = self.endpoint.rsplit_once(':').unwrap(); + rpc.listen(host, port.parse().unwrap()).unwrap(); + handlers.register_handlers(&rpc); + rpc.start(); + self.write("ready.json", &Self::status(&rpc)); + let mut tick = tokio::time::interval(Duration::from_millis(5)); + let mut lease_sequence = 1; + let mut next_renewal = tokio::time::Instant::now() + Duration::from_secs(2); + let mut fragment_limit = None; + while !self.output.join("stop").exists() { + tick.tick().await; + if tokio::time::Instant::now() >= next_renewal { + lease_sequence += 1; + service::renew(&service, lease_sequence); + next_renewal = tokio::time::Instant::now() + Duration::from_secs(2); + self.write("server-progress.json", &Self::status(&rpc)); + } + if fragment_limit.is_none() && self.output.join("arm-fault").exists() { + let before = Self::status(&rpc)["rdma"]["control_fragments_sent"] + .as_u64() + .unwrap(); + assert!(rpc.limit_control_fragments_for_tests(before + 1)); + fragment_limit = Some(before + 1); + self.write("fault-armed.json", &json!({"fragment_limit": before + 1})); + } + if let Some(limit) = fragment_limit { + let status = Self::status(&rpc); + if status["rdma"]["control_fragments_sent"].as_u64().unwrap() == limit { + self.write("fault-ready.json", &status); + } + } + } + self.write("server-final.json", &Self::status(&rpc)); + rpc.stop(); + } + + pub(super) async fn client(&self) { + let config = ChunkKvRpcTransportConfig { + metadata_rdma: Some(self.config(65_536, false)), + aggregate_rdma: Some(self.config(262_144, false)), + }; + let transport = ChunkKvRpcTransport::with_config(2, 1, 1, &config).unwrap(); + let mut result = match tokio::time::timeout( + Duration::from_secs(10), + corpus::run(&transport, &self.endpoint), + ) + .await + { + Ok(result) => result, + Err(error) => { + self.write("client-timeout.json", &json!({ + "transport": serde_json::from_slice::(&transport.transport_status_json().unwrap()).unwrap(), + "connections": serde_json::from_slice::(&transport.connection_status_json(&self.endpoint).unwrap()).unwrap(), + })); + panic!("real Chunk-KV corpus deadline: {error}"); + } + }; + let connections: Value = + serde_json::from_slice(&transport.connection_status_json(&self.endpoint).unwrap()).unwrap(); + let expected_path = if self.profile == "tcp" || self.profile == "fallback" { + "tcp" + } else { + "rdma" + }; + assert_eq!(connections["metadata"]["path"], expected_path); + assert_eq!(connections["aggregate"]["path"], expected_path); + result["connections"] = connections; + result["settings"] = serde_json::to_value(config).unwrap(); + result["expected_transport_errors"] = json!(0); + if self.profile == "disconnect" { + self.partial_response(&transport, &mut result).await; + } + result["transport"] = serde_json::from_slice(&transport.transport_status_json().unwrap()).unwrap(); + result["effective_uid"] = json!(std::fs::read_to_string("/proc/self/status") + .unwrap() + .lines() + .find(|line| line.starts_with("Uid:")) + .unwrap() + .split_whitespace() + .nth(2) + .unwrap()); + result["profile"] = json!(self.profile); + self.write("client.json", &result); + } + + async fn partial_response(&self, transport: &ChunkKvRpcTransport, result: &mut Value) { + std::fs::write(self.output.join("arm-fault"), "arm\n").unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while !self.output.join("fault-armed.json").exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("server fault gate"); + let before: Value = serde_json::from_slice(&transport.transport_status_json().unwrap()).unwrap(); + let error = tokio::time::timeout( + Duration::from_secs(5), + self.wait_partial_close(transport, &before), + ) + .await + .expect("partial RDMA close did not complete") + .expect_err("incomplete logical response was published"); + let after: Value = serde_json::from_slice(&transport.transport_status_json().unwrap()).unwrap(); + let field = "control_fragments_received"; + let received = after["aggregate"]["transport"]["rdma"][field].as_u64().unwrap() + - before["aggregate"]["transport"]["rdma"][field].as_u64().unwrap(); + assert_eq!(received, 1, "fault must follow one actual response fragment"); + assert_eq!(after["aggregate"]["transport"]["rdma"]["fallback_count"], 0); + result["expected_transport_errors"] = json!(1); + result["partial_response"] = + json!({"received_fragments": received, "error": error.to_string(), "published_results": 0}); + } + + async fn wait_partial_close( + &self, + transport: &ChunkKvRpcTransport, + before: &Value, + ) -> crowdb_chunk_kv_client::Result { + let scan = corpus::scan(); + let response = transport.scan(&self.endpoint, &scan); + tokio::pin!(response); + let mut tick = tokio::time::interval(Duration::from_millis(5)); + loop { + tokio::select! { + result = &mut response => return result, + _ = tick.tick() => { + let current: Value = serde_json::from_slice(&transport.transport_status_json().unwrap()).unwrap(); + let received = current["aggregate"]["transport"]["rdma"]["control_fragments_received"].as_u64().unwrap() + - before["aggregate"]["transport"]["rdma"]["control_fragments_received"].as_u64().unwrap(); + if received == 1 { + self.write("client-partial-ready.json", ¤t); + } + } + } + } + } +} diff --git a/app/crowdb-chunk-kv-server/tests/common/rdma_chunk_kv/corpus.rs b/app/crowdb-chunk-kv-server/tests/common/rdma_chunk_kv/corpus.rs new file mode 100644 index 000000000..21ea972e2 --- /dev/null +++ b/app/crowdb-chunk-kv-server/tests/common/rdma_chunk_kv/corpus.rs @@ -0,0 +1,325 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use crowdb_chunk_kv_client::{ChunkKvRpcTransport, ChunkKvTransport}; +use crowdb_protocol::chunk_kv::{ + BatchMutationItem, BatchMutationRequest, ChunkKvResponse, ChunkKvRpcErrorCode, ClientRequestId, Id128, + MultiGetRequest, OperationResult, PartitionRouting, PointOperation, PointRequest, RequestRouting, + RpcCompareCondition, RpcValue, ScanDirection, ScanRequest, SeekKind, SeekRequest, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use super::service::{EPOCH, PARTITION}; + +pub(super) fn routing(sequence: u64) -> RequestRouting { + RequestRouting { + request_id: ClientRequestId { + client_instance_id: Id128 { high: 20, low: 21 }, + client_sequence: sequence, + }, + map_revision: 1, + partition_id: PARTITION, + owner_epoch: EPOCH, + min_journal_position: None, + deadline_ms: None, + } +} + +fn records() -> Vec<(Vec, Vec)> { + [ + 63 * 1024, + 65_553, + 255 * 1024, + 262_175, + 768 * 1024, + 1_048_583, + 300 * 1024, + 600 * 1024, + ] + .into_iter() + .enumerate() + .map(|(index, size)| { + let key = format!("k{index:02}").into_bytes(); + let value = (0..size) + .map(|offset| u8::try_from(offset % 251).unwrap() ^ u8::try_from(index).unwrap()) + .collect(); + (key, value) + }) + .collect() +} + +fn values(response: ChunkKvResponse) -> (Vec, Option) { + match response.result.unwrap() { + OperationResult::Scan { items, continuation } => (items, continuation), + other => panic!("expected scan response: {other:?}"), + } +} + +pub(super) fn scan() -> ScanRequest { + ScanRequest { + routing: routing(300), + start: None, + end: None, + direction: ScanDirection::Forward, + limit: 100, + continuation: None, + } +} + +pub(super) async fn run(transport: &ChunkKvRpcTransport, endpoint: &str) -> Value { + let records = records(); + let results = populate(transport, endpoint, &records).await; + check_conditions(transport, endpoint, &records[0]).await; + let mut expected = Vec::new(); + for (index, (key, value)) in records.iter().enumerate() { + let response = transport + .point( + endpoint, + &PointRequest { + routing: routing(50 + u64::try_from(index).unwrap()), + operation: PointOperation::Get { key: key.clone() }, + }, + ) + .await + .unwrap(); + match response.result.unwrap() { + OperationResult::Value(Some(found)) => { + assert_eq!(&found.key, key); + assert_eq!(&found.value, value); + expected.push(found); + } + other => panic!("expected stored value: {other:?}"), + } + } + check_groups(transport, endpoint, &expected).await; + let error = transport + .point( + endpoint, + &PointRequest { + routing: RequestRouting { + deadline_ms: Some(1), + ..routing(200) + }, + operation: PointOperation::Get { + key: records[0].0.clone(), + }, + }, + ) + .await + .unwrap() + .result + .unwrap_err(); + assert_eq!(error.code, ChunkKvRpcErrorCode::RequestExpired); + let mut stale = scan(); + stale.continuation = Some(crowdb_protocol::chunk_kv::ScanContinuation { + direction: ScanDirection::Forward, + last_key: b"k00".to_vec(), + partition_id: PARTITION, + owner_epoch: EPOCH + 1, + map_revision: 1, + }); + let stale = transport.scan(endpoint, &stale).await.unwrap().result; + assert!(stale.is_err(), "stale continuation must not produce a scan"); + assert_eq!(stale.unwrap_err().code, ChunkKvRpcErrorCode::RefreshRequired); + json!({ + "records": records.len(), "payload_bytes": records.iter().map(|(_, v)| v.len()).sum::(), + "value_sizes_bytes": records.iter().map(|(_, value)| value.len()).collect::>(), + "values_sha256": format!("{:x}", Sha256::digest(serde_json::to_vec(&expected).unwrap())), + "mutation_results_sha256": format!("{:x}", Sha256::digest(serde_json::to_vec(&results).unwrap())), + "expected_typed_errors": 2, "typed_errors": [error.code, ChunkKvRpcErrorCode::RefreshRequired], "logical_mutation_sequences": (1..=8).collect::>(), + "errors": 0, "verification_errors": 0, + }) +} + +async fn populate( + transport: &ChunkKvRpcTransport, + endpoint: &str, + records: &[(Vec, Vec)], +) -> Vec { + let mut results = Vec::new(); + for (index, (key, value)) in records.iter().take(6).enumerate() { + let request = PointRequest { + routing: routing(u64::try_from(index).unwrap() + 1), + operation: PointOperation::Put { + key: key.clone(), + value: value.clone(), + }, + }; + let result = transport.point(endpoint, &request).await.unwrap(); + assert!(matches!( + result.result, + Ok(OperationResult::Mutation { applied: true, .. }) + )); + assert_eq!( + transport.point(endpoint, &request).await.unwrap(), + result, + "stable mutation identity must deduplicate" + ); + results.push(serde_json::to_value(result).unwrap()); + } + let batch = BatchMutationRequest { + routing: PartitionRouting { + map_revision: 1, + partition_id: PARTITION, + owner_epoch: EPOCH, + deadline_ms: None, + }, + operations: records + .iter() + .enumerate() + .skip(6) + .map(|(index, (key, value))| BatchMutationItem { + request_id: routing(u64::try_from(index).unwrap() + 1).request_id, + operation: PointOperation::Put { + key: key.clone(), + value: value.clone(), + }, + }) + .collect(), + }; + let result = transport.batch_mutate(endpoint, &batch).await.unwrap(); + assert_eq!(transport.batch_mutate(endpoint, &batch).await.unwrap(), result); + for (item, request) in result.result.as_ref().unwrap().iter().zip(&batch.operations) { + assert_eq!(item.request_id, request.request_id); + assert!(matches!( + item.result, + Ok(OperationResult::Mutation { applied: true, .. }) + )); + } + results.push(serde_json::to_value(result).unwrap()); + results +} + +async fn check_conditions(transport: &ChunkKvRpcTransport, endpoint: &str, record: &(Vec, Vec)) { + let response = transport + .point( + endpoint, + &PointRequest { + routing: routing(100), + operation: PointOperation::PutIfAbsent { + key: record.0.clone(), + value: b"wrong".to_vec(), + }, + }, + ) + .await + .unwrap(); + assert!( + matches!(response.result, Ok(OperationResult::Mutation { applied: false, observed: Some(ref v), .. }) if v.value == record.1) + ); + let response = transport + .point( + endpoint, + &PointRequest { + routing: routing(101), + operation: PointOperation::CompareExchange { + key: record.0.clone(), + condition: RpcCompareCondition::Value(b"wrong".to_vec()), + value: b"wrong".to_vec(), + }, + }, + ) + .await + .unwrap(); + assert!(matches!( + response.result, + Ok(OperationResult::Mutation { applied: false, .. }) + )); + for (sequence, operation) in [ + ( + 102, + PointOperation::Put { + key: b"temp".to_vec(), + value: b"value".to_vec(), + }, + ), + ( + 103, + PointOperation::ConditionalDelete { + key: b"temp".to_vec(), + condition: RpcCompareCondition::Value(b"value".to_vec()), + }, + ), + ( + 104, + PointOperation::Delete { + key: b"missing".to_vec(), + }, + ), + ] { + let response = transport + .point( + endpoint, + &PointRequest { + routing: routing(sequence), + operation, + }, + ) + .await + .unwrap(); + assert!(matches!(response.result, Ok(OperationResult::Mutation { .. }))); + } +} + +async fn check_groups(transport: &ChunkKvRpcTransport, endpoint: &str, expected: &[RpcValue]) { + let indices = [7, 0, 7, 2]; + let request = MultiGetRequest { + routing: routing(210), + keys: indices.iter().map(|&i| expected[i].key.clone()).collect(), + }; + let result = transport + .multi_get(endpoint, &request) + .await + .unwrap() + .result + .unwrap(); + assert_eq!( + result, + indices + .iter() + .map(|&i| Some(expected[i].clone())) + .collect::>() + ); + let seek = SeekRequest { + routing: routing(211), + key: b"k03".to_vec(), + kind: SeekKind::Ceiling, + }; + assert_eq!( + transport.seek(endpoint, &seek).await.unwrap().result.unwrap(), + OperationResult::Value(Some(expected[3].clone())) + ); + let (items, continuation) = values(transport.scan(endpoint, &scan()).await.unwrap()); + assert_eq!(items, expected); + assert!(continuation.is_none()); + let mut reverse = scan(); + reverse.direction = ScanDirection::Reverse; + assert_eq!( + values(transport.scan(endpoint, &reverse).await.unwrap()).0, + expected.iter().rev().cloned().collect::>() + ); + let mut page = scan(); + page.limit = 2; + let mut paged = Vec::new(); + loop { + let (items, continuation) = values(transport.scan(endpoint, &page).await.unwrap()); + assert!(items.len() <= 2); + paged.extend(items); + if continuation.is_none() { + break; + } + page.continuation = continuation; + } + assert_eq!(paged, expected); + let (bounded, continuation) = values( + transport + .scan_bounded(endpoint, &scan(), 600 * 1024) + .await + .unwrap(), + ); + assert!(!bounded.is_empty()); + assert!(bounded.len() < expected.len()); + assert!(continuation.is_some()); + assert_eq!(bounded, expected[..bounded.len()]); +} diff --git a/app/crowdb-chunk-kv-server/tests/common/rdma_chunk_kv/service.rs b/app/crowdb-chunk-kv-server/tests/common/rdma_chunk_kv/service.rs new file mode 100644 index 000000000..9fbd3be3d --- /dev/null +++ b/app/crowdb-chunk-kv-server/tests/common/rdma_chunk_kv/service.rs @@ -0,0 +1,161 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crowdb_chunk_kv::{ + CrowdbPartitionTree, Partition, PartitionConfig, PartitionId, PartitionJournal, PartitionRange, + PartitionTree, StreamPartitionJournal, +}; +use crowdb_chunk_kv_server::ChunkKvService; +use crowdb_chunk_stream::memory::MemoryStreamStore; +use crowdb_chunk_stream::{ + ChunkStream, StreamChunkStore, StreamConfig, StreamMetadataStore, StreamName, StreamRecord, + StreamRecordState, StreamRegistry, +}; +use crowdb_protocol::chunk_kv::{ + ChunkKvRangeBalancePolicy, ChunkKvRangeCatalogEntry, ChunkKvRangeCatalogHead, ChunkKvRangeCatalogPage, + ChunkKvRangeCatalogPageRef, ChunkKvRangeCatalogPartitionState, DomainFailurePolicy, + DomainMonitorDescriptor, Id128, KeyRange, OwnerDescriptor, PartitionArtifact, ServingAssignment, + ServingGrant, +}; + +#[path = "../process.rs"] +mod test_process; + +pub(super) const PARTITION: Id128 = Id128 { high: 1, low: 2 }; +pub(super) const EPOCH: u64 = 4; + +pub(super) async fn create(endpoint: &str) -> Arc { + let stream_name = StreamName { high: 30, low: 31 }; + let store = Arc::new(MemoryStreamStore::new(32 << 20)); + let stream = ChunkStream::create( + StreamRecord { + purpose: crowdb_protocol::chunk_stream::StreamPurpose::Wal, + stream_name, + metadata_group_id: 7, + record_version: 1, + consumer_id: None, + created_at_ms: StreamRecord::creation_time_ms(), + state: StreamRecordState::Active, + consumer_kind: Some("chunk-kv-partition".into()), + }, + EPOCH, + StreamConfig::default(), + store.clone() as Arc, + store.clone() as Arc, + store as Arc, + ) + .await + .unwrap(); + let tree: Arc = Arc::new( + CrowdbPartitionTree::open( + 1, + &crowdb_tree_ffi::Config { + buffer_pool_bytes: 64 << 20, + ..Default::default() + }, + ) + .unwrap(), + ); + let journal: Arc = + Arc::new(StreamPartitionJournal::new(stream, stream_name).unwrap()); + let partition = Partition::open( + PartitionId { high: 1, low: 2 }, + PartitionRange { + start: Some(Vec::new()), + end: None, + }, + EPOCH, + PartitionConfig::default(), + tree, + journal, + ) + .unwrap(); + let service = Arc::new(ChunkKvService::new(test_process::TestProcess::authority(7), 8).unwrap()); + let (head, page) = catalog(endpoint, stream_name); + service + .install_catalog_and_reconcile(&head, &[page], &[partition]) + .unwrap(); + renew(&service, 1); + service +} + +fn catalog(endpoint: &str, stream_name: StreamName) -> (ChunkKvRangeCatalogHead, ChunkKvRangeCatalogPage) { + let mut page = ChunkKvRangeCatalogPage { + generation: 1, + page_index: 0, + entries: vec![ChunkKvRangeCatalogEntry { + partition_id: PARTITION, + range: KeyRange { + start: Vec::new(), + end: None, + }, + owner: OwnerDescriptor { + instance_id: 7, + rpc_endpoint: endpoint.into(), + }, + owner_epoch: EPOCH, + state: ChunkKvRangeCatalogPartitionState::Serving, + artifact: PartitionArtifact { + tree_id: 1, + stream_name, + tail_overlay: None, + }, + transition_id: None, + }], + checksum: [0; 32], + }; + page.seal().unwrap(); + let mut head = ChunkKvRangeCatalogHead { + generation: 1, + previous_generation: None, + pages: vec![ChunkKvRangeCatalogPageRef { + page_generation: 1, + page_index: 0, + first_key: Vec::new(), + page_checksum: page.checksum, + }], + checksum: [0; 32], + }; + head.seal().unwrap(); + (head, page) +} + +pub(super) fn renew(service: &ChunkKvService, sequence: u64) { + let policy = DomainMonitorDescriptor { + domain: "chunk-kv".into(), + service_registry_name: "chunk-kv".into(), + driver_version: 1, + capability_version: 1, + heartbeat_interval_ms: 2_000, + suspect_after_ms: 6_000, + dead_after_ms: 10_000, + lease_duration_ms: 12_000, + max_clock_skew_ms: 1_000, + self_fence_margin_ms: 1_000, + failure_policy: DomainFailurePolicy::AutomaticSharedStorage, + balance_policy: "count-first-v1".into(), + chunk_kv_range_balance: Some(ChunkKvRangeBalancePolicy::default()), + }; + let now = u64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis()).unwrap(); + let mut grant = ServingGrant { + instance_id: 7, + process_epoch: 1, + lease_sequence: sequence, + catalog_generation: 1, + issued_at_ms: now, + expires_at_ms: now + policy.lease_duration_ms, + assignments: vec![ServingAssignment { + partition_id: PARTITION, + owner_epoch: EPOCH, + }], + assignment_digest: [0; 32], + }; + grant.seal(); + service + .authority() + .install(grant, &policy, now, service.monotonic_ms()) + .unwrap(); +} diff --git a/app/crowdb-chunk-kv-server/tests/config_test.rs b/app/crowdb-chunk-kv-server/tests/config_test.rs index 03292e9d7..b39d5906f 100644 --- a/app/crowdb-chunk-kv-server/tests/config_test.rs +++ b/app/crowdb-chunk-kv-server/tests/config_test.rs @@ -3,6 +3,37 @@ use crowdb_chunk_kv_server::{BalanceConfig, ChunkKvServerConfig, ConfigError}; +#[test] +fn rpc_rdma_settings_round_trip_and_require_a_stream_layout() { + let mut config = ChunkKvServerConfig { + instance_id: 1, + ..ChunkKvServerConfig::default() + }; + config.rpc_rdma = Some(crowdb_rpc_ffi::RdmaConfig { + enabled: true, + ..crowdb_rpc_ffi::RdmaConfig::default() + }); + assert!(config.validate().is_err()); + config.rpc_rdma = Some( + config + .rpc_rdma + .take() + .unwrap() + .with_control_data_capacity(262_144) + .unwrap(), + ); + config.validate().unwrap(); + let encoded = toml::to_string(&config).unwrap(); + assert_eq!(toml::from_str::(&encoded).unwrap(), config); + let server = crowdb_chunk_kv_server::ChunkKvRpcService::create_server( + 1, + Some(&crowdb_rpc_ffi::RdmaConfig::default()), + ) + .unwrap(); + let status: serde_json::Value = serde_json::from_slice(&server.transport_status_json().unwrap()).unwrap(); + assert!(status["rdma"].is_null()); +} + #[test] fn defaults_close_the_documented_timing_contract() { let mut config = ChunkKvServerConfig { diff --git a/app/crowdb-chunk-kv-server/tests/rdma_rpc_test.rs b/app/crowdb-chunk-kv-server/tests/rdma_rpc_test.rs new file mode 100644 index 000000000..7d3c06fd4 --- /dev/null +++ b/app/crowdb-chunk-kv-server/tests/rdma_rpc_test.rs @@ -0,0 +1,17 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/rdma_chunk_kv.rs"] +mod fixture; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "run through tools/rdma/run-chunk-kv.py with explicit transport and topology"] +async fn real_chunk_kv_service_preserves_large_controls_and_typed_results() { + crowdb_rpc_ffi::init_test_logging(); + let profile = fixture::TestChunkKvProfile::load(); + match std::env::var("CROWDB_RDMA_CHUNK_KV_ROLE").unwrap().as_str() { + "server" => profile.server().await, + "client" => profile.client().await, + other => panic!("invalid Chunk-KV test role: {other}"), + } +} diff --git a/app/crowdb-chunk-kv-server/tests/server_test.rs b/app/crowdb-chunk-kv-server/tests/server_test.rs index c37982748..06010189f 100644 --- a/app/crowdb-chunk-kv-server/tests/server_test.rs +++ b/app/crowdb-chunk-kv-server/tests/server_test.rs @@ -1356,5 +1356,7 @@ mod lease; #[path = "common/serving_lease.rs"] mod serving_fixture; +#[path = "server_test/maintenance.rs"] +mod maintenance; #[path = "server_test/split_scan.rs"] mod split_scan; diff --git a/app/crowdb-chunk-kv-server/tests/server_test/maintenance.rs b/app/crowdb-chunk-kv-server/tests/server_test/maintenance.rs new file mode 100644 index 000000000..6d5130a68 --- /dev/null +++ b/app/crowdb-chunk-kv-server/tests/server_test/maintenance.rs @@ -0,0 +1,109 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::*; +use crowdb_chunk_kv::{MutationOperation, RequestId}; + +fn install_grant(service: &ChunkKvService, generation: u64) { + let mut grant = ServingGrant { + instance_id: INSTANCE_ID, + process_epoch: 1, + lease_sequence: generation + 1, + catalog_generation: generation, + issued_at_ms: 1_000, + expires_at_ms: 13_000, + assignments: vec![ServingAssignment { + partition_id: Id128 { high: 1, low: 2 }, + owner_epoch: EPOCH, + }], + assignment_digest: [0; 32], + }; + grant.seal(); + service + .authority() + .install(grant, &policy(), 1_000, service.monotonic_ms()) + .unwrap(); +} + +async fn write(partition: &Partition, sequence: u64) { + partition + .mutate( + EPOCH, + RequestId { + client_high: 1, + client_low: 1, + client_sequence: sequence, + }, + MutationOperation::Put { + key: b"key".to_vec(), + value: vec![1], + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn metadata_maintenance_requires_a_live_independent_assignment() { + let fixture = serving_fixture::TestServingLease::start().await; + let service = fixture.service; + let partition = fixture.partition; + let store = fixture.store; + write(&partition, 1).await; + write(&partition, 2).await; + let reads = store.record_load_count(); + service.authority().clear(); + assert!(!service.maintain_serving_metadata().await.unwrap()); + assert_eq!( + partition.metrics().snapshot().reclaimed_stream_metadata_records, + 0 + ); + assert_eq!(store.record_load_count(), reads); + install_grant(&service, 1); + assert!(!service.maintain_serving_metadata().await.unwrap()); + assert!(store.record_load_count() > reads); + let reclaimed = partition.metrics().snapshot().reclaimed_stream_metadata_records; + assert_eq!(reclaimed, 0, "untrimmed WAL records remain referenced"); + + let (mut head, mut page) = catalog( + Id128 { high: 1, low: 2 }, + partition.snapshot().stream_name, + EPOCH, + 2, + Some(1), + ); + page.entries[0].transition_id = Some(Id128 { high: 20, low: 21 }); + page.seal().unwrap(); + head.pages[0].page_checksum = page.checksum; + head.seal().unwrap(); + service + .install_catalog_and_reconcile(&head, &[page], &[]) + .unwrap(); + install_grant(&service, 2); + write(&partition, 3).await; + let reads = store.record_load_count(); + assert!(!service.maintain_serving_metadata().await.unwrap()); + assert_eq!( + partition.metrics().snapshot().reclaimed_stream_metadata_records, + reclaimed + ); + + let (head, page) = catalog( + Id128 { high: 1, low: 2 }, + partition.snapshot().stream_name, + EPOCH, + 3, + Some(2), + ); + service + .install_catalog_and_reconcile(&head, &[page], &[]) + .unwrap(); + assert_eq!(store.record_load_count(), reads); + install_grant(&service, 3); + assert!(!service.maintain_serving_metadata().await.unwrap()); + assert!(store.record_load_count() > reads); + assert_eq!( + partition.metrics().snapshot().reclaimed_stream_metadata_records, + reclaimed + ); +} diff --git a/app/crowdb-chunk-kv-server/tests/stream_metadata_reclaim_test.rs b/app/crowdb-chunk-kv-server/tests/stream_metadata_reclaim_test.rs new file mode 100644 index 000000000..1d1fdbac3 --- /dev/null +++ b/app/crowdb-chunk-kv-server/tests/stream_metadata_reclaim_test.rs @@ -0,0 +1,118 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use std::sync::Arc; + +use bytes::Bytes; +use crowdb_chunk_stream::{ + memory::MemoryStreamStore, ChunkStream, KvStreamMetadataStore, StreamConfig, StreamMetadataStore, + StreamName, StreamPurpose, StreamRecord, StreamRecordState, +}; +use crowdb_kv_client::{CrowdbKvClient, ReadMode}; +use crowdb_protocol::chunk_stream::decode_stream_chunk_record; +use crowdb_protocol::key::{BinaryKey, StreamChunkRecordKey}; + +#[path = "common/metadata.rs"] +mod metadata; + +async fn pages(kv: &CrowdbKvClient, name: StreamName) -> Vec { + kv.scan( + 0, + 1, + &StreamChunkRecordKey::attached_prefix(name), + &[], + &[], + 0, + ReadMode::Linearizable, + None, + true, + None, + ) + .await + .unwrap() + .items + .into_iter() + .map(|(key, _)| StreamChunkRecordKey::from_bytes(&key).unwrap()) + .collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn production_record_reclaim_respects_trim_and_retained_generation() { + let fixture = metadata::TestMetadata::start().await; + let chunks = Arc::new(MemoryStreamStore::new(512)); + let metadata = Arc::new(KvStreamMetadataStore::new(fixture.client.clone(), 0, 1).unwrap()); + let name = StreamName { high: 81, low: 82 }; + let stream = ChunkStream::create( + StreamRecord { + purpose: StreamPurpose::Wal, + stream_name: name, + metadata_group_id: 1, + record_version: 1, + consumer_id: None, + created_at_ms: StreamRecord::creation_time_ms(), + state: StreamRecordState::Active, + consumer_kind: None, + }, + 7, + StreamConfig::default(), + chunks.clone(), + metadata.clone(), + chunks.clone(), + ) + .await + .unwrap(); + for byte in 0..16 { + stream.append(&[Bytes::from(vec![byte; 384])]).await.unwrap(); + } + stream.checkpoint().await.unwrap(); + let retained = stream.record_generation(); + for byte in 16..24 { + stream.append(&[Bytes::from(vec![byte; 384])]).await.unwrap(); + } + stream.checkpoint().await.unwrap(); + let before_trim = pages(&fixture.client, name).await; + assert_eq!(before_trim.len(), 24); + assert_eq!(stream.reclaim_metadata_before(retained, 3).await.unwrap(), 0); + assert_eq!(pages(&fixture.client, name).await, before_trim); + let expected = fixture + .client + .scan( + 0, + 1, + &StreamChunkRecordKey::attached_prefix(name), + &[], + &[], + 0, + ReadMode::Linearizable, + None, + false, + None, + ) + .await + .unwrap() + .items + .into_iter() + .filter_map(|(key, value)| { + let record = decode_stream_chunk_record(&value).unwrap(); + (record.generation >= retained || record.logical_end.unwrap() > 23 * 384) + .then(|| StreamChunkRecordKey::from_bytes(&key).unwrap()) + }) + .collect::>(); + stream.trim_prefix(23 * 384).await.unwrap(); + let before = pages(&fixture.client, name).await.len(); + assert_eq!(stream.reclaim_metadata_before(retained, 3).await.unwrap(), 1); + assert_eq!(pages(&fixture.client, name).await.len(), before - 1); + for _ in 0..64 { + if pages(&fixture.client, name).await == expected { + break; + } + assert!(stream.reclaim_metadata_before(retained, 3).await.unwrap() <= 1); + let present = pages(&fixture.client, name).await; + assert!(expected.iter().all(|key| present.contains(key))); + } + let retained_records = metadata.record_store(1).unwrap(); + assert_eq!(pages(&fixture.client, name).await, expected); + let tail = retained_records.load_tail(name).await.unwrap().unwrap(); + assert!(tail.record().generation >= retained); + assert_eq!(stream.read_at(23 * 384, 384).await.unwrap().as_ref(), &[23; 384]); +} diff --git a/app/crowdb-chunkdb/src/lifecycle/handler.rs b/app/crowdb-chunkdb/src/lifecycle/handler.rs index 8afeea280..057bb9955 100644 --- a/app/crowdb-chunkdb/src/lifecycle/handler.rs +++ b/app/crowdb-chunkdb/src/lifecycle/handler.rs @@ -90,6 +90,7 @@ impl From for LifecycleError { fn from(error: StoreError) -> Self { match error { StoreError::Conflict => Self::StateConflict, + StoreError::InvalidRange(message) => Self::InvalidRequest(message), other => Self::Storage(other), } } @@ -131,6 +132,7 @@ mod admission; mod append; #[path = "lock_map.rs"] mod lock_map; +mod range_retirement; mod recovery; mod reservation; mod seal; diff --git a/app/crowdb-chunkdb/src/lifecycle/handler/range_retirement.rs b/app/crowdb-chunkdb/src/lifecycle/handler/range_retirement.rs new file mode 100644 index 000000000..9ab965475 --- /dev/null +++ b/app/crowdb-chunkdb/src/lifecycle/handler/range_retirement.rs @@ -0,0 +1,24 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::{LifecycleError, LifecycleHandler}; +use crowdb_protocol::chunkdb::rpc::{DeleteChunkRangeRequest, DeleteChunkRangeResponse}; + +impl LifecycleHandler { + pub async fn delete_chunk_range( + &self, + request: &DeleteChunkRangeRequest, + ) -> Result { + let id = request + .chunk_id + .ok_or_else(|| LifecycleError::InvalidRequest("missing chunk identity".into()))?; + self.check_range(&id)?; + self.store + .retire_chunk_range(request) + .await + .map_err(|error| match error { + crate::storage::StoreError::ChunkNotFound => LifecycleError::ChunkNotFound, + other => other.into(), + }) + } +} diff --git a/app/crowdb-chunkdb/src/service/chunkdb_rpc_service/mutations.rs b/app/crowdb-chunkdb/src/service/chunkdb_rpc_service/mutations.rs index 7b96e0081..d52c5fea9 100644 --- a/app/crowdb-chunkdb/src/service/chunkdb_rpc_service/mutations.rs +++ b/app/crowdb-chunkdb/src/service/chunkdb_rpc_service/mutations.rs @@ -994,13 +994,14 @@ impl ChunkdbRpcService { &self, req: ServerRequest, server: &Arc, - _request: RequestGuard, + mut request: RequestGuard, ) { let req_id = req.request_id; let create_nano = req.rpc_create_nano; let msg_type = FBMsgType::EDeleteChunkRangeResponse.0 as u16; let conn_handle_usize = req.conn_handle as usize; + let handler = Arc::clone(&self.handler); let server = Arc::clone(server); self.spawn(async move { let Ok(fb_req) = flatbuffers::root::(req.control()) else { @@ -1031,16 +1032,32 @@ impl ChunkdbRpcService { ); return; }; - let _ = (chunk_id, fb_req.chunk_offset(), fb_req.chunk_size()); - // The used-range lifecycle must exist before this RPC can safely - // reclaim data. Until then it must never mutate data. + let result = handler + .delete_chunk_range(&crowdb_protocol::chunkdb::rpc::DeleteChunkRangeRequest { + chunk_id: Some(chunk_id), + chunk_offset: fb_req.chunk_offset(), + chunk_size: fb_req.chunk_size(), + expected_modify_ts: fb_req.expected_modify_ts(), + }) + .await; + let (code, message, range_start, range_end, reclaimed) = match result { + Ok(result) => { + request.mark_success(); + (FBChunkdbRetCode::Success, None, 0, 0, result.reclaimed) + } + Err(error) => { + let (code, message, start, end) = map_error(&error); + (code, Some(message), start, end, false) + } + }; let ctrl = build_delete_range_response( req_id, create_nano, - FBChunkdbRetCode::Unimplemented, - Some("chunk range deletion is not implemented"), - 0, - 0, + code, + message.as_deref(), + range_start, + range_end, + reclaimed, ); submit_fb_response( &server, diff --git a/app/crowdb-chunkdb/src/service/chunkdb_rpc_service/wire.rs b/app/crowdb-chunkdb/src/service/chunkdb_rpc_service/wire.rs index 4f01a2961..d42cd699e 100644 --- a/app/crowdb-chunkdb/src/service/chunkdb_rpc_service/wire.rs +++ b/app/crowdb-chunkdb/src/service/chunkdb_rpc_service/wire.rs @@ -599,6 +599,7 @@ pub(super) fn build_delete_range_response( error_msg: Option<&str>, range_start: u32, range_end: u32, + reclaimed: bool, ) -> (Vec, usize) { let mut fbb = FlatBufferBuilder::new(); let err_off = error_msg.map(|m| fbb.create_string(m)); @@ -611,6 +612,7 @@ pub(super) fn build_delete_range_response( error_msg: err_off, range_start, range_end, + reclaimed, }, ); fbb.finish(off, None); diff --git a/app/crowdb-chunkdb/src/storage.rs b/app/crowdb-chunkdb/src/storage.rs index ae3fe7254..7fa124866 100644 --- a/app/crowdb-chunkdb/src/storage.rs +++ b/app/crowdb-chunkdb/src/storage.rs @@ -7,6 +7,7 @@ //! routed KV group. Chunk records have one explicit, strictly decoded envelope. mod chunk_codec; +pub mod range_retirement; pub mod reservation; mod scope; use chunk_codec::{decode_chunk, encode_chunk}; @@ -29,6 +30,8 @@ use crowdb_protocol::chunk_domain::ChunkDomain; /// Storage error. #[derive(Debug, thiserror::Error)] pub enum StoreError { + #[error("invalid retired chunk range: {0}")] + InvalidRange(String), #[error("chunk not found")] ChunkNotFound, #[error("chunk already exists")] diff --git a/app/crowdb-chunkdb/src/storage/range_retirement.rs b/app/crowdb-chunkdb/src/storage/range_retirement.rs new file mode 100644 index 000000000..510dba10a --- /dev/null +++ b/app/crowdb-chunkdb/src/storage/range_retirement.rs @@ -0,0 +1,145 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +//! Exact dead byte ranges, co-located with and fenced by the owning chunk. + +mod record; +pub use record::{ChunkRangeRetirement, RetiredByteRange}; + +use super::{chunk_key, decode_chunk, encode_chunk, ChunkStore, Result, StoreError}; +use bytes::Bytes; +use crowdb_kv_client::{BatchOp, GetOutcome, ReadMode}; +use crowdb_protocol::chunk_domain::ChunkDomain; +use crowdb_protocol::chunkdb::rpc::{ChunkState, DeleteChunkRangeRequest, DeleteChunkRangeResponse}; +use crowdb_protocol::common::ChunkId; + +impl ChunkStore { + /// Qualifies and records a logically dead range without freeing physical blocks. + /// The caller must establish absence of live/future references before calling. + /// Conflicts and unknown results are returned without confirmation reads. + pub async fn retire_chunk_range( + &self, + request: &DeleteChunkRangeRequest, + ) -> Result { + let id = request + .chunk_id + .ok_or_else(|| invalid("missing chunk identity"))?; + let end = u64::from(request.chunk_offset) + u64::from(request.chunk_size); + if request.chunk_size == 0 { + return Err(invalid("empty range")); + } + let route = self.route(&id)?; + let authority = self.capture_authority(&id)?; + let key = chunk_key(&id); + let GetOutcome::Found { value, revision } = self + .kv + .get( + route.kv_store_id, + route.kv_group_id, + &key, + ReadMode::Linearizable, + None, + ) + .await + .map_err(|error| StoreError::Kv(error.to_string()))? + else { + return Err(StoreError::ChunkNotFound); + }; + let chunk = decode_chunk(&value)?; + if chunk.id != Some(id) + || ChunkDomain::for_chunk(&id) != Some(ChunkDomain::UserData) + || chunk.chunk_type != i32::from(id.high.to_be_bytes()[0]) + || chunk.modify_ts == 0 + { + return Err(invalid("range must belong to a user-data chunk")); + } + match ChunkState::try_from(chunk.state) { + Ok(ChunkState::Deleted) => { + return Ok(DeleteChunkRangeResponse { + reclaimed: chunk.strips.is_empty() && chunk.cleanup_intents.is_empty(), + }) + } + Ok(ChunkState::Sealed) => {} + _ => return Err(StoreError::Conflict), + } + if chunk.writer_epoch == 0 { + if request.chunk_offset != 0 || end.div_ceil(1024) != u64::from(chunk.sealed_length) { + return Err(invalid("range must own the complete sealed dedicated chunk")); + } + } else if end > chunk.acknowledged_cursor { + return Err(StoreError::Conflict); + } + let mut record = self + .get_chunk_range_retirement(&id) + .await? + .unwrap_or_else(|| ChunkRangeRetirement::new(&chunk)); + record.validate(&chunk)?; + let range = RetiredByteRange { + start: u64::from(request.chunk_offset), + end, + }; + if record.contains(range) { + // Repeating a proven retirement is safe after later layout changes. + return Ok(DeleteChunkRangeResponse { reclaimed: false }); + } + if request.expected_modify_ts != 0 && chunk.modify_ts != request.expected_modify_ts { + return Err(StoreError::Conflict); + } + record.insert(range)?; + record.revision = chunk.modify_ts; + // Re-put the unchanged chunk as the atomic batch precondition. Retirement + // does not change physical layout or invalidate the foreground layout cache. + let ops = [ + BatchOp::Put { + key: Bytes::from(key.clone()), + value: Bytes::from(encode_chunk(&chunk)?), + }, + BatchOp::Put { + key: Bytes::from(resource_key(&id)), + value: Bytes::from(record.encode()?), + }, + ]; + self.check_submission(&id, authority.as_ref())?; + match self + .kv + .batch_write_cas(route.kv_store_id, route.kv_group_id, &ops, &key, revision) + .await + { + Ok(_) => Ok(DeleteChunkRangeResponse { reclaimed: false }), + Err(crowdb_kv_client::Error::CasFailed { .. } | crowdb_kv_client::Error::CasBusy) => { + Err(StoreError::Conflict) + } + Err(error) => Err(StoreError::Kv(error.to_string())), + } + } + + /// Loads physical retirement metadata; ordinary chunk reads do not fetch it. + pub async fn get_chunk_range_retirement(&self, id: &ChunkId) -> Result> { + let route = self.route(id)?; + match self + .kv + .get( + route.kv_store_id, + route.kv_group_id, + &resource_key(id), + ReadMode::Linearizable, + None, + ) + .await + .map_err(|error| StoreError::Kv(error.to_string()))? + { + GetOutcome::NotFound => Ok(None), + GetOutcome::Found { value, .. } => ChunkRangeRetirement::decode(&value).map(Some), + } + } +} + +fn resource_key(id: &ChunkId) -> Vec { + let mut key = b"/chunk-ranges/".to_vec(); + key.extend_from_slice(&id.high.to_be_bytes()); + key.extend_from_slice(&id.low.to_be_bytes()); + key +} +fn invalid(message: &str) -> StoreError { + StoreError::InvalidRange(message.into()) +} diff --git a/app/crowdb-chunkdb/src/storage/range_retirement/record.rs b/app/crowdb-chunkdb/src/storage/range_retirement/record.rs new file mode 100644 index 000000000..3cc02af2a --- /dev/null +++ b/app/crowdb-chunkdb/src/storage/range_retirement/record.rs @@ -0,0 +1,124 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use super::{invalid, Result, StoreError}; +use bincode::Options; +use crowdb_protocol::{chunkdb::rpc::Chunk, common::ChunkId}; +use serde::{Deserialize, Serialize}; + +const MAX_RANGES: usize = 4096; +const MAX_RECORD_BYTES: u64 = 128 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct RetiredByteRange { + pub start: u64, + pub end: u64, +} + +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ChunkRangeRetirement { + version: u16, + chunk_id: Option, + writer_epoch: u64, + create_ts_ms: u64, + sealed_ts_ms: u64, + sealed_bytes: u64, + pub revision: u64, + ranges: Vec, +} + +impl ChunkRangeRetirement { + #[must_use] + pub fn ranges(&self) -> &[RetiredByteRange] { + &self.ranges + } + + pub(super) fn new(chunk: &Chunk) -> Self { + Self { + version: 2, + chunk_id: chunk.id, + writer_epoch: chunk.writer_epoch, + create_ts_ms: chunk.create_ts_ms, + sealed_ts_ms: chunk.sealed_ts_ms, + sealed_bytes: sealed_bytes(chunk), + revision: chunk.modify_ts, + ranges: Vec::new(), + } + } + pub(super) fn validate(&self, chunk: &Chunk) -> Result<()> { + self.validate_shape()?; + if self.chunk_id != chunk.id + || self.writer_epoch != chunk.writer_epoch + || self.create_ts_ms != chunk.create_ts_ms + || self.sealed_ts_ms != chunk.sealed_ts_ms + || self.sealed_bytes != sealed_bytes(chunk) + { + return Err(invalid("retirement resource identity corruption")); + } + if self.revision > chunk.modify_ts { + return Err(StoreError::Conflict); + } + Ok(()) + } + fn validate_shape(&self) -> Result<()> { + if !matches!(self.version, 1 | 2) + || self.chunk_id.is_none() + || (self.version == 1 && self.writer_epoch == 0) + || self.sealed_bytes == 0 + || self.revision == 0 + || self.ranges.len() > MAX_RANGES + || self + .ranges + .iter() + .any(|r| r.start >= r.end || r.end > self.sealed_bytes) + || self.ranges.windows(2).any(|pair| pair[0].end >= pair[1].start) + { + return Err(invalid("retirement resource format or interval corruption")); + } + Ok(()) + } + pub(super) fn contains(&self, range: RetiredByteRange) -> bool { + self.ranges + .iter() + .any(|r| r.start <= range.start && r.end >= range.end) + } + pub(super) fn insert(&mut self, range: RetiredByteRange) -> Result<()> { + let mut ranges = self.ranges.clone(); + ranges.push(range); + ranges.sort_unstable_by_key(|r| r.start); + let mut merged: Vec = Vec::with_capacity(ranges.len()); + for range in ranges { + if let Some(previous) = merged.last_mut().filter(|previous| previous.end >= range.start) { + previous.end = previous.end.max(range.end); + } else { + merged.push(range); + } + } + if merged.len() > MAX_RANGES { + return Err(invalid("retirement range budget exceeded")); + } + self.ranges = merged; + Ok(()) + } + pub(super) fn encode(&self) -> Result> { + bincode::serialize(self).map_err(|error| StoreError::Serde(error.to_string())) + } + pub(super) fn decode(bytes: &[u8]) -> Result { + let record: Self = bincode::DefaultOptions::new() + .with_fixint_encoding() + .with_limit(MAX_RECORD_BYTES) + .reject_trailing_bytes() + .deserialize(bytes) + .map_err(|error| StoreError::Serde(error.to_string()))?; + record.validate_shape()?; + Ok(record) + } +} + +fn sealed_bytes(chunk: &Chunk) -> u64 { + if chunk.writer_epoch == 0 { + u64::from(chunk.sealed_length) * 1024 + } else { + chunk.acknowledged_cursor + } +} diff --git a/app/crowdb-chunkdb/tests/common/diskio_stub.rs b/app/crowdb-chunkdb/tests/common/diskio_stub.rs index 6cc10f737..0f1d5ac4e 100644 --- a/app/crowdb-chunkdb/tests/common/diskio_stub.rs +++ b/app/crowdb-chunkdb/tests/common/diskio_stub.rs @@ -95,7 +95,8 @@ async fn serve(mut stream: TcpStream, blocks: Blocks, control: Arc (Vec, Vec) { + if kind == 0xfffe { + assert_eq!(ctrl.len(), 160); + assert_eq!(&ctrl[..4], &[0x44, 0x52, 4, 0]); + assert!(data.is_empty()); + let mut capability = vec![0; 160]; + capability[..4].copy_from_slice(&[0x44, 0x52, 4, 0]); + capability[4..12].copy_from_slice(&1_u64.to_le_bytes()); + return (capability, Vec::new()); + } let mut builder = FlatBufferBuilder::new(); let mut payload = vec![]; if kind == FBMsgType::EDiskWriteRequest.0 as u16 { diff --git a/app/crowdb-chunkdb/tests/range_retirement_test.rs b/app/crowdb-chunkdb/tests/range_retirement_test.rs new file mode 100644 index 000000000..ea55140c6 --- /dev/null +++ b/app/crowdb-chunkdb/tests/range_retirement_test.rs @@ -0,0 +1,450 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/runtime_context.rs"] +#[allow(dead_code)] +mod runtime_context; +#[path = "common/slot_groups.rs"] +#[allow(dead_code)] +mod slot_groups; + +use crowdb_chunkdb::routing::{BindingCache, BindingTable}; +use crowdb_chunkdb::storage::{range_retirement::RetiredByteRange, ChunkStore, StoreError}; +use crowdb_protocol::chunk_slot::{ChunkSlotBootstrap, ChunkStorageGroup}; +use crowdb_protocol::chunkdb::rpc::{Chunk, ChunkState, ChunkType, DeleteChunkRangeRequest}; +use crowdb_protocol::common::ChunkId; +use std::sync::Arc; + +fn store(test: &slot_groups::TestGroups) -> ChunkStore { + ChunkStore::new(Arc::clone(&test.kv), bindings()) +} +fn bindings() -> BindingCache { + let bindings = BindingCache::new(); + bindings + .replace(BindingTable::new( + ChunkSlotBootstrap { + service_instances: vec![1], + storage_groups: vec![ChunkStorageGroup { + store_id: 0, + group_id: 1, + }], + } + .storage_map() + .unwrap(), + )) + .unwrap(); + bindings +} +fn chunk() -> Chunk { + Chunk { + id: Some(ChunkId { + high: 5 << 56, + low: 1, + }), + modify_ts: 1, + state: ChunkState::Sealed as i32, + chunk_type: ChunkType::S3 as i32, + writer_epoch: 7, + acknowledged_cursor: 8192, + sealed_length: 8, + create_ts_ms: 10, + sealed_ts_ms: 20, + ..Default::default() + } +} +fn request(chunk: &Chunk, offset: u32, size: u32) -> DeleteChunkRangeRequest { + DeleteChunkRangeRequest { + chunk_id: chunk.id, + chunk_offset: offset, + chunk_size: size, + expected_modify_ts: chunk.modify_ts, + } +} + +#[tokio::test] +async fn exact_union_survives_restart_without_changing_layout_or_claiming_capacity() { + let test = slot_groups::TestGroups::start().await; + let store = store(&test); + let chunk = chunk(); + let id = chunk.id.unwrap(); + store.put_chunk(&chunk).await.unwrap(); + for (start, size) in [(134, 234), (300, 200), (500, 1), (1025, 2047)] { + assert!( + !store + .retire_chunk_range(&request(&chunk, start, size)) + .await + .unwrap() + .reclaimed + ); + } + let expected = store.get_chunk_range_retirement(&id).await.unwrap().unwrap(); + assert_eq!( + expected.ranges(), + [ + RetiredByteRange { start: 134, end: 501 }, + RetiredByteRange { + start: 1025, + end: 3072 + } + ] + ); + assert_eq!(store.get_chunk(&id).await.unwrap(), chunk); + + let restarted = crate::store(&test); + assert!( + !restarted + .retire_chunk_range(&request(&chunk, 134, 367)) + .await + .unwrap() + .reclaimed + ); + assert_eq!( + restarted.get_chunk_range_retirement(&id).await.unwrap().unwrap(), + expected + ); + assert_eq!(restarted.get_chunk(&id).await.unwrap(), chunk); +} + +#[tokio::test] +async fn changed_layout_rejects_new_ranges_but_known_retirement_replays() { + let test = slot_groups::TestGroups::start().await; + let store = store(&test); + let mut chunk = chunk(); + let id = chunk.id.unwrap(); + store.put_chunk(&chunk).await.unwrap(); + let retired = request(&chunk, 0, 10); + let stale = request(&chunk, 20, 10); + store.retire_chunk_range(&retired).await.unwrap(); + let before = store.get_chunk_range_retirement(&id).await.unwrap(); + chunk.modify_ts += 1; + store.put_chunk(&chunk).await.unwrap(); + assert!(matches!( + store.retire_chunk_range(&stale).await, + Err(StoreError::Conflict) + )); + assert!(!store.retire_chunk_range(&retired).await.unwrap().reclaimed); + assert_eq!(store.get_chunk_range_retirement(&id).await.unwrap(), before); + store.retire_chunk_range(&request(&chunk, 20, 10)).await.unwrap(); + assert_eq!( + store + .get_chunk_range_retirement(&id) + .await + .unwrap() + .unwrap() + .revision, + 2 + ); +} + +#[tokio::test] +async fn concurrent_retirements_are_atomic_and_conflicts_never_lose_a_range() { + let test = slot_groups::TestGroups::start().await; + let store = store(&test); + let chunk = chunk(); + store.put_chunk(&chunk).await.unwrap(); + let (a, b) = (request(&chunk, 0, 10), request(&chunk, 20, 10)); + let (first, second) = tokio::join!(store.retire_chunk_range(&a), store.retire_chunk_range(&b)); + for (result, req) in [(first, a), (second, b)] { + match result { + Ok(result) => assert!(!result.reclaimed), + Err(StoreError::Conflict) => { + store.retire_chunk_range(&req).await.unwrap(); + } + error => panic!("unexpected retirement outcome: {error:?}"), + } + } + assert_eq!( + store + .get_chunk_range_retirement(&chunk.id.unwrap()) + .await + .unwrap() + .unwrap() + .ranges(), + [ + RetiredByteRange { start: 0, end: 10 }, + RetiredByteRange { start: 20, end: 30 } + ] + ); +} + +#[tokio::test] +async fn invalid_ranges_active_writers_and_system_chunks_never_create_retirement() { + let test = slot_groups::TestGroups::start().await; + let store = store(&test); + for case in 0..7 { + let mut chunk = chunk(); + chunk.id.as_mut().unwrap().low += case; + let mut req = request(&chunk, 0, 1); + match case { + 0 => req.chunk_size = 0, + 1 => req.chunk_offset = 8192, + 2 => chunk.modify_ts = 0, + 3 => chunk.state = ChunkState::Active as i32, + 4 => { + chunk.id.as_mut().unwrap().high = 1 << 56; + req.chunk_id = chunk.id; + } + 5 => chunk.writer_epoch = 0, + 6 => { + req.chunk_offset = u32::MAX; + req.chunk_size = u32::MAX; + } + _ => unreachable!(), + } + store.put_chunk(&chunk).await.unwrap(); + assert!( + matches!( + store.retire_chunk_range(&req).await, + Err(StoreError::InvalidRange(_) | StoreError::Conflict) + ), + "case {case}" + ); + assert!(store + .get_chunk_range_retirement(&chunk.id.unwrap()) + .await + .unwrap() + .is_none()); + assert_eq!(store.get_chunk(&chunk.id.unwrap()).await.unwrap(), chunk); + } +} + +#[tokio::test] +async fn reused_physical_identity_cannot_adopt_old_retired_ranges() { + let test = slot_groups::TestGroups::start().await; + let store = store(&test); + let mut chunk = chunk(); + store.put_chunk(&chunk).await.unwrap(); + store.retire_chunk_range(&request(&chunk, 0, 10)).await.unwrap(); + chunk.writer_epoch += 1; + chunk.modify_ts += 1; + store.put_chunk(&chunk).await.unwrap(); + assert!(matches!( + store.retire_chunk_range(&request(&chunk, 0, 10)).await, + Err(StoreError::InvalidRange(_)) + )); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn production_rpc_qualifies_notifications_and_keeps_explicit_layout_conditions() { + use crowdb_chunkdb::range_guard::RangeGuard; + use crowdb_chunkdb_client::{ChunkdbClientError, ChunkdbRpcTransport}; + use crowdb_protocol::{chunk_domain::ChunkDomain, port::alloc::alloc_test_port, ServicePort}; + let test = slot_groups::TestGroups::start().await; + let store = store(&test); + let chunk = chunk(); + store.put_chunk(&chunk).await.unwrap(); + let guard = Arc::new(RangeGuard::new()); + guard + .install( + &ChunkSlotBootstrap { + service_instances: vec![1], + storage_groups: vec![ChunkStorageGroup { + store_id: 0, + group_id: 1, + }], + } + .service_map() + .unwrap(), + 1, + ) + .unwrap(); + let (context, stop) = runtime_context::runtime_context(Arc::clone(&test.kv), bindings(), guard); + let runtime = context.start(ChunkDomain::UserData).await.unwrap(); + let service = Arc::new(runtime.rpc_service); + let server = Arc::new(crowdb_rpc_ffi::RpcServer::new(None)); + let port = alloc_test_port(ServicePort::ChunkdbRpc); + server.listen("127.0.0.1", i32::from(port)).unwrap(); + service.register_handlers(&server); + server.start(); + let endpoint = format!("http://127.0.0.1:{port}"); + let client = ChunkdbRpcTransport::new(); + let mut req = request(&chunk, 134, 234); + req.expected_modify_ts = chunk.modify_ts + 1; + assert!(matches!( + client.send_delete_chunk_range(&endpoint, &req).await, + Err(ChunkdbClientError::Aborted(_)) + )); + assert!(store + .get_chunk_range_retirement(&chunk.id.unwrap()) + .await + .unwrap() + .is_none()); + req.expected_modify_ts = 0; + for _ in 0..2 { + assert!( + !client + .send_delete_chunk_range(&endpoint, &req) + .await + .unwrap() + .reclaimed + ); + } + assert_eq!( + store + .get_chunk_range_retirement(&chunk.id.unwrap()) + .await + .unwrap() + .unwrap() + .ranges(), + [RetiredByteRange { start: 134, end: 368 }] + ); + assert_eq!(store.get_chunk(&chunk.id.unwrap()).await.unwrap(), chunk); + req.chunk_id.as_mut().unwrap().low += 1; + assert!(matches!( + client.send_delete_chunk_range(&endpoint, &req).await, + Err(ChunkdbClientError::NotFound(_)) + )); + server.stop(); + stop.send(true).unwrap(); + for worker in runtime.handles { + tokio::time::timeout(std::time::Duration::from_secs(5), worker) + .await + .unwrap() + .unwrap(); + } +} + +#[tokio::test] +async fn server_qualifies_current_layout_but_retains_resource_identity_and_atomicity() { + let test = slot_groups::TestGroups::start().await; + let store = store(&test); + let mut chunk = chunk(); + store.put_chunk(&chunk).await.unwrap(); + let mut notification = request(&chunk, 134, 234); + notification.expected_modify_ts = 0; + chunk.modify_ts += 1; + store.put_chunk(&chunk).await.unwrap(); + assert!(!store.retire_chunk_range(¬ification).await.unwrap().reclaimed); + let retired = store + .get_chunk_range_retirement(&chunk.id.unwrap()) + .await + .unwrap() + .unwrap(); + assert_eq!(retired.revision, chunk.modify_ts); + assert_eq!(retired.ranges(), [RetiredByteRange { start: 134, end: 368 }]); + chunk.writer_epoch += 1; + chunk.modify_ts += 1; + store.put_chunk(&chunk).await.unwrap(); + assert!(matches!( + store.retire_chunk_range(¬ification).await, + Err(StoreError::InvalidRange(_)) + )); + assert_eq!( + store + .get_chunk_range_retirement(&chunk.id.unwrap()) + .await + .unwrap() + .unwrap(), + retired + ); +} + +#[tokio::test] +async fn dedicated_notification_accepts_only_its_complete_sealed_extent() { + let test = slot_groups::TestGroups::start().await; + let store = store(&test); + let mut chunk = chunk(); + chunk.writer_epoch = 0; + chunk.acknowledged_cursor = 0; + store.put_chunk(&chunk).await.unwrap(); + for (offset, length) in [(1, 8191), (0, 7168), (0, 8193)] { + let mut notification = request(&chunk, offset, length); + notification.expected_modify_ts = 0; + assert!(matches!( + store.retire_chunk_range(¬ification).await, + Err(StoreError::InvalidRange(_)) + )); + } + let mut notification = request(&chunk, 0, 8100); + notification.expected_modify_ts = 0; + assert!(!store.retire_chunk_range(¬ification).await.unwrap().reclaimed); + let restarted = crate::store(&test); + assert!( + !restarted + .retire_chunk_range(¬ification) + .await + .unwrap() + .reclaimed + ); + assert_eq!( + restarted + .get_chunk_range_retirement(&chunk.id.unwrap()) + .await + .unwrap() + .unwrap() + .ranges(), + [RetiredByteRange { start: 0, end: 8100 }] + ); + assert_eq!(restarted.get_chunk(&chunk.id.unwrap()).await.unwrap(), chunk); +} + +#[tokio::test] +async fn deleted_chunk_identity_cannot_be_allocated_again() { + let test = slot_groups::TestGroups::start().await; + let store = store(&test); + let mut chunk = chunk(); + chunk.state = ChunkState::Deleted as i32; + store.put_chunk(&chunk).await.unwrap(); + let mut reused = chunk.clone(); + reused.state = ChunkState::Active as i32; + reused.writer_epoch += 1; + assert!(matches!( + store + .create_chunk_with_finalize_task(&reused, &slot_groups::finalize(chunk.id.unwrap())) + .await, + Err(StoreError::ChunkAlreadyExists) + )); + assert_eq!(store.get_chunk(&chunk.id.unwrap()).await.unwrap(), chunk); +} + +#[tokio::test] +async fn interval_budget_rejects_growth_but_allows_coalescing_without_losing_live_gaps() { + let test = slot_groups::TestGroups::start().await; + let store = store(&test); + let mut chunk = chunk(); + chunk.acknowledged_cursor = 1024 * 1024; + chunk.sealed_length = 1024; + store.put_chunk(&chunk).await.unwrap(); + let id = chunk.id.unwrap(); + let mut key = b"/chunk-ranges/".to_vec(); + key.extend_from_slice(&id.high.to_be_bytes()); + key.extend_from_slice(&id.low.to_be_bytes()); + let ranges: Vec<_> = (0..4096) + .map(|i| RetiredByteRange { + start: i * 2, + end: i * 2 + 1, + }) + .collect(); + // Version-one resource fixture at the maximum interval count. + let encoded = bincode::serialize(&( + 1_u16, + chunk.id, + chunk.writer_epoch, + chunk.create_ts_ms, + chunk.sealed_ts_ms, + chunk.acknowledged_cursor, + chunk.modify_ts, + &ranges, + )) + .unwrap(); + test.kv.put(0, 1, &key, &encoded, None).await.unwrap(); + assert!(matches!( + store.retire_chunk_range(&request(&chunk, 8192, 1)).await, + Err(StoreError::InvalidRange(_)) + )); + assert_eq!( + store + .get_chunk_range_retirement(&id) + .await + .unwrap() + .unwrap() + .ranges(), + ranges + ); + store.retire_chunk_range(&request(&chunk, 1, 1)).await.unwrap(); + let record = store.get_chunk_range_retirement(&id).await.unwrap().unwrap(); + assert_eq!(record.ranges().len(), 4095); + assert_eq!(record.ranges()[0], RetiredByteRange { start: 0, end: 3 }); + assert_eq!(record.ranges()[1], RetiredByteRange { start: 4, end: 5 }); + assert_eq!(store.get_chunk(&id).await.unwrap(), chunk); +} diff --git a/app/crowdb-cli/src/commands/bench/loader.rs b/app/crowdb-cli/src/commands/bench/loader.rs index 9df08ab0d..1ad30f7db 100644 --- a/app/crowdb-cli/src/commands/bench/loader.rs +++ b/app/crowdb-cli/src/commands/bench/loader.rs @@ -57,7 +57,12 @@ impl BenchRecorder { /// Record one successful op with its latency (µs) and payload bytes. pub fn record_ok(&self, us: u64, bytes: u64) { - self.latency.observe(us.saturating_mul(1000)); + self.record_ok_ns(us.saturating_mul(1000), bytes); + } + + /// Preserve nanosecond precision for RPC completion callbacks. + pub fn record_ok_ns(&self, ns: u64, bytes: u64) { + self.latency.observe(ns); self.bytes.observe(bytes); } diff --git a/app/crowdb-cli/src/commands/bench/result.rs b/app/crowdb-cli/src/commands/bench/result.rs index 97cb5fd73..ecea040fd 100644 --- a/app/crowdb-cli/src/commands/bench/result.rs +++ b/app/crowdb-cli/src/commands/bench/result.rs @@ -83,6 +83,7 @@ impl BenchOps { #[derive(Debug, Serialize)] pub struct OpStats { pub latency_us: LatencyUs, + pub latency_ns: LatencyNs, } impl From for OpStats { @@ -94,10 +95,57 @@ impl From for OpStats { p50: s.p50 / 1000, p99: s.p99 / 1000, }, + latency_ns: LatencyNs { + count: s.count, + avg: s.avg, + min: s.min, + max: s.max, + p50: s.p50, + p90: s.p90, + p95: s.p95, + p99: s.p99, + p999: s.p999, + zero_count: s.zero_count, + overflow_count: s.overflow_count, + }, } } } +/// Nanosecond values preserve sub-microsecond precision in machine reports. +#[derive(Debug, Serialize)] +pub struct LatencyNs { + pub count: u64, + pub avg: f64, + pub min: u64, + pub max: u64, + pub p50: u64, + pub p90: u64, + pub p95: u64, + pub p99: u64, + pub p999: u64, + pub zero_count: u64, + pub overflow_count: u64, +} + +impl fmt::Display for LatencyNs { + #[allow(clippy::cast_precision_loss)] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "avg={:.3}us p50={:.3}us p90={:.3}us p95={:.3}us p99={:.3}us p99.9={:.3}us max={:.3}us overflow={}", + self.avg / 1000.0, + self.p50 as f64 / 1000.0, + self.p90 as f64 / 1000.0, + self.p95 as f64 / 1000.0, + self.p99 as f64 / 1000.0, + self.p999 as f64 / 1000.0, + self.max as f64 / 1000.0, + self.overflow_count, + ) + } +} + /// Per-op latency percentiles. Field names are serialized as /// `*_us` (the contract the regression scripts parse); the Rust names /// drop the shared `_us` postfix to satisfy `clippy::struct_field_names`. @@ -208,25 +256,13 @@ impl fmt::Display for BenchResult { writeln!(f, " correctness_errors: {ce}")?; } if let Some(s) = &self.by_op.read { - writeln!( - f, - " read: avg={}us p50={}us p99={}us", - s.latency_us.avg, s.latency_us.p50, s.latency_us.p99, - )?; + writeln!(f, " read: {}", s.latency_ns)?; } if let Some(s) = &self.by_op.write { - writeln!( - f, - " write: avg={}us p50={}us p99={}us", - s.latency_us.avg, s.latency_us.p50, s.latency_us.p99, - )?; + writeln!(f, " write: {}", s.latency_ns)?; } if let Some(s) = &self.by_op.list { - writeln!( - f, - " list: avg={}us p50={}us p99={}us", - s.latency_us.avg, s.latency_us.p50, s.latency_us.p99, - )?; + writeln!(f, " list: {}", s.latency_ns)?; } if let Some(cts) = &self.client_transport_stats { writeln!( diff --git a/app/crowdb-diskio/src/dio_main.cpp b/app/crowdb-diskio/src/dio_main.cpp index ed383e38a..d86744bce 100644 --- a/app/crowdb-diskio/src/dio_main.cpp +++ b/app/crowdb-diskio/src/dio_main.cpp @@ -116,13 +116,16 @@ static std::shared_ptr create_engine(const crowdb::dis #ifdef CROWDB_HAVE_LIBURING try { auto engine = std::make_shared(cfg.sq_entries); + std::printf("crowdb-diskio engine: io_uring\n"); return engine; } catch (const std::exception &e) { std::fprintf(stderr, "warning: uring engine creation failed (%s), falling back to blocking\n", e.what()); } #endif - return std::make_shared(cfg.thread_pool_size); + auto engine = std::make_shared(cfg.thread_pool_size); + std::printf("crowdb-diskio engine: blocking\n"); + return engine; } // Build the DiskSet from config. Disks with a non-empty path are @@ -205,7 +208,9 @@ int main(int argc, char *argv[]) } // Create + start the RPC server. - crowdb::rpc::RpcServer server(nullptr, 1, cfg.rpc_workers); + const auto rdma_defaults = crowdb::rpc::RdmaConfig::for_layout(crowdb::rpc::RdmaWireLayout::ControlData, + crowdb::rpc::RDMA_DATAPATH_CAPACITY); + crowdb::rpc::RpcServer server(nullptr, 1, cfg.rpc_workers, rdma_defaults); if (!server.listen(cfg.bind_address, cfg.listen_port)) { std::fprintf(stderr, "error: failed to listen on %s:%d\n", cfg.bind_address.c_str(), cfg.listen_port); return 1; @@ -215,12 +220,13 @@ int main(int argc, char *argv[]) disk_set->size()); std::fflush(stdout); - auto *transport = server.transport(); - auto dio_server = + auto transport = server.shared_transport(); + auto dio_server = std::make_unique(disk_set, transport, cfg.max_write_request_age_ms, cfg.max_clock_skew_ms); dio_server->register_handlers(server); server.start(); + std::printf("crowdb-diskio transport ready: %s\n", transport->status_json().c_str()); // Scheduled executor for periodic tasks (group-0 sync, etc.). // The main loop polls run_due_tasks() every ~100ms. @@ -282,6 +288,7 @@ int main(int argc, char *argv[]) if (group0_sync != nullptr) { group0_sync->stop(); } + std::printf("crowdb-diskio transport final: %s\n", transport->status_json().c_str()); server.stop(); disk_set->shutdown(); diff --git a/app/crowdb-diskio/src/rpc/dio_fsync.cpp b/app/crowdb-diskio/src/rpc/dio_fsync.cpp new file mode 100644 index 000000000..17986608c --- /dev/null +++ b/app/crowdb-diskio/src/rpc/dio_fsync.cpp @@ -0,0 +1,59 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#include "crowdb-common/metrics/metrics.h" +#include "rpc/dio_request.h" +#include "rpc/dio_server.h" + +#include + +#include +#include +#include +#include +#include + +namespace crowdb::diskio +{ +namespace dproto = crowdb::diskio::proto; +namespace rproto = crowdb::rpc::proto; + +crowdb::rpc::OutFrame *DiskioServer::handle_fsync(crowdb::rpc::Frame *request, crowdb::rpc::Connection *conn) +{ + uint64_t req_id = request->request_id; + uint64_t create_nano = request->rpc_create_nano; + uint16_t msg_type = static_cast(rproto::FBMsgType_EDiskFsyncResponse); + + auto *fb_req = decode_disk_request(*request); + if (fb_req == nullptr) { + delete request; + send_error_response(conn, req_id, create_nano, msg_type, static_cast(dproto::FBDiskIoRetCode_IoError)); + return nullptr; + } + DiskId did = parse_disk_id(fb_req->disk_id()); + + auto disk = disk_set_->find_disk(did); + if (disk == nullptr) { + delete request; + send_error_response(conn, req_id, create_nano, msg_type, + static_cast(dproto::FBDiskIoRetCode_DiskNotExist)); + return nullptr; + } + + delete request; + + Disk *disk_ptr = disk.get(); + auto started = std::chrono::steady_clock::now(); + disk_ptr->engine()->submit_fsync(disk_ptr, [transport = transport_, connection = conn->weak_from_this().lock(), + latency = fsync_latency_, req_id, create_nano, msg_type, started, + disk = std::move(disk)](int res) { + latency->observe(elapsed_nanos(started)); + int16_t ret_code = (res < 0) ? static_cast(dproto::FBDiskIoRetCode_IoError) + : static_cast(dproto::FBDiskIoRetCode_Success); + send_response(transport, connection, req_id, create_nano, msg_type, ret_code); + }); + + return nullptr; +} + +} // namespace crowdb::diskio diff --git a/app/crowdb-diskio/src/rpc/dio_read.cpp b/app/crowdb-diskio/src/rpc/dio_read.cpp new file mode 100644 index 000000000..88f7698d5 --- /dev/null +++ b/app/crowdb-diskio/src/rpc/dio_read.cpp @@ -0,0 +1,110 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#include "crowdb-common/metrics/metrics.h" +#include "rpc/dio_read_buffer.h" +#include "rpc/dio_request.h" +#include "rpc/dio_server.h" + +#include + +#include +#include +#include +#include +#include + +namespace crowdb::diskio +{ +namespace dproto = crowdb::diskio::proto; +namespace rproto = crowdb::rpc::proto; + +crowdb::rpc::OutFrame *DiskioServer::handle_read(crowdb::rpc::Frame *request, crowdb::rpc::Connection *conn) +{ + uint64_t req_id = request->request_id; + uint64_t create_nano = request->rpc_create_nano; + uint16_t msg_type = static_cast(rproto::FBMsgType_EDiskReadResponse); + + auto *fb_req = decode_disk_request(*request); + if (fb_req == nullptr || (request->data_buf != nullptr && request->data_buf->len != 0)) { + delete request; + send_error_response(conn, req_id, create_nano, msg_type, static_cast(dproto::FBDiskIoRetCode_IoError)); + return nullptr; + } + DiskId did = parse_disk_id(fb_req->disk_id()); + uint32_t zone_index = fb_req->zone_index(); + uint64_t zone_offset = fb_req->zone_offset(); + uint32_t size = fb_req->size(); + uint64_t test_pattern_offset = fb_req->test_pattern_offset(); + + auto disk = disk_set_->find_disk(did); + if (disk == nullptr) { + delete request; + send_error_response(conn, req_id, create_nano, msg_type, + static_cast(dproto::FBDiskIoRetCode_DiskNotExist)); + return nullptr; + } + + Zone *zone = disk->find_zone(zone_index); + if (zone == nullptr) { + delete request; + send_error_response(conn, req_id, create_nano, msg_type, + static_cast(dproto::FBDiskIoRetCode_ZoneNotExist)); + return nullptr; + } + if (!valid_disk_range(*zone, zone_offset, size)) { + delete request; + send_error_response(conn, req_id, create_nano, msg_type, static_cast(dproto::FBDiskIoRetCode_IoError)); + return nullptr; + } + off_t phys_offset = zone->base_offset + static_cast(zone_offset); + + const bool registered = conn->path() == crowdb::rpc::ConnectionPath::Rdma; + auto *root = request->data_buf == nullptr ? nullptr : request->data_buf->allocation_owner; + const bool receive_owned = + root != nullptr && root->type == crowdb::rpc::BufferType::Registered && root->registration_handle != 0 && + root->last_release_cb != nullptr && + root->ownership.load(std::memory_order_acquire) == crowdb::rpc::BufferOwnership::CallerOwned; + // Control has been decoded, and READ carries no input payload. Retain its + // existing receive data region until disk and response SEND both finish. + auto *read_buf = registered ? (receive_owned ? root->slice(0, size) : nullptr) : conn->pool()->alloc(size); + if (read_buf == nullptr) { + delete request; + send_error_response(conn, req_id, create_nano, msg_type, static_cast(dproto::FBDiskIoRetCode_IoError)); + return nullptr; + } + + DiskReadBuffer io{.buffer = read_buf, .size = size, .offset = phys_offset, .io_size = size, .aligned = {}}; + const auto aligned = io.prepare_alignment(*disk, *zone); + delete request; + if (aligned != IoRetCode::Success) { + read_buf->release(); + send_error_response(conn, req_id, create_nano, msg_type, static_cast(aligned)); + return nullptr; + } + if (!io.begin()) { + conn->close(); + return nullptr; + } + Disk *disk_ptr = disk.get(); + auto started = std::chrono::steady_clock::now(); + auto *io_data = io.data(); + disk_ptr->engine()->submit_read(disk_ptr, io.offset, io_data, io.io_size, test_pattern_offset, + [transport = transport_, connection = conn->weak_from_this().lock(), + latency = read_latency_, req_id, create_nano, msg_type, io = std::move(io), + disk = std::move(disk), started](int result) mutable { + latency->observe(elapsed_nanos(started)); + const auto completed = io.finish(result); + if (!completed.valid_owner) { + if (connection != nullptr) + connection->close(); + return; + } + send_response(transport, connection, req_id, create_nano, msg_type, + static_cast(completed.code), completed.data); + }); + + return nullptr; +} + +} // namespace crowdb::diskio diff --git a/app/crowdb-diskio/src/rpc/dio_read_buffer.h b/app/crowdb-diskio/src/rpc/dio_read_buffer.h new file mode 100644 index 000000000..71131a074 --- /dev/null +++ b/app/crowdb-diskio/src/rpc/dio_read_buffer.h @@ -0,0 +1,91 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#pragma once + +#include "crowdb-rpc/buffer.h" +#include "disk/disk.h" + +#include +#include +#include +#include + +namespace crowdb::diskio +{ +// One disk-read destination, including alignment-edge staging when required. +// Its asynchronous callback must finish ownership before publishing/releasing. +struct DiskReadBuffer +{ + crowdb::rpc::Buffer *buffer; + uint32_t size; + off_t offset; + size_t io_size; + size_t prefix = 0; + std::shared_ptr aligned; + crowdb::rpc::BufferOwnership previous_owner = crowdb::rpc::BufferOwnership::Available; + + IoRetCode prepare_alignment(const Disk &disk, const Zone &zone) + { + if (!disk.is_o_direct()) + return IoRetCode::Success; + const size_t alignment = std::max(4096, disk.block_size()); + if ((alignment & (alignment - 1)) != 0 || alignment > static_cast(std::numeric_limits::max()) || + static_cast(offset) + size > static_cast(std::numeric_limits::max()) - alignment) + return IoRetCode::InvalidAlignment; + prefix = static_cast(offset) % alignment; + offset -= static_cast(prefix); + io_size = ((prefix + size + alignment - 1) / alignment) * alignment; + if (offset < zone.base_offset || io_size > static_cast(zone.capacity - (offset - zone.base_offset))) + return IoRetCode::InvalidAlignment; + if (prefix != 0 || io_size != size || reinterpret_cast(buffer->payload_data()) % alignment != 0) { + aligned = + std::shared_ptr(static_cast(std::aligned_alloc(alignment, io_size)), std::free); + if (aligned == nullptr) + return IoRetCode::IoError; + } + return IoRetCode::Success; + } + + bool begin() + { + using crowdb::rpc::BufferOwnership; + previous_owner = buffer->ownership.load(std::memory_order_acquire); + if ((previous_owner == BufferOwnership::Available || previous_owner == BufferOwnership::CallerOwned) && + buffer->transition(previous_owner, BufferOwnership::DiskOwned)) + return true; + (void)buffer->quarantine(); + buffer->release(); + return false; + } + + uint8_t *data() const + { + return aligned ? aligned.get() : buffer->payload_data(); + } + + struct Result + { + IoRetCode code; + crowdb::rpc::Buffer *data; + bool valid_owner; + }; + + Result finish(int result) + { + if (!buffer->transition(crowdb::rpc::BufferOwnership::DiskOwned, previous_owner)) { + (void)buffer->quarantine(); + buffer->release(); + return {IoRetCode::IoError, nullptr, false}; + } + if (result < 0 || static_cast(result) < io_size) { + buffer->release(); + return {result < 0 ? IoRetCode::IoError : IoRetCode::PartialWrite, nullptr, true}; + } + if (aligned) + std::memcpy(buffer->payload_data(), aligned.get() + prefix, size); + buffer->len = size; + return {IoRetCode::Success, buffer, true}; + } +}; +} // namespace crowdb::diskio diff --git a/app/crowdb-diskio/src/rpc/dio_request.h b/app/crowdb-diskio/src/rpc/dio_request.h new file mode 100644 index 000000000..e40d6437d --- /dev/null +++ b/app/crowdb-diskio/src/rpc/dio_request.h @@ -0,0 +1,51 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#pragma once + +#include "crowdb-rpc/framing.h" +#include "disk/disk.h" + +#include +#include + +#include +#include + +namespace crowdb::diskio +{ +template const Message *decode_disk_request(const crowdb::rpc::Frame &request) +{ + if (request.control.size() < sizeof(flatbuffers::uoffset_t)) + return nullptr; + flatbuffers::Verifier verifier(request.control.data(), request.control.size()); + return verifier.VerifyBuffer(nullptr) ? flatbuffers::GetRoot(request.control.data()) : nullptr; +} + +inline DiskId parse_disk_id(const crowdb::rpc::proto::FBInt128 *id) +{ + return id == nullptr ? DiskId{0, 0} : DiskId{id->high(), id->low()}; +} + +inline bool valid_disk_range(const Zone &zone, uint64_t offset, uint32_t size) +{ + if (zone.base_offset < 0 || zone.capacity < 0 || offset > static_cast(zone.capacity) || + size > static_cast(zone.capacity) - offset) + return false; + const auto remaining = static_cast(std::numeric_limits::max() - zone.base_offset); + return offset <= remaining && size <= remaining - offset; +} + +inline uint64_t elapsed_nanos(std::chrono::steady_clock::time_point started) +{ + return static_cast( + std::chrono::duration_cast(std::chrono::steady_clock::now() - started).count()); +} + +inline uint64_t unix_time_ms() +{ + return static_cast( + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()) + .count()); +} +} // namespace crowdb::diskio diff --git a/app/crowdb-diskio/src/rpc/dio_server.cpp b/app/crowdb-diskio/src/rpc/dio_server.cpp index 830d43bdd..2515f0171 100644 --- a/app/crowdb-diskio/src/rpc/dio_server.cpp +++ b/app/crowdb-diskio/src/rpc/dio_server.cpp @@ -7,6 +7,7 @@ #include "crowdb-rpc/server/message.h" #include "crowdb-rpc/server/server.h" #include "disk/disk.h" +#include "rpc/dio_request.h" #include #include @@ -50,18 +51,12 @@ crowdb::common::metrics::LatencyHistogram &engine_fsync_latency_metric() return *metric; } -uint64_t elapsed_nanos(std::chrono::steady_clock::time_point started) -{ - return static_cast( - std::chrono::duration_cast(std::chrono::steady_clock::now() - started).count()); -} - } // namespace -DiskioServer::DiskioServer(std::shared_ptr disk_set, crowdb::rpc::Transport *transport, +DiskioServer::DiskioServer(std::shared_ptr disk_set, std::shared_ptr transport, uint64_t max_write_request_age_ms, uint64_t max_clock_skew_ms) : disk_set_(std::move(disk_set)), - transport_(transport), + transport_(std::move(transport)), aligned_writer_(), read_latency_(&engine_read_latency_metric()), write_latency_(&engine_write_latency_metric()), @@ -95,193 +90,41 @@ crowdb::rpc::Buffer *DiskioServer::build_response_ctrl(crowdb::rpc::BufferPool * if (buf == nullptr) { return nullptr; } - std::memcpy(buf->data, fbb.GetBufferPointer(), size); - buf->write(buf->data, size); + buf->write(fbb.GetBufferPointer(), size); return buf; } void DiskioServer::send_error_response(crowdb::rpc::Connection *conn, uint64_t request_id, uint64_t rpc_create_nano, uint16_t msg_type, int16_t ret_code) { - auto *pool = conn->pool(); - auto *ctrl = build_response_ctrl(pool, request_id, rpc_create_nano, ret_code, msg_type); - auto *out = crowdb::rpc::build_out_frame(request_id, msg_type, ctrl, nullptr); - out->create_nano = static_cast(std::chrono::steady_clock::now().time_since_epoch().count()); - transport_->submit(conn, out); -} - -static DiskId parse_disk_id(const crowdb::rpc::proto::FBInt128 *fb_id) -{ - if (fb_id == nullptr) { - return {0, 0}; - } - return {fb_id->high(), fb_id->low()}; -} - -crowdb::rpc::OutFrame *DiskioServer::handle_read(crowdb::rpc::Frame *request, crowdb::rpc::Connection *conn) -{ - uint64_t req_id = request->request_id; - uint64_t create_nano = request->rpc_create_nano; - uint16_t msg_type = static_cast(rproto::FBMsgType_EDiskReadResponse); - - auto *fb_req = ::flatbuffers::GetRoot(request->control.data()); - if (fb_req == nullptr || request->control.size() < 4) { - delete request; - send_error_response(conn, req_id, create_nano, msg_type, static_cast(dproto::FBDiskIoRetCode_IoError)); - return nullptr; - } - DiskId did = parse_disk_id(fb_req->disk_id()); - uint32_t zone_index = fb_req->zone_index(); - uint64_t zone_offset = fb_req->zone_offset(); - uint32_t size = fb_req->size(); - uint64_t test_pattern_offset = fb_req->test_pattern_offset(); - - auto disk = disk_set_->find_disk(did); - if (disk == nullptr) { - delete request; - send_error_response(conn, req_id, create_nano, msg_type, - static_cast(dproto::FBDiskIoRetCode_DiskNotExist)); - return nullptr; - } - - Zone *zone = disk->find_zone(zone_index); - if (zone == nullptr) { - delete request; - send_error_response(conn, req_id, create_nano, msg_type, - static_cast(dproto::FBDiskIoRetCode_ZoneNotExist)); - return nullptr; - } - if (zone->base_offset < 0 || zone->capacity < 0 || zone_offset > static_cast(zone->capacity) || - size > static_cast(zone->capacity) - zone_offset || - zone_offset > static_cast(std::numeric_limits::max() - zone->base_offset)) { - delete request; - send_error_response(conn, req_id, create_nano, msg_type, static_cast(dproto::FBDiskIoRetCode_IoError)); - return nullptr; - } - off_t phys_offset = zone->base_offset + static_cast(zone_offset); - - auto *pool = conn->pool(); - auto *read_buf = pool->alloc(size); - if (read_buf == nullptr) { - delete request; - send_error_response(conn, req_id, create_nano, msg_type, static_cast(dproto::FBDiskIoRetCode_IoError)); - return nullptr; - } - - auto io_offset = phys_offset; - size_t io_size = size; - size_t prefix = 0; - std::shared_ptr aligned_data; - if (disk->is_o_direct()) { - const size_t alignment = std::max(4096, disk->block_size()); - if ((alignment & (alignment - 1)) != 0 || alignment > static_cast(std::numeric_limits::max()) || - static_cast(phys_offset) + size > - static_cast(std::numeric_limits::max()) - alignment) { - read_buf->release(); - delete request; - send_error_response(conn, req_id, create_nano, msg_type, - static_cast(dproto::FBDiskIoRetCode_InvalidAlignment)); - return nullptr; - } - prefix = static_cast(phys_offset) % alignment; - io_offset = phys_offset - static_cast(prefix); - io_size = ((prefix + size + alignment - 1) / alignment) * alignment; - if (io_offset < zone->base_offset || - io_size > static_cast(zone->capacity - (io_offset - zone->base_offset))) { - read_buf->release(); - delete request; - send_error_response(conn, req_id, create_nano, msg_type, - static_cast(dproto::FBDiskIoRetCode_InvalidAlignment)); - return nullptr; - } - aligned_data = std::shared_ptr(static_cast(std::aligned_alloc(alignment, io_size)), - [](uint8_t *data) { std::free(data); }); - if (aligned_data == nullptr) { - read_buf->release(); - delete request; - send_error_response(conn, req_id, create_nano, msg_type, - static_cast(dproto::FBDiskIoRetCode_IoError)); - return nullptr; - } - } - - delete request; - - Disk *disk_ptr = disk.get(); - auto started = std::chrono::steady_clock::now(); - auto *io_data = aligned_data ? aligned_data.get() : read_buf->data; - disk_ptr->engine()->submit_read(disk_ptr, io_offset, io_data, io_size, test_pattern_offset, - [this, conn, req_id, create_nano, msg_type, read_buf, size, io_size, prefix, - aligned_data = std::move(aligned_data), disk = std::move(disk), started](int res) { - read_latency_->observe(elapsed_nanos(started)); - int16_t ret_code = static_cast(dproto::FBDiskIoRetCode_Success); - crowdb::rpc::Buffer *data = nullptr; - if (res < 0) { - ret_code = static_cast(dproto::FBDiskIoRetCode_IoError); - } - else if (static_cast(res) < io_size) { - ret_code = static_cast(dproto::FBDiskIoRetCode_PartialWrite); - } - else { - if (aligned_data) { - std::memcpy(read_buf->data, aligned_data.get() + prefix, size); - } - read_buf->len = size; - data = read_buf; - } - auto *pool = conn->pool(); - auto *ctrl = build_response_ctrl(pool, req_id, create_nano, ret_code, msg_type); - auto *out = crowdb::rpc::build_out_frame(req_id, msg_type, ctrl, data); - out->create_nano = static_cast( - std::chrono::steady_clock::now().time_since_epoch().count()); - transport_->submit(conn, out); - if (data == nullptr) { - read_buf->release(); - } - }); - - return nullptr; + send_response(transport_, conn->weak_from_this().lock(), request_id, rpc_create_nano, msg_type, ret_code); } -crowdb::rpc::OutFrame *DiskioServer::handle_fsync(crowdb::rpc::Frame *request, crowdb::rpc::Connection *conn) +void DiskioServer::send_response(const std::shared_ptr &transport, + const std::shared_ptr &connection, uint64_t request_id, + uint64_t created, uint16_t type, int16_t code, crowdb::rpc::Buffer *data, + bool sync_completed) { - uint64_t req_id = request->request_id; - uint64_t create_nano = request->rpc_create_nano; - uint16_t msg_type = static_cast(rproto::FBMsgType_EDiskFsyncResponse); - - auto *fb_req = ::flatbuffers::GetRoot(request->control.data()); - if (fb_req == nullptr || request->control.size() < 4) { - delete request; - send_error_response(conn, req_id, create_nano, msg_type, static_cast(dproto::FBDiskIoRetCode_IoError)); - return nullptr; + const auto release = [](crowdb::rpc::Buffer *buffer) { + if (buffer != nullptr) + buffer->release(); + }; + std::unique_ptr payload(data, release); + if (connection == nullptr || !connection->is_open()) + return; + auto *control = build_response_ctrl(connection->pool(), request_id, created, code, type, sync_completed); + if (control == nullptr) { + connection->close(); + return; } - DiskId did = parse_disk_id(fb_req->disk_id()); - - auto disk = disk_set_->find_disk(did); - if (disk == nullptr) { - delete request; - send_error_response(conn, req_id, create_nano, msg_type, - static_cast(dproto::FBDiskIoRetCode_DiskNotExist)); - return nullptr; + auto *frame = crowdb::rpc::build_out_frame(request_id, type, control, payload.release()); + frame->create_nano = static_cast(std::chrono::steady_clock::now().time_since_epoch().count()); + if (!transport->submit(connection.get(), frame)) { + control->release(); + frame->release_data(); + delete frame; + connection->close(); } - - delete request; - - Disk *disk_ptr = disk.get(); - auto started = std::chrono::steady_clock::now(); - disk_ptr->engine()->submit_fsync( - disk_ptr, [this, conn, req_id, create_nano, msg_type, started, disk = std::move(disk)](int res) { - fsync_latency_->observe(elapsed_nanos(started)); - int16_t ret_code = (res < 0) ? static_cast(dproto::FBDiskIoRetCode_IoError) - : static_cast(dproto::FBDiskIoRetCode_Success); - auto *pool = conn->pool(); - auto *ctrl = build_response_ctrl(pool, req_id, create_nano, ret_code, msg_type); - auto *out = crowdb::rpc::build_out_frame(req_id, msg_type, ctrl, nullptr); - out->create_nano = static_cast(std::chrono::steady_clock::now().time_since_epoch().count()); - transport_->submit(conn, out); - }); - - return nullptr; } void DiskioServer::register_handlers(crowdb::rpc::RpcServer &server) diff --git a/app/crowdb-diskio/src/rpc/dio_server.h b/app/crowdb-diskio/src/rpc/dio_server.h index 5eaedb7e0..e235c031f 100644 --- a/app/crowdb-diskio/src/rpc/dio_server.h +++ b/app/crowdb-diskio/src/rpc/dio_server.h @@ -39,7 +39,7 @@ namespace crowdb::diskio class DiskioServer { public: - DiskioServer(std::shared_ptr disk_set, crowdb::rpc::Transport *transport, + DiskioServer(std::shared_ptr disk_set, std::shared_ptr transport, uint64_t max_write_request_age_ms = protocol::kMaxWriteRequestAgeMs, uint64_t max_clock_skew_ms = protocol::kMaxWriteClockSkewMs); @@ -56,7 +56,7 @@ class DiskioServer private: std::shared_ptr disk_set_; - crowdb::rpc::Transport *transport_; + std::shared_ptr transport_; AlignedWriter aligned_writer_; crowdb::common::metrics::LatencyHistogram *read_latency_; crowdb::common::metrics::LatencyHistogram *write_latency_; @@ -67,13 +67,18 @@ class DiskioServer // Build a response control buffer for a diskio response msg_type. // ret_code is a proto::FBDiskIoRetCode value (int16_t to avoid // pulling the generated header into this .h). - crowdb::rpc::Buffer *build_response_ctrl(crowdb::rpc::BufferPool *pool, uint64_t request_id, - uint64_t rpc_create_nano, int16_t ret_code, uint16_t msg_type, - bool sync_completed = false); + static crowdb::rpc::Buffer *build_response_ctrl(crowdb::rpc::BufferPool *pool, uint64_t request_id, + uint64_t rpc_create_nano, int16_t ret_code, uint16_t msg_type, + bool sync_completed = false); // Send an error response synchronously (disk not found, parse error). void send_error_response(crowdb::rpc::Connection *conn, uint64_t request_id, uint64_t rpc_create_nano, uint16_t msg_type, int16_t ret_code); + + static void send_response(const std::shared_ptr &transport, + const std::shared_ptr &connection, uint64_t request_id, + uint64_t created, uint16_t type, int16_t code, crowdb::rpc::Buffer *data = nullptr, + bool sync_completed = false); }; } // namespace crowdb::diskio diff --git a/app/crowdb-diskio/src/rpc/dio_server_write.cpp b/app/crowdb-diskio/src/rpc/dio_server_write.cpp index 01f135c4a..8362b90ef 100644 --- a/app/crowdb-diskio/src/rpc/dio_server_write.cpp +++ b/app/crowdb-diskio/src/rpc/dio_server_write.cpp @@ -125,30 +125,36 @@ crowdb::rpc::OutFrame *DiskioServer::handle_write(crowdb::rpc::Frame *request, c send_error_response(conn, req_id, create_nano, msg_type, static_cast(dproto::FBDiskIoRetCode_IoError)); return nullptr; } - auto data = std::shared_ptr(data_buf, [](crowdb::rpc::Buffer *buffer) { + using crowdb::rpc::BufferOwnership; + const auto previous_owner = + data_buf == nullptr ? BufferOwnership::Available : data_buf->ownership.load(std::memory_order_acquire); + if (data_buf != nullptr && + ((previous_owner != BufferOwnership::CallerOwned && previous_owner != BufferOwnership::Available) || + !data_buf->transition(previous_owner, BufferOwnership::DiskOwned))) { + (void)data_buf->quarantine(); + data_buf->release(); + conn->close(); + return nullptr; + } + auto data = std::shared_ptr(data_buf, [previous_owner](crowdb::rpc::Buffer *buffer) { if (buffer != nullptr) { + if (!buffer->transition(BufferOwnership::DiskOwned, previous_owner)) { + (void)buffer->quarantine(); + } buffer->release(); } }); - auto complete = [this, connection = conn->shared_from_this(), data, req_id, create_nano, + auto complete = [transport = transport_, connection = conn->shared_from_this(), data, req_id, create_nano, msg_type](int16_t code, bool synchronized) { - auto *control = build_response_ctrl(connection->pool(), req_id, create_nano, code, msg_type, synchronized); - if (control == nullptr) { - return; - } - auto *out = crowdb::rpc::build_out_frame(req_id, msg_type, control, nullptr); - out->create_nano = static_cast(std::chrono::steady_clock::now().time_since_epoch().count()); - if (!transport_->submit(connection.get(), out)) { - control->release(); - delete out; - } + send_response(transport, connection, req_id, create_nano, msg_type, code, nullptr, synchronized); }; uint64_t ordering_phys_offset = zone->base_offset + ordering_zone_offset; auto started = std::chrono::steady_clock::now(); aligned_writer_.submit_ordered( - disk, phys_offset, data_buf != nullptr ? data_buf->data : nullptr, size, ordering_phys_offset, - [this, disk, complete = std::move(complete), sync, size, started](int res) mutable { - write_latency_->observe(elapsed_nanos(started)); + disk, phys_offset, data_buf != nullptr ? data_buf->payload_data() : nullptr, size, ordering_phys_offset, + [latency = write_latency_, flush_latency = fsync_latency_, disk, complete = std::move(complete), sync, size, + started](int res) mutable { + latency->observe(elapsed_nanos(started)); if (res == -ETIMEDOUT) { complete(static_cast(dproto::FBDiskIoRetCode_OldRequest), false); } @@ -163,13 +169,13 @@ crowdb::rpc::OutFrame *DiskioServer::handle_write(crowdb::rpc::Frame *request, c } else { auto flush_started = std::chrono::steady_clock::now(); - disk->engine()->submit_fsync( - disk.get(), [this, disk, complete = std::move(complete), flush_started](int result) mutable { - fsync_latency_->observe(elapsed_nanos(flush_started)); - complete(static_cast(result < 0 ? dproto::FBDiskIoRetCode_FsyncFailed - : dproto::FBDiskIoRetCode_Success), - result >= 0); - }); + disk->engine()->submit_fsync(disk.get(), [flush_latency, disk, complete = std::move(complete), + flush_started](int result) mutable { + flush_latency->observe(elapsed_nanos(flush_started)); + complete(static_cast(result < 0 ? dproto::FBDiskIoRetCode_FsyncFailed + : dproto::FBDiskIoRetCode_Success), + result >= 0); + }); } }, *deadline); diff --git a/app/crowdb-diskio/tests/common/synchronized_write.h b/app/crowdb-diskio/tests/common/synchronized_write.h index 8dfe30b56..0dade4742 100644 --- a/app/crowdb-diskio/tests/common/synchronized_write.h +++ b/app/crowdb-diskio/tests/common/synchronized_write.h @@ -172,7 +172,7 @@ class TestSynchronizedWrite : public testing::Test : disks(std::make_shared()), disk(std::make_shared(&engine)), connection(std::make_shared(1, "write-test", &pool)), - server(disks, &transport) + server(disks, transport_owner) { payload.fill(7); disks->add(disk); @@ -206,15 +206,16 @@ class TestSynchronizedWrite : public testing::Test return frame; } - rpc::SystemBufferPool pool; - TestWriteTransport transport; - TestWriteEngine engine; - std::shared_ptr disks; - std::shared_ptr disk; - std::shared_ptr connection; - dio::DiskioServer server; - std::array payload{}; - size_t released = 0; + rpc::SystemBufferPool pool; + std::shared_ptr transport_owner = std::make_shared(); + TestWriteTransport &transport = *transport_owner; + TestWriteEngine engine; + std::shared_ptr disks; + std::shared_ptr disk; + std::shared_ptr connection; + dio::DiskioServer server; + std::array payload{}; + size_t released = 0; }; } // namespace crowdb::diskio::test diff --git a/app/crowdb-diskio/tests/common/test_dio_hardware.h b/app/crowdb-diskio/tests/common/test_dio_hardware.h new file mode 100644 index 000000000..ffde27412 --- /dev/null +++ b/app/crowdb-diskio/tests/common/test_dio_hardware.h @@ -0,0 +1,266 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#pragma once + +#include "crowdb-common/runtime_path.h" +#include "crowdb-rpc/client/client.h" +#include "disk/block_disk.h" +#include "engine/uring/uring_engine.h" +#include "rpc/dio_server.h" + +#include +#include +#include + +#if defined(CROWDB_HAVE_LIBURING) +# include +# include + +# include +# include +# include +# include + +namespace crowdb::diskio +{ +class TestObservedEngine final : public IoEngine +{ + public: + struct ParkedWrite + { + Disk *disk; + off_t offset; + const uint8_t *data; + size_t size; + std::function done; + }; + + UringEngine io{32}; + std::atomic write_pointer{0}, read_pointer{0}; + std::atomic reads{0}; + std::atomic hold_writes{false}; + std::atomic parked{nullptr}; + std::shared_ptr> completed_writes = std::make_shared>(0); + + void submit_write(Disk *disk, off_t offset, const uint8_t *data, size_t size, + std::function done) override + { + write_pointer.store(reinterpret_cast(data)); + ParkedWrite write{disk, offset, data, size, std::move(done)}; + if (hold_writes.load(std::memory_order_acquire)) { + parked.store(new ParkedWrite(std::move(write)), std::memory_order_release); + return; + } + start_write(std::move(write)); + } + + void submit_read(Disk *disk, off_t offset, uint8_t *data, size_t size, uint64_t pattern, + std::function done) override + { + read_pointer.store(reinterpret_cast(data)); + reads.fetch_add(1); + io.submit_read(disk, offset, data, size, pattern, std::move(done)); + } + + void submit_fsync(Disk *disk, std::function done) override + { + io.submit_fsync(disk, std::move(done)); + } + + void resume_write() + { + auto pending = std::unique_ptr(parked.exchange(nullptr, std::memory_order_acq_rel)); + if (pending != nullptr) + start_write(std::move(*pending)); + } + + private: + void start_write(ParkedWrite write) + { + io.submit_write(write.disk, write.offset, write.data, write.size, + [done = std::move(write.done), completed = completed_writes](int result) { + done(result); + completed->fetch_add(1, std::memory_order_release); + }); + } +}; + +struct TestDiskCall +{ + std::atomic callbacks{0}; + crowdb_rpc_status status = CROWDB_RPC_ERR_CONN_ERROR; + crowdb_rpc_buffer_t control = nullptr, data = nullptr; + + ~TestDiskCall() + { + release_data(); + if (control != nullptr) + crowdb_rpc_buffer_release(control); + } + + void release_data() + { + if (data != nullptr) + crowdb_rpc_buffer_release(std::exchange(data, nullptr)); + } + + static void complete(uint64_t, crowdb_rpc_buffer_t control, crowdb_rpc_buffer_t data, crowdb_rpc_status status, + void *context) + { + const auto owner = + std::unique_ptr>(static_cast *>(context)); + auto &call = **owner; + call.control = control; + call.data = data; + call.status = status; + call.callbacks.fetch_add(1, std::memory_order_release); + } +}; + +class TestDioHardware : public ::testing::Test +{ + protected: + bool use_rdma = true; + bool allow_tcp_fallback = false; + static constexpr uint32_t BLOCK = 1 << 20; + std::shared_ptr engine; + std::shared_ptr disks; + std::unique_ptr server, client; + std::unique_ptr service; + std::shared_ptr peer; + crowdb::rpc::RpcClient rpc; + std::atomic receive_pointer{0}; + std::atomic read_receive_pointer{0}; + std::filesystem::path path; + + void SetUp() override + { + const auto *address = use_rdma ? std::getenv("CROWDB_RDMA_E2E_LISTEN_ADDR") : "127.0.0.1"; + if (use_rdma && (std::getenv("CROWDB_RDMA_E2E") == nullptr || address == nullptr)) + GTEST_SKIP() << "set CROWDB_RDMA_E2E and local HCA address"; +# ifndef CROWDB_RPC_HAVE_RDMA + if (use_rdma) + GTEST_SKIP() << "RDMA support not compiled"; +# endif + path = crowdb::common::test_runtime_path("diskio-rdma") / "block.bin"; + const auto fd = ::open(path.c_str(), O_CREAT | O_TRUNC | O_RDWR, 0600); + ASSERT_GE(fd, 0); + const auto sized = ::ftruncate(fd, 4 * BLOCK); + ::close(fd); + ASSERT_EQ(sized, 0); + engine = std::make_shared(); + auto disk = std::make_shared( + DiskId{ + 7, 9 + }, + path.string(), engine, std::vector{{.zone_index = 0, .base_offset = 0, .capacity = 4 * BLOCK}}, true); + ASSERT_GE(disk->fd(), 0); + ASSERT_TRUE(disk->is_o_direct()); + disks = std::make_shared(); + disks->add(std::move(disk)); + crowdb::rpc::RdmaConfig config = + crowdb::rpc::RdmaConfig::for_layout(crowdb::rpc::RdmaWireLayout::ControlData, BLOCK); + config.use_environment = false; + config.sq_depth = config.rq_depth = config.posted_receive_target = 4; + config.cq_depth = 8; + config.send_arena_bytes = 4 * BLOCK; + config.receive_arena_bytes = uint64_t{4} * (BLOCK + 1024); + config.source_address = address; + if (const auto *device = std::getenv("CROWDB_RDMA_DEVICE")) + config.device = device; + server = std::make_unique(); + ASSERT_TRUE(server->configure_rdma(config, use_rdma, allow_tcp_fallback, use_rdma)); + ASSERT_TRUE(server->listen(address, 0)); + service = std::make_unique(disks, server->shared_transport()); + service->register_handlers(*server); + const auto write_type = static_cast(crowdb::rpc::proto::FBMsgType_EDiskWriteRequest); + server->register_handler(write_type, [this](crowdb::rpc::Frame *frame, crowdb::rpc::Connection *connection) { + receive_pointer.store(reinterpret_cast(frame->data_buf->payload_data())); + return service->handle_write(frame, connection); + }); + const auto read_type = static_cast(crowdb::rpc::proto::FBMsgType_EDiskReadRequest); + server->register_handler(read_type, [this](crowdb::rpc::Frame *frame, crowdb::rpc::Connection *connection) { + read_receive_pointer.store( + frame->data_buf == nullptr ? 0 : reinterpret_cast(frame->data_buf->payload_data())); + return service->handle_read(frame, connection); + }); + server->start(); + client = std::make_unique(); + ASSERT_TRUE(client->configure_rdma(config, use_rdma, allow_tcp_fallback, false)); + client->start(); + peer = client->transport()->connect(address, server->listen_port()); + ASSERT_NE(peer, nullptr); + ASSERT_EQ(peer->path(), use_rdma ? crowdb::rpc::ConnectionPath::Rdma : crowdb::rpc::ConnectionPath::Tcp); + rpc.attach(peer.get()); + } + + void TearDown() override + { + if (engine != nullptr) + engine->resume_write(); + if (peer != nullptr) { + peer->close(); + rpc.fail_all(peer.get(), crowdb::rpc::RpcError::ConnectionClosed); + } + if (client != nullptr) + client->stop(); + if (server != nullptr) + server->stop(); + service.reset(); + disks.reset(); + engine.reset(); + if (!path.empty()) + std::filesystem::remove(path); + } + + template bool wait_for(Predicate predicate) + { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!predicate() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + return predicate(); + } + + std::shared_ptr call(uint64_t id, uint32_t index, bool writing) + { + flatbuffers::FlatBufferBuilder builder; + crowdb::rpc::proto::FBInt128 disk(7, 9); + uint16_t type; + crowdb::rpc::Buffer *data = nullptr; + if (writing) { + const auto now = static_cast(std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()); + builder.Finish( + proto::CreateFBDiskWriteRequest(builder, id, 0, &disk, 0, uint64_t{index} * BLOCK, BLOCK, 0, now)); + type = static_cast(crowdb::rpc::proto::FBMsgType_EDiskWriteRequest); + data = client->transport()->allocate_producer_buffer(BLOCK, use_rdma); + if (data == nullptr) + return nullptr; + for (uint32_t byte = 0; byte < BLOCK; ++byte) + data->data[byte] = static_cast(byte * 131U + index); + data->len = BLOCK; + } + else { + builder.Finish(proto::CreateFBDiskReadRequest(builder, id, 0, &disk, 0, uint64_t{index} * BLOCK, BLOCK)); + type = static_cast(crowdb::rpc::proto::FBMsgType_EDiskReadRequest); + } + auto *control = client->pool()->alloc(builder.GetSize()); + if (control == nullptr) { + if (data != nullptr) + data->release(); + return nullptr; + } + control->write(builder.GetBufferPointer(), builder.GetSize()); + auto result = std::make_shared(); + auto *context = new std::shared_ptr(result); + if (!rpc.send(client->transport(), peer.get(), id, control, data, type, TestDiskCall::complete, context)) { + delete context; + return nullptr; + } + return result; + } +}; +} // namespace crowdb::diskio +#endif diff --git a/app/crowdb-diskio/tests/dio_hardware_test.cpp b/app/crowdb-diskio/tests/dio_hardware_test.cpp new file mode 100644 index 000000000..7934ab86d --- /dev/null +++ b/app/crowdb-diskio/tests/dio_hardware_test.cpp @@ -0,0 +1,154 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#include "common/test_dio_hardware.h" + +#include + +#if defined(CROWDB_RPC_HAVE_RDMA) && defined(CROWDB_HAVE_LIBURING) +namespace crowdb::diskio +{ +namespace +{ +folly::dynamic test_status(const std::string &snapshot) +{ + folly::json::serialization_opts options; + // Capability generations span all uint64_t values. Preserve decimal + // counters exactly instead of forcing the whole snapshot through int64_t. + options.parse_numbers_as_strings = true; + return folly::parseJson(snapshot, options)["rdma"]; +} +} // namespace + +class TestDioFailure : public TestDioHardware +{ + protected: + void SetUp() override + { + allow_tcp_fallback = true; + TestDioHardware::SetUp(); + } +}; + +TEST_F(TestDioHardware, DirectBlockRoundTripKeepsRegisteredPointersAndBoundsHeldResponses) +{ + for (uint32_t index = 0; index < 3; ++index) { + auto written = call(index + 1, index, true); + ASSERT_NE(written, nullptr); + ASSERT_TRUE(wait_for([&] { return written->callbacks.load(std::memory_order_acquire) == 1; })); + ASSERT_EQ(written->status, CROWDB_RPC_OK); + ASSERT_NE(written->control, nullptr); + const auto *response = + flatbuffers::GetRoot(crowdb_rpc_buffer_data(written->control)); + ASSERT_EQ(response->ret_code(), proto::FBDiskIoRetCode_Success); + EXPECT_EQ(engine->write_pointer.load(), receive_pointer.load()); + EXPECT_EQ(engine->write_pointer.load() % 4096, 0U); + } + const auto registered_before = server->transport()->status_json(); + std::vector> reads; + for (uint32_t index = 0; index < 8; ++index) { + auto read = call(index + 100, index % 3, false); + ASSERT_NE(read, nullptr); + reads.push_back(std::move(read)); + } + ASSERT_TRUE(wait_for([&] { return engine->reads.load() == 8; })); + ASSERT_TRUE(wait_for([&] { + return std::count_if(reads.begin(), reads.end(), [](const auto &read) { + return read->callbacks.load(std::memory_order_acquire) == 1; + }) == 4; + })); + EXPECT_EQ(engine->read_pointer.load() % 4096, 0U); + EXPECT_EQ(engine->read_pointer.load(), read_receive_pointer.load()); + const auto before = test_status(registered_before); + const auto held = test_status(server->transport()->status_json()); + EXPECT_EQ(held["registered_bytes"], before["registered_bytes"]); + EXPECT_EQ(held["registered_bytes_high_water"], before["registered_bytes_high_water"]); + // Four caller-held response leases consume all client RQ slots. The next + // four replies retain the server's own receive data regions in SEND. + std::vector released(reads.size(), false); + size_t remaining = reads.size(); + while (remaining != 0) { + size_t index = reads.size(); + ASSERT_TRUE(wait_for([&] { + for (size_t candidate = 0; candidate < reads.size(); ++candidate) { + if (!released[candidate] && reads[candidate]->callbacks.load(std::memory_order_acquire) == 1) { + index = candidate; + return true; + } + } + return false; + })); + auto &read = reads[index]; + ASSERT_EQ(read->status, CROWDB_RPC_OK); + ASSERT_NE(read->control, nullptr); + const auto *response = flatbuffers::GetRoot(crowdb_rpc_buffer_data(read->control)); + ASSERT_EQ(response->ret_code(), proto::FBDiskIoRetCode_Success); + ASSERT_NE(read->data, nullptr); + ASSERT_EQ(crowdb_rpc_buffer_len(read->data), BLOCK); + const auto *bytes = crowdb_rpc_buffer_data(read->data); + std::vector expected(BLOCK); + for (uint32_t byte = 0; byte < BLOCK; ++byte) + expected[byte] = static_cast(byte * 131U + index % 3); + ASSERT_EQ(std::memcmp(bytes, expected.data(), BLOCK), 0); + read->release_data(); + released[index] = true; + --remaining; + } + const auto client_status = client->transport()->status_json(); + const auto server_status = server->transport()->status_json(); + const auto after = test_status(server_status); + EXPECT_EQ(after["registered_bytes"], before["registered_bytes"]); + EXPECT_EQ(after["registered_bytes_high_water"], before["registered_bytes_high_water"]); + for (const auto &status : {client_status, server_status}) { + EXPECT_NE(status.find("\"copied_payload_bytes\":0"), std::string::npos); + EXPECT_NE(status.find("\"fallback_count\":0"), std::string::npos); + EXPECT_NE(status.find("\"receive_post_errors\":0"), std::string::npos); + EXPECT_NE(status.find("\"send_errors\":0"), std::string::npos); + EXPECT_NE(status.find("\"protocol_errors\":0"), std::string::npos); + EXPECT_NE(status.find("\"cq_errors\":0"), std::string::npos); + } + EXPECT_NE(client_status.find("\"direct_payload_bytes\":3145728"), std::string::npos); + EXPECT_NE(server_status.find("\"direct_payload_bytes\":8388608"), std::string::npos); + RecordProperty("client_transport", client_status); + RecordProperty("server_transport_before_reads", registered_before); + RecordProperty("server_transport", server_status); + RecordProperty("topology", "single_process_hca_local_loop"); +} + +TEST_F(TestDioFailure, PeerCloseAndServerDestructionRetainPendingDiskDataWithoutFallback) +{ + engine->hold_writes.store(true, std::memory_order_release); + auto written = call(41, 0, true); + ASSERT_NE(written, nullptr); + ASSERT_TRUE(wait_for([&] { return engine->parked.load(std::memory_order_acquire) != nullptr; })); + peer->close(); + ASSERT_TRUE(wait_for([&] { return written->callbacks.load(std::memory_order_acquire) == 1; })); + EXPECT_EQ(written->status, CROWDB_RPC_ERR_CONN_CLOSED); + std::weak_ptr retained = server->shared_transport(); + server->stop(); + server.reset(); + ASSERT_FALSE(retained.expired()); + const auto *pending = engine->parked.load(std::memory_order_acquire); + ASSERT_NE(pending, nullptr); + EXPECT_EQ(reinterpret_cast(pending->data), receive_pointer.load()); + EXPECT_EQ(pending->data[0], 0U); + EXPECT_EQ(pending->data[BLOCK - 1], static_cast((BLOCK - 1) * 131U)); + engine->resume_write(); + ASSERT_TRUE(wait_for([&] { return engine->completed_writes->load(std::memory_order_acquire) == 1; })); + EXPECT_EQ(written->callbacks.load(), 1U); + EXPECT_NE(client->transport()->status_json().find("\"fallback_count\":0"), std::string::npos); + RecordProperty("client_transport", client->transport()->status_json()); + RecordProperty("expected_connection_errors", 1); + const auto fd = ::open(path.c_str(), O_RDONLY); + ASSERT_GE(fd, 0); + std::vector stored(BLOCK); + const auto bytes = ::pread(fd, stored.data(), stored.size(), 0); + ::close(fd); + ASSERT_EQ(bytes, BLOCK); + for (uint32_t index = 0; index < BLOCK; ++index) + ASSERT_EQ(stored[index], static_cast(index * 131U)); + service.reset(); + EXPECT_TRUE(retained.expired()); +} +} // namespace crowdb::diskio +#endif diff --git a/app/crowdb-diskio/tests/dio_lifetime_test.cpp b/app/crowdb-diskio/tests/dio_lifetime_test.cpp new file mode 100644 index 000000000..86f55a4a2 --- /dev/null +++ b/app/crowdb-diskio/tests/dio_lifetime_test.cpp @@ -0,0 +1,257 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#include "rpc/dio_server.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace crowdb::diskio; +using namespace crowdb::rpc; +namespace proto = crowdb::diskio::proto; + +class TestDeferredEngine final : public IoEngine +{ + public: + struct Pending + { + const uint8_t *data; + uint8_t *output; + size_t size; + std::function done; + }; + + std::vector pending; + + void submit_write(Disk *, off_t, const uint8_t *data, size_t size, std::function done) override + { + pending.push_back({data, nullptr, size, std::move(done)}); + } + + void submit_read(Disk *, off_t, uint8_t *data, size_t size, uint64_t, std::function done) override + { + pending.push_back({nullptr, data, size, std::move(done)}); + } + + void submit_fsync(Disk *, std::function done) override + { + pending.push_back({nullptr, nullptr, 0, std::move(done)}); + } + + void complete(int result) + { + ASSERT_FALSE(pending.empty()); + auto io = std::move(pending.front()); + pending.erase(pending.begin()); + if (io.output != nullptr && result > 0) + std::memset(io.output, 0x5a, std::min(io.size, static_cast(result))); + io.done(result); + } +}; + +class TestDeferredDisk final : public Disk +{ + public: + explicit TestDeferredDisk(IoEngine *engine) : engine_(engine) + { + zones_.push_back({.zone_index = 0, .base_offset = 0, .capacity = 8 << 20}); + } + + DiskType type() const override + { + return DiskType::Mem; + } + + int fd() const override + { + return 1; + } + + bool is_o_direct() const override + { + return false; + } + + size_t block_size() const override + { + return 1; + } + + IoEngine *engine() override + { + return engine_; + } + + DiskId id() const override + { + return {7, 9}; + } + + Zone *find_zone(uint32_t index) override + { + return index == 0 ? &zones_[0] : nullptr; + } + + private: + IoEngine *engine_; +}; + +class DioLifetimeTest : public ::testing::Test +{ + protected: + TestDeferredEngine engine; + std::shared_ptr disks = std::make_shared(); + std::unique_ptr server; + std::unique_ptr service; + std::shared_ptr connection; + + void SetUp() override + { + disks->add(std::make_shared(&engine)); + server = std::make_unique(); + ASSERT_TRUE(server->configure_rdma({}, false, false, false)); + connection = std::make_shared(1, "delayed", server->pool()); + // The unit fixture invokes handlers directly and records I/O only. + // Completion must safely discard a reply to this closed connection. + connection->close(); + service = std::make_unique(disks, server->shared_transport()); + } + + Frame *write(Buffer *data, uint64_t offset = 0, uint32_t size = 16, uint64_t ordering = 0) + { + flatbuffers::FlatBufferBuilder builder; + crowdb::rpc::proto::FBInt128 disk(7, 9); + const auto now = static_cast( + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()) + .count()); + auto request = proto::CreateFBDiskWriteRequest(builder, 17, 0, &disk, 0, offset, size, ordering, now); + builder.Finish(request); + auto *frame = new Frame; + frame->request_id = 17; + frame->control.assign(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + frame->data_buf = data; + return frame; + } + + Frame *read(uint32_t size) + { + flatbuffers::FlatBufferBuilder builder; + crowdb::rpc::proto::FBInt128 disk(7, 9); + builder.Finish(proto::CreateFBDiskReadRequest(builder, 19, 0, &disk, 0, 0, size)); + auto *frame = new Frame; + frame->request_id = 19; + frame->control.assign(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + return frame; + } +}; + +TEST_F(DioLifetimeTest, DiskOwnsExactPayloadUntilCompletionWithoutReplacementAdmission) +{ + RegisteredBufferPool buffers(4096, 4); + uint32_t releases = 0; + for (uint32_t index = 0; index < 4; ++index) { + auto *data = buffers.alloc(32); + ASSERT_NE(data, nullptr); + data->write("prefix!registered data for disk", 30); + data->view_offset = 7; + data->len = 16; + data->ownership.store(BufferOwnership::CallerOwned); + data->last_release_ctx = &releases; + data->last_release_cb = [](void *context, Buffer *buffer) { + ++*static_cast(context); + EXPECT_TRUE(buffer->transition(BufferOwnership::CallerOwned, BufferOwnership::Available)); + buffer->reset_view(); + buffer->release(); + }; + const auto *payload = data->payload_data(); + EXPECT_EQ(service->handle_write(write(data, index * 16, 16, index * 16), connection.get()), nullptr); + ASSERT_EQ(engine.pending.size(), index + 1); + EXPECT_EQ(engine.pending.back().data, payload); + EXPECT_EQ(data->ownership.load(), BufferOwnership::DiskOwned); + EXPECT_EQ(std::memcmp(payload, "registered data ", 16), 0); + } + EXPECT_EQ(releases, 0U); + EXPECT_EQ(buffers.alloc(1), nullptr); + engine.complete(16); + EXPECT_EQ(releases, 1U); + auto *returned = buffers.alloc(1); + ASSERT_NE(returned, nullptr); + EXPECT_EQ(buffers.alloc(1), nullptr); + returned->release(); + engine.complete(8); + engine.complete(-EIO); + engine.complete(-ECANCELED); + EXPECT_EQ(releases, 4U); + EXPECT_EQ(buffers.committed_bytes(), 4096U); + EXPECT_EQ(buffers.quarantined_bytes(), 0U); +} + +TEST_F(DioLifetimeTest, WriteCompletionRetainsTransportAndConnectionWhileServiceDrains) +{ + std::weak_ptr transport = server->shared_transport(); + std::weak_ptr peer = connection; + auto *data = server->pool()->alloc(16); + ASSERT_NE(data, nullptr); + data->write("delayed I/O data", 16); + service->handle_write(write(data), connection.get()); + ASSERT_EQ(engine.pending.size(), 1U); + auto shutdown = std::async(std::launch::async, [this] { + service.reset(); + server.reset(); + }); + connection.reset(); + EXPECT_EQ(shutdown.wait_for(std::chrono::milliseconds(10)), std::future_status::timeout); + EXPECT_FALSE(transport.expired()); + EXPECT_FALSE(peer.expired()); + EXPECT_EQ(std::memcmp(engine.pending.front().data, "delayed I/O data", 16), 0); + engine.complete(16); + shutdown.get(); + EXPECT_TRUE(peer.expired()); + EXPECT_TRUE(transport.expired()); +} + +TEST_F(DioLifetimeTest, ReadCompletionRetainsOutputAndTransportAfterBothFacadesDie) +{ + std::weak_ptr transport = server->shared_transport(); + service->handle_read(read(4096), connection.get()); + ASSERT_EQ(engine.pending.size(), 1U); + ASSERT_NE(engine.pending.front().output, nullptr); + service.reset(); + server.reset(); + connection.reset(); + EXPECT_FALSE(transport.expired()); + engine.complete(4096); + EXPECT_TRUE(transport.expired()); +} + +TEST_F(DioLifetimeTest, MalformedControlAndPayloadOrZoneBoundsNeverReachDisk) +{ + auto *malformed = new Frame; + malformed->control.assign(16, 0xff); + service->handle_write(malformed, connection.get()); + auto *short_data = server->pool()->alloc(8); + ASSERT_NE(short_data, nullptr); + short_data->len = 8; + service->handle_write(write(short_data), connection.get()); + for (auto offset : {uint64_t{8 << 20}, UINT64_MAX}) { + auto *data = server->pool()->alloc(16); + ASSERT_NE(data, nullptr); + data->len = 16; + service->handle_write(write(data, offset), connection.get()); + } + auto *data = server->pool()->alloc(16); + ASSERT_NE(data, nullptr); + data->len = 16; + service->handle_write(write(data, 0, 16, 1), connection.get()); + EXPECT_TRUE(engine.pending.empty()); +} +} // namespace diff --git a/app/crowdb-diskio/tests/dio_server_drain_test.cpp b/app/crowdb-diskio/tests/dio_server_drain_test.cpp index b7ea4c253..6f237c646 100644 --- a/app/crowdb-diskio/tests/dio_server_drain_test.cpp +++ b/app/crowdb-diskio/tests/dio_server_drain_test.cpp @@ -51,7 +51,7 @@ TEST_F(DiskioWriteDrain, MissingStaleAndUnboundedFutureTimesAreRejectedBeforeIo) TEST_F(DiskioWriteDrain, AQueuedExpiredWriteCannotRunBeforeTheFreshDrain) { - crowdb::diskio::DiskioServer guarded(disks, &transport, 50, 1); + crowdb::diskio::DiskioServer guarded(disks, transport_owner, 50, 1); guarded.handle_write(request(false, 0, 41), connection.get()); guarded.handle_write(request(false, 32, 42), connection.get()); EXPECT_EQ(engine.writes.size(), 1); diff --git a/app/crowdb-diskio/tests/dio_tcp_test.cpp b/app/crowdb-diskio/tests/dio_tcp_test.cpp new file mode 100644 index 000000000..41d37e16f --- /dev/null +++ b/app/crowdb-diskio/tests/dio_tcp_test.cpp @@ -0,0 +1,52 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#include "common/test_dio_hardware.h" + +#ifdef CROWDB_HAVE_LIBURING +namespace crowdb::diskio +{ +class TestDioTcp : public TestDioHardware +{ + protected: + void SetUp() override + { + use_rdma = false; + TestDioHardware::SetUp(); + } +}; + +TEST_F(TestDioTcp, DirectBlockCorpusMatchesRdmaBytesWithExplicitTcp) +{ + for (uint32_t index = 0; index < 3; ++index) { + auto written = call(index + 1, index, true); + ASSERT_NE(written, nullptr); + ASSERT_TRUE(wait_for([&] { return written->callbacks.load(std::memory_order_acquire) == 1; })); + ASSERT_EQ(written->status, CROWDB_RPC_OK); + ASSERT_NE(written->control, nullptr); + const auto *write_response = + flatbuffers::GetRoot(crowdb_rpc_buffer_data(written->control)); + ASSERT_EQ(write_response->ret_code(), proto::FBDiskIoRetCode_Success); + auto read = call(index + 100, index, false); + ASSERT_NE(read, nullptr); + ASSERT_TRUE(wait_for([&] { return read->callbacks.load(std::memory_order_acquire) == 1; })); + ASSERT_EQ(read->status, CROWDB_RPC_OK); + ASSERT_NE(read->control, nullptr); + const auto *read_response = + flatbuffers::GetRoot(crowdb_rpc_buffer_data(read->control)); + ASSERT_EQ(read_response->ret_code(), proto::FBDiskIoRetCode_Success); + ASSERT_NE(read->data, nullptr); + ASSERT_EQ(crowdb_rpc_buffer_len(read->data), BLOCK); + std::vector expected(BLOCK); + for (uint32_t byte = 0; byte < BLOCK; ++byte) + expected[byte] = static_cast(byte * 131U + index); + ASSERT_EQ(std::memcmp(crowdb_rpc_buffer_data(read->data), expected.data(), BLOCK), 0); + } + EXPECT_NE(client->transport()->status_json().find("\"rdma\":null"), std::string::npos); + EXPECT_NE(server->transport()->status_json().find("\"rdma\":null"), std::string::npos); + RecordProperty("client_transport", client->transport()->status_json()); + RecordProperty("server_transport", server->transport()->status_json()); + RecordProperty("topology", "single_process_tcp_loopback"); +} +} // namespace crowdb::diskio +#endif diff --git a/app/crowdb-kv-server/src/background/keepalive.rs b/app/crowdb-kv-server/src/background/keepalive.rs index 345288259..784109955 100644 --- a/app/crowdb-kv-server/src/background/keepalive.rs +++ b/app/crowdb-kv-server/src/background/keepalive.rs @@ -68,6 +68,7 @@ impl KeepAliveLoop { // Initial registration. let (stores, groups) = hosted_summary(®istry); + let registration_started = tokio::time::Instant::now(); let mut registered = if !discovery_ready { false } else if let Err(e) = svc @@ -83,8 +84,10 @@ impl KeepAliveLoop { let retry_interval = tokio::time::Duration::from_secs(1); let regular_interval = tokio::time::Duration::from_secs(interval_secs); + // The value is stamped before discovery and RPC retries. Starting + // its interval after completion would add that latency to its age. let mut ticker = tokio::time::interval_at( - tokio::time::Instant::now() + if registered { regular_interval } else { retry_interval }, + registration_started + if registered { regular_interval } else { retry_interval }, if registered { regular_interval } else { retry_interval }, ); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -99,6 +102,7 @@ impl KeepAliveLoop { continue; } let (stores, groups) = hosted_summary(®istry); + let heartbeat_started = tokio::time::Instant::now(); if let Err(e) = svc .heartbeat_kv_server(identity, &rpc_endpoint, &stores, &groups, "ok", &data_root) .await @@ -112,7 +116,7 @@ impl KeepAliveLoop { registered = true; info!(instance_id, "keep-alive: registered"); ticker = tokio::time::interval_at( - tokio::time::Instant::now() + regular_interval, + heartbeat_started + regular_interval, regular_interval, ); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); diff --git a/app/crowdb-kv-server/tests/group0_control_plane_test.rs b/app/crowdb-kv-server/tests/group0_control_plane_test.rs index 301bc90ea..423c6f0c4 100644 --- a/app/crowdb-kv-server/tests/group0_control_plane_test.rs +++ b/app/crowdb-kv-server/tests/group0_control_plane_test.rs @@ -45,7 +45,7 @@ async fn facade_reads_scans_and_revision_checks_without_rpc_loopback() { .unwrap_err(); assert!(matches!( error, - KvGroupOperationError::CompareFailed { current_revision } if current_revision == first_revision + KvGroupOperationError::CompareFailed { observation } if observation.revision == first_revision )); } diff --git a/app/crowdb-kv-server/tests/keepalive_recovery_test.rs b/app/crowdb-kv-server/tests/keepalive_recovery_test.rs index d7c039f47..ff7f45341 100644 --- a/app/crowdb-kv-server/tests/keepalive_recovery_test.rs +++ b/app/crowdb-kv-server/tests/keepalive_recovery_test.rs @@ -9,6 +9,90 @@ use crowdb_kv_server::{background::keepalive::KeepAliveLoop, store_registry::KvS use crowdb_protocol::common::KvServerIdentity; use std::{sync::Arc, time::Duration}; +struct TestDelayedTopology { + store: Arc, + first: std::sync::atomic::AtomicBool, + entered: tokio::sync::Notify, + release: tokio::sync::Notify, +} + +async fn delayed_topology(State(state): State>) -> Json { + if state.first.swap(false, std::sync::atomic::Ordering::AcqRel) { + state.entered.notify_one(); + state.release.notified().await; + } + Json(serde_json::json!({"stores": [state.store.status()]})) +} + +#[tokio::test] +async fn slow_registration_does_not_add_its_latency_to_the_first_heartbeat_interval() { + let store = Arc::new(PxKvStore::new(0, "127.0.0.1:0".parse().unwrap())); + store.add_group(PxGroup::new( + 0, + PxLocalReplica::new(1, PxLocalReplicaRole::Leader), + )); + store.start().await.unwrap(); + let registry = Arc::new(KvStoreRegistry::with_config(CrowDBConfig::for_tests())); + registry.add_store(0, &store); + let delay = Arc::new(TestDelayedTopology { + store: store.clone(), + first: std::sync::atomic::AtomicBool::new(true), + entered: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let management = format!("http://{}", listener.local_addr().unwrap()); + let router = Router::new() + .route("/topology", get(delayed_topology)) + .with_state(delay.clone()); + let http = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let keepalive = KeepAliveLoop::spawn( + registry, + KvServerIdentity { + instance_id: 7, + node_id: Some(1), + }, + management.clone(), + &store.listen_addr().unwrap().to_string(), + vec![management], + String::new(), + 1, + ); + tokio::time::timeout(Duration::from_secs(1), delay.entered.notified()) + .await + .unwrap(); + // Inject discovery latency after register() has stamped its first value. + tokio::time::sleep(Duration::from_millis(900)).await; + delay.release.notify_one(); + let client = CrowdbKvClient::new(ClientConfig::new(Vec::new())); + client.seed_leader(0, 0, store.listen_addr().unwrap().to_string()); + let observations = crowdb_kv_client::ServiceRegistryClient::new(client); + let initial = tokio::time::timeout(Duration::from_millis(250), async { + loop { + if let Some(value) = observations.read_instance("kv-server", 7).await.unwrap() { + break value.last_heartbeat_ms; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + tokio::time::timeout(Duration::from_millis(750), async { + loop { + let current = observations.read_instance("kv-server", 7).await.unwrap().unwrap(); + if current.last_heartbeat_ms > initial { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("registration latency must not postpone the next heartbeat beyond its original cadence"); + keepalive.stop().await; + http.abort(); + store.shutdown(Duration::from_secs(1)).await; +} + #[tokio::test] async fn a_recovered_singleton_registers_its_current_address_with_unreachable_retained_seeds() { async fn topology(State(store): State>) -> Json { diff --git a/app/crowdb-web/src/access/dataset.rs b/app/crowdb-web/src/access/dataset.rs index 55bdf4013..4be78cfb5 100644 --- a/app/crowdb-web/src/access/dataset.rs +++ b/app/crowdb-web/src/access/dataset.rs @@ -37,7 +37,7 @@ pub(super) async fn proxy(state: AppState, request: Request) -> Result Result { @@ -72,9 +72,18 @@ async fn forward(state: AppState, request: Request) -> Result>()).as_deref(), + Some(["", "namespaces", "dataset-ns", "datasets", dataset, "snapshots", snapshot]) + if !dataset.is_empty() && snapshot.len() == 32 && snapshot.bytes().all(|b| b.is_ascii_hexdigit())) + { + 8 * METADATA_LIMIT + } else { + 4 * METADATA_LIMIT + }; + let body = axum::body::to_bytes(request.into_body(), limit) .await - .map_err(|_| err_400("Dataset request exceeds 4 MiB"))?; + .map_err(|_| err_400("Dataset request exceeds its body limit"))?; let client = reqwest::Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) @@ -88,7 +97,7 @@ async fn forward(state: AppState, request: Request) -> Result Result METADATA_LIMIT { return Err(err_502("Dataset metadata response exceeds 1 MiB")); diff --git a/app/crowdb-web/src/state.rs b/app/crowdb-web/src/state.rs index 1385083ea..4a0428b51 100644 --- a/app/crowdb-web/src/state.rs +++ b/app/crowdb-web/src/state.rs @@ -513,7 +513,16 @@ impl AppState { // necessarily the leader — so it is NOT used to seed the leader // hint (see below). Only a live endpoint from the monitor cache // (the actual leader) overwrites the hint. - let (_, mgmt_seeds) = resolve_group0_endpoint(&config_snapshot); + let (_, mut mgmt_seeds) = resolve_group0_endpoint(&config_snapshot); + if self.node_monitor_url.is_some() { + if let Some(binding) = + crate::node::cluster_binding(self).map_err(|(_, body)| Error::Config(body.0.error))? + { + // A bound node can serve the first request before its console + // projection or monitor cache has observed the cluster. + mgmt_seeds = binding.management_seeds; + } + } let live_endpoint = self.monitor_cache.group0_leader_endpoint().await; // Only overwrite the leader hint with a live endpoint from the diff --git a/app/crowdb-web/tests/bound_client_test.rs b/app/crowdb-web/tests/bound_client_test.rs new file mode 100644 index 000000000..1d2433ad3 --- /dev/null +++ b/app/crowdb-web/tests/bound_client_test.rs @@ -0,0 +1,54 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/memory_kv.rs"] +mod memory_kv; + +use crowdb_console_shared::ConsoleConfig; +use crowdb_kv_client::{ClientConfig, CrowdbKvClient, GetOutcome, ReadMode}; +use crowdb_protocol::mgmt::{node::NodeBinding, SystemBootstrapIdentity}; +use crowdb_web::AppState; + +#[tokio::test] +async fn bound_operations_discover_group0_without_a_warmed_console_projection() { + let cluster = memory_kv::TestKvCluster::start().await; + let kv = CrowdbKvClient::new(ClientConfig::new(cluster.mgmt_endpoints.clone())); + kv.seed_leader(0, 0, cluster.group0_leader_endpoint.clone()); + kv.put(0, 0, b"binding-probe", b"committed", None).await.unwrap(); + let root = crowdb_test_harness::test_dirs::tempdir_in_test_data("bound-client"); + let state = AppState::with_runtime_root(ConsoleConfig::default(), root.path().join("console")) + .with_node_monitor("http://127.0.0.1:1") + .unwrap(); + // Bootstrap can create the shared client before the binding is committed. + let _ = state.kv_client().await; + std::fs::write( + root.path().join("node-binding.json"), + serde_json::to_vec(&NodeBinding { + node_id: 1, + bootstrap: SystemBootstrapIdentity { + cluster_id: "bound-client".into(), + operation_id: "bootstrap".into(), + configuration_digest: "a".repeat(64), + }, + management_seeds: cluster.mgmt_endpoints.clone(), + }) + .unwrap(), + ) + .unwrap(); + for cold in [false, true] { + if cold { + state.clear_cluster_clients().await; + } + assert!(state.monitor_cache.snapshot().await.is_empty()); + assert!(state.config.read().unwrap().servers.is_empty()); + let context = state.op_context().await.unwrap(); + let result = context + .kv() + .get(0, 0, b"binding-probe", ReadMode::Linearizable, None) + .await; + assert!( + matches!(result, Ok(GetOutcome::Found { ref value, .. }) if value.as_ref() == b"committed"), + "cold={cold}: {result:?}" + ); + } +} diff --git a/app/crowdb-web/tests/dataset_proxy_test.rs b/app/crowdb-web/tests/dataset_proxy_test.rs index a5f721e73..c159a0318 100644 --- a/app/crowdb-web/tests/dataset_proxy_test.rs +++ b/app/crowdb-web/tests/dataset_proxy_test.rs @@ -24,6 +24,15 @@ async fn fixture(readonly: bool) -> (axum::Router, tokio::task::JoinHandle<()>) async move { assert_eq!(request.headers()["authorization"], expected); assert!(request.uri().path().starts_with("/api/dataset/v2/")); + if request.uri().query() == Some("body_size=1") { + let bytes = axum::body::to_bytes(request.into_body(), 8 * 1024 * 1024) + .await + .unwrap(); + return ( + StatusCode::OK, + serde_json::json!({"length":bytes.len()}).to_string(), + ); + } if request.uri().query() == Some("large=1") { return (StatusCode::OK, "x".repeat(1024 * 1024 + 1)); } @@ -93,6 +102,12 @@ async fn readonly_allows_exact_matrix_but_rejects_all_mutations() { ("POST", format!("/snapshots/{snapshot}/matrix"), 200), ("GET", format!("/snapshots/{snapshot}/contains"), 200), ("POST", "/compositions".into(), 403), + ("PUT", format!("/snapshots/{snapshot}"), 403), + ( + "PUT", + format!("/snapshots/{snapshot}/split-revisions/{snapshot}"), + 403, + ), ("POST", format!("/snapshots/{snapshot}/reclaim"), 403), ("PUT", String::new(), 403), ("DELETE", "/uploads/123".into(), 403), @@ -113,3 +128,34 @@ async fn readonly_allows_exact_matrix_but_rejects_all_mutations() { } task.abort(); } + +#[tokio::test] +async fn complete_snapshot_inputs_get_eight_mib_without_expanding_upload_part_limits() { + let (app, task) = fixture(false).await; + let root = "/api/access/dataset/api/dataset/v2/namespaces/dataset-ns/datasets/example"; + let snapshot = "11111111111111111111111111111111"; + for (suffix, bytes, expected) in [ + (format!("/snapshots/{snapshot}"), 6 * 1024 * 1024, 200), + (format!("/snapshots/{snapshot}"), 8 * 1024 * 1024 + 1, 400), + (format!("/uploads/{snapshot}/parts/0"), 4 * 1024 * 1024 + 1, 400), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("{root}{suffix}?body_size=1")) + .body(Body::from(vec![b'x'; bytes])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status().as_u16(), expected); + if expected == 200 { + let body = axum::body::to_bytes(response.into_body(), 1024).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["length"], bytes); + } + } + task.abort(); +} diff --git a/app/crowdb-web/ui/src/dataset/Actions.tsx b/app/crowdb-web/ui/src/dataset/Actions.tsx index 493549855..2cdc747f0 100644 --- a/app/crowdb-web/ui/src/dataset/Actions.tsx +++ b/app/crowdb-web/ui/src/dataset/Actions.tsx @@ -4,26 +4,26 @@ import { useState } from 'react'; import { buttonClass, JsonView } from '../access/Workbench'; import * as api from './api'; -import type { DatasetSelection, Receipt, SplitSource } from './types'; +import type { DatasetSelection, PublicationResult, SplitSource } from './types'; import { savedOperation, type Operation } from './operation'; import { ImportEditor } from './import/Editor'; import { SplitEditor } from './splits/Editor'; -export function Actions({ selected, readonly, capabilities, complete }: { selected: DatasetSelection; readonly: boolean; capabilities: string[]; complete: (receipt?: Receipt) => void }) { - const [editor, setEditor] = useState<{ kind: 'composition' | 'split'; parent?: string; revision?: string; source?: SplitSource; recovery?: Operation }>(); +export function Actions({ selected, readonly, capabilities, complete }: { selected: DatasetSelection; readonly: boolean; capabilities: string[]; complete: (receipt?: PublicationResult) => void }) { + const [editor, setEditor] = useState<{ kind: 'snapshot' | 'split'; parent?: string; revision?: string; source?: SplitSource; recovery?: Operation }>(); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [observation, setObservation] = useState(); const [confirm, setConfirm] = useState(); const [unknown, setUnknown] = useState(); if (selected.kind === 'root') return null; const dataset = selected.dataset; const snapshot = 'snapshot' in selected ? selected.snapshot : undefined; const run = async (action: () => Promise) => { setBusy(true); setError(''); try { await action(); } catch (reason) { setError(String(reason)); } finally { setBusy(false); } }; - const open = (kind: 'composition' | 'split') => { + const open = async (kind: 'snapshot' | 'split') => { try { - const recovery = savedOperation(dataset); + const recovery = await savedOperation(dataset); if (recovery) { setEditor({ kind: recovery.kind, recovery, parent: recovery.snapshot }); return; } const source = 'split' in selected && selected.split !== undefined && selected.revision && snapshot ? { snapshot, revision: selected.revision, name: selected.split } : undefined; setEditor({ kind, parent: snapshot, source, revision: 'revision' in selected ? selected.revision : undefined }); } catch (reason) { setError(String(reason)); } }; - const done = (receipt: Receipt) => { setEditor(undefined); complete(receipt); }; + const done = (receipt: PublicationResult) => { setEditor(undefined); complete(receipt); }; const mutate = (action: 'retain' | 'release' | 'reclaim', target = snapshot!) => run(async () => { try { const result = await api.mutate(action === 'reclaim' ? 'snapshot-reclaim' : 'dataset-management', `${api.snapshotPath(dataset, target)}/${action}`, 'POST'); setObservation(result); setConfirm(undefined); complete(); @@ -31,23 +31,23 @@ export function Actions({ selected, readonly, capabilities, complete }: { select }); return
{!readonly &&
- - + + {snapshot && <> - + }
} - {!readonly && confirm &&

Reclaim {confirm}? Eligible data and metadata become unavailable. Latest, stable, retained, reachable or actively read Snapshots remain protected by the server.

} + {!readonly && confirm &&

Reclaim {confirm}? Eligible data and metadata become unavailable. Latest, stable, retained or reachable Snapshots remain protected. New reads stop after marking; cleanup follows the grace period.

} {snapshot && } {unknown &&
Reclamation outcome unknown for {unknown}. Inspect before continuing.
} {error &&

{error}

}{observation !== undefined && } - {!readonly && editor?.kind === 'composition' && setEditor(undefined)} complete={done} />} + {!readonly && editor?.kind === 'snapshot' && setEditor(undefined)} complete={done} />} {!readonly && editor?.kind === 'split' && editor.parent && setEditor(undefined)} complete={done} />}
; } diff --git a/app/crowdb-web/ui/src/dataset/BeginRecovery.tsx b/app/crowdb-web/ui/src/dataset/BeginRecovery.tsx deleted file mode 100644 index 7686e732a..000000000 --- a/app/crowdb-web/ui/src/dataset/BeginRecovery.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026-present Gian -// Licensed under the Apache License, Version 2.0. - -import { useState } from 'react'; -import { buttonClass } from '../access/Workbench'; -import * as api from './api'; -import { forgetOperation, operationPath, retryIdentical, type Operation } from './operation'; -import type { CompositionDraft, SplitDraft } from './types'; - -/** Creation may commit before its response reaches the browser. Keep its identity. */ -export function BeginRecovery({ operation, busy, run, loaded, discarded }: { - operation: Operation; busy: boolean; run: (action: () => Promise) => void; - discarded: () => void; - loaded: (draft: CompositionDraft | SplitDraft) => void; -}) { - const [missing, setMissing] = useState(false); - return
-

Draft creation needs inspection. A missing draft can be created by replaying the saved request with the same operation ID.

- - {missing && !operation.pending && } - {operation.pending && } -
; -} diff --git a/app/crowdb-web/ui/src/dataset/Publication.tsx b/app/crowdb-web/ui/src/dataset/Publication.tsx index 4f6b0f7e6..549ea0d18 100644 --- a/app/crowdb-web/ui/src/dataset/Publication.tsx +++ b/app/crowdb-web/ui/src/dataset/Publication.tsx @@ -1,75 +1,34 @@ // Copyright 2026-present Gian // Licensed under the Apache License, Version 2.0. -import { useState } from 'react'; import { buttonClass, JsonView } from '../access/Workbench'; -import * as api from './api'; -import type { CompositionDraft, CompositionPreview, Receipt, SplitDraft, SplitPreview, SplitRevision } from './types'; -import { forgetOperation, newAttempt, operationPath, reconcile, request, retryIdentical, saveOperation, type Operation } from './operation'; -import { cleanupUploads } from './import/cleanup'; -import { newId } from './import/upload'; +import type { PublicationResult } from './types'; +import { forgetOperation, prepare, publish, retryIdentical, useObservedHead, type Operation } from './operation'; -export function Publication({ operation: op, draft, update, complete, busy, run, restart }: { - operation: Operation; draft: CompositionDraft | SplitDraft; update: (draft: CompositionDraft | SplitDraft) => void; - restart?: () => Promise; - complete: (receipt: Receipt) => void; busy: boolean; run: (action: () => Promise) => void; +export function Publication({ operation: op, update, complete, close, busy, run }: { + operation: Operation; update: (op: Operation) => void; complete: (result: PublicationResult) => void; + close: () => void; busy: boolean; run: (action: () => Promise) => void; }) { - const [cleaned, setCleaned] = useState(false); - const [preview, setPreview] = useState(); - const [cleanup, setCleanup] = useState(); const [conflict, setConflict] = useState<{ generation: string | null }>(); - const path = operationPath(op); - const refresh = async () => update(await api.control(path)); - const state = draft.progress.state; - const evaluated = state === 'evaluated' || state === 'prepared'; - const importReady = op.kind === 'split' || op.importsComplete === true; - const ready = importReady && state === 'prepared' && (op.kind === 'split' || ('uploads' in draft && draft.uploads.publication_ready)); - const evaluate = async () => { - const expected_page = draft.progress.state === 'evaluating' ? draft.progress.next_page : '0'; - const value = await request(op, `${path}/evaluate`, 'POST', { expected_revision: draft.revision, expected_page }); - setPreview(value); await refresh(); + const finish = async (result?: PublicationResult) => { + if (result) { await forgetOperation(op); complete(result); } + else update({ ...op }); }; - const prepare = async () => { - await request(op, `${path}/prepare`, 'POST', { expected_revision: draft.revision }); await refresh(); - }; - const publish = async () => { - if (draft.progress.state !== 'prepared') throw new Error('Prepare this draft first.'); - op.attempt ??= newId(); saveOperation(op); - const target = draft.progress.target; - const receipt = await request(op, `${path}/publish`, 'POST', { attempt: op.attempt, expected_head: op.expectedHead, - ...(target.kind === 'snapshot' ? { snapshot: target.snapshot } : { revision: target.revision }) }); - forgetOperation(op); complete(receipt); - }; - return
-

{state === 'prepared' ? 'Prepared candidate' : `Draft · ${state}`}

-

Operation {op.operation} · edit revision {draft.revision}

-

{op.kind === 'composition' ? 'Successful publication advances Dataset latest. Stable and the chosen parent stay unchanged.' : 'Publication replaces the complete current Split set. Dataset latest stays unchanged.'}

- {preview && } - {'uploads' in draft &&
Upload ownership and initial Split validation
} - {draft.progress.state === 'prepared' && } + return
+

{op.prepared ? 'Complete result' : 'Complete input ready'}

+

{op.kind === 'snapshot' ? 'Snapshot' : 'Split revision'} {op.resource}

+

{op.kind === 'snapshot' ? 'Publishing selects this Snapshot as latest. Stable and the parent keep their current values.' : 'Publishing replaces the current Split set. Historical revisions remain available.'}

+ {op.prepared && } {op.pending ?
-

Request unresolved. Inspect status before retrying; do not start another operation.

- - - {op.kind === 'composition' && op.attempt && op.conflict && } - {op.kind === 'split' && op.conflict && } - {conflict &&

Observed head: {conflict.generation ?? 'none'}. Keep this candidate and parent; publish with a new commit attempt?

} +

Reply unavailable. The complete request is saved in this browser.

+
:
- - - - - - {state === 'abandoned' && } + {!op.prepared && op.input && } + + {op.conflict &&

The head changed. Review the returned values before replacing it with this complete result.

+ +
} +
} - {cleaned && restart && } - {cleanup !== undefined && } +

Complete results can be read by ID before publication. Discarding local input leaves unreferenced results for GC.

; } diff --git a/app/crowdb-web/ui/src/dataset/api.ts b/app/crowdb-web/ui/src/dataset/api.ts index 69df7d017..66fe356e8 100644 --- a/app/crowdb-web/ui/src/dataset/api.ts +++ b/app/crowdb-web/ui/src/dataset/api.ts @@ -17,7 +17,7 @@ export function decimal(value: string): bigint { return BigInt(value); } export class DatasetError extends Error { - constructor(readonly status: number, message: string, readonly unknownOutcome = false) { super(message); } + constructor(readonly status: number, message: string, readonly unknownOutcome = false, readonly observedHead?: unknown) { super(message); } } async function readBounded(response: Response): Promise { const reader = response.body?.getReader(); @@ -35,10 +35,10 @@ async function readBounded(response: Response): Promise { let result: unknown; try { result = JSON.parse(text); } catch { throw new DatasetError(response.status || 502, 'Dataset returned an invalid response.'); } if (!response.ok) { - const error = result as { message?: string; code?: string; error?: string | { message?: string; code?: string } }; + const error = result as { message?: string; code?: string; observed_head?: unknown; error?: string | { message?: string; code?: string } }; const message = error.message ?? (typeof error.error === 'string' ? error.error : error.error?.message); const detail = error.code && message !== error.code ? `${error.code}: ${message}` : message; - throw new DatasetError(response.status, detail ?? `Dataset HTTP ${response.status}`, response.status >= 500); + throw new DatasetError(response.status, detail ?? `Dataset HTTP ${response.status}`, response.status >= 500, error.observed_head); } return result; } @@ -52,7 +52,7 @@ export async function control(path: string, method = 'GET', body?: unknown, s }); } catch (reason) { if (signal?.aborted) throw reason; - throw new DatasetError(0, 'Dataset service is unavailable. Reconcile any pending operation before retrying.', method !== 'GET'); + throw new DatasetError(0, 'Dataset service is unavailable. Retry the saved complete request after reconnecting.', method !== 'GET'); } return await readBounded(response) as T; } diff --git a/app/crowdb-web/ui/src/dataset/editor.test.tsx b/app/crowdb-web/ui/src/dataset/editor.test.tsx index 911f097d7..388b015e5 100644 --- a/app/crowdb-web/ui/src/dataset/editor.test.tsx +++ b/app/crowdb-web/ui/src/dataset/editor.test.tsx @@ -8,9 +8,10 @@ import { defaultPolicy, type DraftSplit } from './types'; import { DomainProvider } from '../contexts/DomainContext'; import { DatasetView } from '../views/DatasetView'; import * as api from './api'; +vi.mock('./operation/storage', () => ({ readRequest: vi.fn(async () => undefined), writeRequest: vi.fn(async () => {}), removeRequest: vi.fn(async () => {}) })); vi.mock('./api', async original => ({ ...await original(), listDatasets: vi.fn(async () => ({ entries: [], after: null })), - capabilities: vi.fn(async () => ['dataset-management', 'composition']), + capabilities: vi.fn(async () => ['dataset-management', 'composition-preparation', 'snapshot-head-update']), mutate: vi.fn(async () => ({})), page: vi.fn(async () => ({ entries: [], after: null })), splitHead: vi.fn(async () => ({ revision: null, generation: null })), @@ -66,17 +67,33 @@ it('readonly exposes inspection without creation, import or lifecycle mutations' expect(api.mutate).not.toHaveBeenCalled(); expect(screen.queryByText('Session activity')).toBeNull(); }); -it('a conflict discovered by identical retry retains candidate and enables explicit head review', async () => { - const { request, retryIdentical, newAttempt } = await import('./operation'); - const operation: import('./operation').Operation = { dataset: 'retry', kind: 'composition', operation: '1'.repeat(32), expectedHead: null, attempt: '2'.repeat(32) }; - const body = { attempt: operation.attempt, snapshot: '3'.repeat(32), expected_head: null }; - vi.mocked(api.mutate).mockRejectedValueOnce(new api.DatasetError(502, 'outcome unknown', true)).mockRejectedValueOnce(new api.DatasetError(409, 'head conflict')); - await expect(request(operation, '/compositions/publish', 'POST', body)).rejects.toThrow('outcome unknown'); +it('identical retry preserves the candidate and uses the returned head without status queries', async () => { + const { publish, retryIdentical, useObservedHead } = await import('./operation'); + const operation: import('./operation').Operation = { version: 2, dataset: 'retry', kind: 'snapshot', resource: '3'.repeat(32), expectedHead: null, + prepared: { snapshot: '3'.repeat(32), initial_split: null, sample_count: '1' } }; + const observed = { latest: '5'.repeat(32), stable: null, generation: '4'.repeat(64) }; + vi.mocked(api.mutate).mockRejectedValueOnce(new api.DatasetError(502, 'outcome unknown', true)) + .mockRejectedValueOnce(new api.DatasetError(409, 'head conflict', false, observed)) + .mockResolvedValueOnce({ ...observed, latest: operation.resource }); + await expect(publish(operation)).rejects.toThrow('outcome unknown'); + expect(operation.pending).toEqual({ kind: 'publish', expectedHead: null, expectedRevision: undefined }); await expect(retryIdentical(operation)).rejects.toThrow('head conflict'); - expect(operation.pending?.body).toEqual(body); expect(operation.conflict).toBe(true); - newAttempt(operation, '4'.repeat(32)); - expect(operation.expectedHead).toBe('4'.repeat(32)); expect(operation.attempt).not.toBe(body.attempt); - expect(operation.pending).toBeUndefined(); + expect(operation.conflict).toEqual(observed); expect(operation.pending).toBeUndefined(); + await useObservedHead(operation); + expect(operation.expectedHead).toBe(observed.generation); expect(operation.resource).toBe('3'.repeat(32)); + expect((await publish(operation)).target).toEqual({ kind: 'snapshot', snapshot: operation.resource }); + const first = vi.mocked(api.mutate).mock.calls; + expect(first[0].slice(0, 4)).toEqual(first[1].slice(0, 4)); + expect(first[2][3]).toEqual({ snapshot: operation.resource, expected_head: observed.generation }); + expect(api.control).not.toHaveBeenCalled(); expect(api.head).not.toHaveBeenCalled(); +}); +it('storage failure stops a preparation before its network mutation', async () => { + const { prepare } = await import('./operation'); const { writeRequest } = await import('./operation/storage'); + vi.mocked(writeRequest).mockRejectedValueOnce(new Error('browser storage full')); + const operation: import('./operation').Operation = { version: 2, dataset: 'bounded', kind: 'split', resource: '3'.repeat(32), snapshot: '2'.repeat(32), expectedHead: null, expectedRevision: null, + input: { splits: [], policy: defaultPolicy } }; + await expect(prepare(operation)).rejects.toThrow('browser storage full'); + expect(api.mutate).not.toHaveBeenCalled(); }); it('reclamation confirmation keeps its exact Snapshot target across navigation', async () => { const { Actions } = await import('./Actions'); @@ -96,7 +113,7 @@ it('waits for the selected Split base before enabling edits so loading cannot er vi.mocked(api.splitHead).mockReturnValueOnce(new Promise(resolve => { loaded = resolve; })); render(); expect(screen.getByRole('button', { name: 'Add selection Split' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Save Split draft' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Prepare Split revision' })).toBeDisabled(); expect(screen.queryByLabelText('Split 1 name')).toBeNull(); await act(async () => loaded({ revision: null, generation: null })); fireEvent.click(screen.getByRole('button', { name: 'Add selection Split' })); diff --git a/app/crowdb-web/ui/src/dataset/import/Editor.tsx b/app/crowdb-web/ui/src/dataset/import/Editor.tsx index 1858fa76d..c6af1aeef 100644 --- a/app/crowdb-web/ui/src/dataset/import/Editor.tsx +++ b/app/crowdb-web/ui/src/dataset/import/Editor.tsx @@ -5,22 +5,19 @@ import { useEffect, useRef, useState } from 'react'; import { buttonClass, inputClass, JsonView } from '../../access/Workbench'; import * as api from '../api'; import { encoded } from '../identity'; -import { defaultPolicy, type CompositionDraft, type DraftSplit, type Predicate, type Receipt, type SplitPolicy, type SplitSource } from '../types'; -import { operationPath, type Operation } from '../operation'; +import { defaultPolicy, type DraftSplit, type Predicate, type PublicationResult, type SplitPolicy, type SplitSource } from '../types'; +import { forgetOperation, type Operation } from '../operation'; import { Definitions, PredicateInput, validateEditor } from '../splits/Definitions'; -import { BeginRecovery } from '../BeginRecovery'; import { Publication } from '../Publication'; import { parseMapping, type Mapping } from './mapping'; -import { CompositionSplits } from './Splits'; import { saveImport } from './transfer'; const emptyMapping = JSON.stringify({ version: 1, schema: { version: 1, fields: [] }, samples: [] }, null, 2); export function ImportEditor({ dataset, parent: initialParent, source: initialSource, recovery, close, complete }: { - dataset: string; parent?: string; source?: SplitSource; recovery?: Operation; close: () => void; complete: (receipt: Receipt) => void; + dataset: string; parent?: string; source?: SplitSource; recovery?: Operation; close: () => void; complete: (result: PublicationResult) => void; }) { const transfer = useRef(); useEffect(() => () => transfer.current?.abort(), []); - const [editingSplits, setEditingSplits] = useState(false); const [parent, setParent] = useState(initialParent ?? ''); const [frozenSource, setFrozenSource] = useState(initialSource); const [mode, setMode] = useState(initialSource ? 'split' : 'all'); @@ -31,16 +28,17 @@ export function ImportEditor({ dataset, parent: initialParent, source: initialSo const [files, setFiles] = useState([]); const [mapping, setMapping] = useState(); const [splits, setSplits] = useState([]); const [policy, setPolicy] = useState(defaultPolicy); const element = useRef(null); - const [op, setOp] = useState(recovery); const [draft, setDraft] = useState(); + const [op, setOp] = useState(recovery); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [progress, setProgress] = useState(''); const run = (action: () => Promise) => { setBusy(true); setError(''); void action().catch(reason => setError(String(reason))).finally(() => setBusy(false)); }; useEffect(() => { - if (!recovery) return; - const cancel = new AbortController(); - void api.control(operationPath(recovery), 'GET', undefined, cancel.signal).then(value => { - if (!cancel.signal.aborted) { setDraft(value); setParent(value.parent ?? ''); setSplits(value.splits); setPolicy(value.policy); setMode(value.source.kind); if (value.source.kind === 'split') { setFrozenSource(value.source.source); setFilterSplit(!!value.source.selection); if (value.source.selection) setPredicate(value.source.selection); } if (value.source.kind === 'selection') setPredicate(value.source.predicate); } - }).catch(reason => { if (!cancel.signal.aborted) setError(String(reason)); }); - return () => cancel.abort(); + const input = recovery?.input && 'parent' in recovery.input ? recovery.input : recovery?.source; + if (!input) return; + setParent(input.parent ?? ''); setSplits(input.splits); setPolicy(input.policy); + setMode(input.source.kind); + if (input.source.kind === 'split') { setFrozenSource(input.source.source); setFilterSplit(!!input.source.selection); if (input.source.selection) setPredicate(input.source.selection); } + if (input.source.kind === 'selection') setPredicate(input.source.predicate); + if (recovery?.mapping) setText(JSON.stringify(recovery.mapping, null, 2)); }, [recovery]); const review = () => { try { validateEditor(element.current); const value = parseMapping(text, files); if (parent) api.opaqueId(parent); setMapping(value); setError(''); } @@ -49,13 +47,13 @@ export function ImportEditor({ dataset, parent: initialParent, source: initialSo const save = async () => { validateEditor(element.current); transfer.current = new AbortController(); const signal = transfer.current.signal; - try { await saveImport({ dataset, parent, inherit, mode, predicate, frozenSource, filterSplit, splits, policy, op, mapping, files, signal, setOp, setDraft, setProgress }); } + try { await saveImport({ dataset, parent, inherit, mode, predicate, frozenSource, filterSplit, splits, policy, op, mapping, files, signal, setOp, setProgress }); } finally { transfer.current = undefined; } }; return

Import / New Snapshot

Local test/demo import · namespace dataset-ns · {dataset}

- {(!op || !op.importsComplete) && <> + {(!op || !op.input) && <>