From 05ca00a4dc130a2e5f42dbf3e7e9960741926622 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Tue, 24 Feb 2026 22:45:50 -0800 Subject: [PATCH 01/32] Add state upload: StateLogCorrelator, state snapshot uploads alongside log uploads Adds state snapshot upload capability to bd-logger: - StateLogCorrelator tracks state changes and coordinates snapshot uploads - State snapshots are uploaded before log uploads to ensure consistency - Integrates with retention registry to manage snapshot lifecycle - Adds skip_intent support and completion tracking to artifact uploader --- AGENTS.md | 11 + CLAUDE.md | 1 + bd-artifact-upload/src/uploader.rs | 105 +-- bd-artifact-upload/src/uploader_test.rs | 69 +- bd-crash-handler/src/lib.rs | 4 +- bd-crash-handler/src/monitor_test.rs | 25 +- bd-logger/Cargo.toml | 1 + bd-logger/src/builder.rs | 36 +- bd-logger/src/consumer.rs | 71 +- bd-logger/src/consumer_test.rs | 7 +- bd-logger/src/lib.rs | 3 + bd-logger/src/logger.rs | 23 + bd-logger/src/logger_test.rs | 2 + bd-logger/src/state_upload.rs | 501 ++++++++++++++ bd-logger/src/state_upload_test.rs | 361 ++++++++++ bd-logger/src/test/mod.rs | 1 + bd-logger/src/test/setup.rs | 18 + .../src/test/state_upload_integration.rs | 655 ++++++++++++++++++ bd-runtime/src/runtime.rs | 13 +- bd-state/src/lib.rs | 52 +- bd-test-helpers/src/runtime.rs | 1 + 21 files changed, 1868 insertions(+), 92 deletions(-) create mode 120000 CLAUDE.md create mode 100644 bd-logger/src/state_upload.rs create mode 100644 bd-logger/src/state_upload_test.rs create mode 100644 bd-logger/src/test/state_upload_integration.rs diff --git a/AGENTS.md b/AGENTS.md index d55214787..114e895af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,17 @@ ``` This should be placed at the top of the test file, after the license header and before imports. +## Formatting + +- **ONLY** use `cargo +nightly fmt` to format code. This command automatically picks up `rustfmt.toml` from the repo root. +- **NEVER** use any of these alternatives - they may not use the correct config: + - `cargo fmt` (stable rustfmt lacks required features) + - `rustfmt ` (may not find config) + - `cargo +nightly fmt -- .` (using `.` as path can break config discovery) + - Any editor/IDE auto-format (may use wrong rustfmt version or config) +- If you see unexpected whitespace-only changes across many files after formatting, STOP and investigate - the wrong formatter was likely used. +- The repo uses `edition = "2024"` and `imports_layout = "HorizontalVertical"` in rustfmt.toml - imports should be vertical (one per line), not horizontal. + ## Code Quality Checks - After generating or modifying code, always run clippy to check for static lint violations: `cargo clippy --workspace --bins --examples --tests -- --no-deps` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/bd-artifact-upload/src/uploader.rs b/bd-artifact-upload/src/uploader.rs index c05aa8af6..8020af28e 100644 --- a/bd-artifact-upload/src/uploader.rs +++ b/bd-artifact-upload/src/uploader.rs @@ -49,20 +49,6 @@ pub static REPORT_DIRECTORY: LazyLock = LazyLock::new(|| "report_upload /// The index file used for tracking all of the individual files. pub static REPORT_INDEX_FILE: LazyLock = LazyLock::new(|| "report_index.pb".into()); -#[derive(Default, Clone, Copy)] -pub enum ArtifactType { - #[default] - Report, -} - -impl ArtifactType { - fn to_type_id(self) -> &'static str { - match self { - Self::Report => "client_report", - } - } -} - // // FeatureFlag // @@ -110,11 +96,14 @@ impl SnappedFeatureFlag { struct NewUpload { uuid: Uuid, file: std::fs::File, - type_id: String, state: LogFields, timestamp: Option, session_id: String, feature_flags: Vec, + type_id: String, + skip_intent: bool, + /// Optional oneshot sender to notify the caller when the upload completes or is dropped. + completion_tx: Option>, } // Used for bounded_buffer logs @@ -140,11 +129,11 @@ impl MemorySized for SnappedFeatureFlag { impl MemorySized for NewUpload { fn size(&self) -> usize { std::mem::size_of::() - + self.type_id.len() + self.state.size() + std::mem::size_of::>() + self.session_id.len() + self.feature_flags.size() + + self.type_id.len() } } @@ -179,11 +168,13 @@ pub trait Client: Send + Sync { fn enqueue_upload( &self, file: std::fs::File, - type_id: String, state: LogFields, timestamp: Option, session_id: String, feature_flags: Vec, + type_id: String, + skip_intent: bool, + completion_tx: Option>, ) -> anyhow::Result; } @@ -193,15 +184,16 @@ pub struct UploadClient { } impl Client for UploadClient { - /// Dispatches a payload to be uploaded, returning the associated artifact UUID. fn enqueue_upload( &self, file: std::fs::File, - type_id: String, state: LogFields, timestamp: Option, session_id: String, feature_flags: Vec, + type_id: String, + skip_intent: bool, + completion_tx: Option>, ) -> anyhow::Result { let uuid = uuid::Uuid::new_v4(); @@ -210,11 +202,13 @@ impl Client for UploadClient { .try_send(NewUpload { uuid, file, - type_id, state, timestamp, session_id, feature_flags, + type_id, + skip_intent, + completion_tx, }) .inspect_err(|e| log::warn!("failed to enqueue artifact upload: {e:?}")); @@ -262,6 +256,9 @@ pub struct Uploader { index: VecDeque, + /// Oneshot senders waiting for upload confirmation, keyed by artifact name (uuid string). + completion_senders: HashMap>, + max_entries: IntWatch, initial_backoff_interval: DurationWatch, max_backoff_interval: DurationWatch, @@ -305,6 +302,7 @@ impl Uploader { time_provider, file_system, index: VecDeque::default(), + completion_senders: HashMap::default(), max_entries: runtime.register_int_watch(), initial_backoff_interval: runtime.register_duration_watch(), max_backoff_interval: runtime.register_duration_watch(), @@ -345,13 +343,10 @@ impl Uploader { { if next.pending_intent_negotiation { log::debug!("starting intent negotiation for {:?}", next.name); - self.intent_task_handle = Some(tokio::spawn(Self::perform_intent_negotiation( self.data_upload_tx.clone(), next.name.clone(), - next.type_id.clone().unwrap_or_default(), next.time.to_offset_date_time(), - next.metadata.clone(), bd_api::backoff_policy( &mut self.initial_backoff_interval, &mut self.max_backoff_interval, @@ -385,12 +380,13 @@ impl Uploader { return Ok(()); }; + + log::debug!("starting file upload for {:?}", next.name); self.upload_task_handle = Some(tokio::spawn(Self::upload_artifact( self.data_upload_tx.clone(), contents, next.name.clone(), - next.type_id.clone().unwrap_or_default(), next.time.to_offset_date_time(), next.session_id.clone(), bd_api::backoff_policy( @@ -417,22 +413,26 @@ impl Uploader { Some(NewUpload { uuid, file, - type_id, state, timestamp, session_id, feature_flags, + type_id, + skip_intent, + completion_tx, }) = self.upload_queued_rx.recv() => { log::debug!("tracking artifact: {uuid} for upload"); self .track_new_upload( uuid, file, - type_id, state, session_id, timestamp, feature_flags, + type_id, + skip_intent, + completion_tx, ) .await; } @@ -489,7 +489,7 @@ impl Uploader { let mut modified = false; let mut new_index = VecDeque::default(); let mut filenames = HashSet::new(); - for mut entry in self.index.drain(..) { + for entry in self.index.drain(..) { let file_path = REPORT_DIRECTORY.join(&entry.name); if !self .file_system @@ -504,13 +504,6 @@ impl Uploader { modified = true; continue; } - // Handle inserting a default type_id for entries that are missing it. This can happen for - // older versions of the uploader that didn't persist the type_id to disk. - // TODO(snowp): Remove this at some point in the future after. - if entry.type_id.as_deref().unwrap_or_default().is_empty() { - entry.type_id = Some(ArtifactType::default().to_type_id().to_string()); - modified = true; - } filenames.insert(entry.name.clone()); new_index.push_back(entry); } @@ -553,6 +546,11 @@ impl Uploader { self.stats.dropped_intent.inc(); let entry = &self.index.pop_front().ok_or(InvariantError::Invariant)?; + // Notify the caller that this upload was rejected. + if let Some(tx) = self.completion_senders.remove(&entry.name) { + let _ = tx.send(false); + } + if let Err(e) = self .file_system .delete_file(&REPORT_DIRECTORY.join(&entry.name)) @@ -589,6 +587,11 @@ impl Uploader { log::warn!("failed to delete artifact {:?}: {}", entry.name, e); } + // Notify the caller that the upload succeeded. + if let Some(tx) = self.completion_senders.remove(&entry.name) { + let _ = tx.send(true); + } + self.write_index().await; Ok(entry.name) @@ -607,11 +610,13 @@ impl Uploader { &mut self, uuid: Uuid, file: std::fs::File, - type_id: String, state: LogFields, session_id: String, timestamp: Option, feature_flags: Vec, + _type_id: String, + skip_intent: bool, + completion_tx: Option>, ) { // If we've reached our limit of entries, stop the entry currently being uploaded (the oldest // one) to make space for the newer one. @@ -621,7 +626,12 @@ impl Uploader { self.stats.dropped.inc(); self.stop_current_upload(); - self.index.pop_front(); + if let Some(evicted) = self.index.pop_front() { + // Notify any waiting caller that their upload was dropped. + if let Some(tx) = self.completion_senders.remove(&evicted.name) { + let _ = tx.send(false); + } + } } let uuid = uuid.to_string(); @@ -656,19 +666,13 @@ impl Uploader { // Only write the index after we've written the report file to disk to try to minimze the risk // of the file being written without a corresponding entry. - let type_id = if type_id.is_empty() { - ArtifactType::default().to_type_id().to_string() - } else { - type_id - }; self.index.push_back(Artifact { name: uuid.clone(), - type_id: Some(type_id), time: timestamp .unwrap_or_else(|| self.time_provider.now()) .into_proto(), session_id, - pending_intent_negotiation: true, + pending_intent_negotiation: !skip_intent, metadata: state .into_iter() .map(|(key, value)| (key.into(), value.into_proto())) @@ -694,6 +698,11 @@ impl Uploader { self.write_index().await; + // Store the completion sender before returning so it fires on upload or rejection. + if let Some(tx) = completion_tx { + self.completion_senders.insert(uuid.clone(), tx); + } + #[cfg(test)] if let Some(hooks) = &self.test_hooks { hooks.entry_received_tx.send(uuid.clone()).await.unwrap(); @@ -727,7 +736,6 @@ impl Uploader { data_upload_tx: tokio::sync::mpsc::Sender, contents: Vec, name: String, - type_id: String, timestamp: OffsetDateTime, session_id: String, mut retry_policy: ExponentialBackoff, @@ -747,7 +755,7 @@ impl Uploader { upload_uuid.clone(), UploadArtifactRequest { upload_uuid, - type_id: type_id.clone(), + type_id: "client_report".to_string(), contents: contents.clone(), artifact_id: name.clone(), time: timestamp.into_proto(), @@ -780,9 +788,7 @@ impl Uploader { async fn perform_intent_negotiation( data_upload_tx: tokio::sync::mpsc::Sender, id: String, - type_id: String, timestamp: OffsetDateTime, - state_metadata: HashMap, mut retry_policy: ExponentialBackoff, ) -> Result { loop { @@ -790,11 +796,12 @@ impl Uploader { let (tracked, response) = TrackedArtifactIntent::new( upload_uuid.clone(), UploadArtifactIntentRequest { - type_id: type_id.clone(), + type_id: "client_report".to_string(), artifact_id: id.clone(), intent_uuid: upload_uuid.clone(), time: timestamp.into_proto(), - metadata: state_metadata.clone(), + // TODO(snowp): Figure out how to send relevant metadata about the artifact here. + metadata: HashMap::new(), ..Default::default() }, ); diff --git a/bd-artifact-upload/src/uploader_test.rs b/bd-artifact-upload/src/uploader_test.rs index 7ca5b6549..76f50c123 100644 --- a/bd-artifact-upload/src/uploader_test.rs +++ b/bd-artifact-upload/src/uploader_test.rs @@ -161,11 +161,13 @@ async fn basic_flow() { .client .enqueue_upload( setup.make_file(b"abc"), - "client_report".to_string(), [("foo".into(), "bar".into())].into(), Some(timestamp), "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); @@ -220,7 +222,6 @@ async fn feature_flags() { .client .enqueue_upload( setup.make_file(b"abc"), - "client_report".to_string(), [("foo".into(), "bar".into())].into(), Some(timestamp), "session_id".to_string(), @@ -232,6 +233,9 @@ async fn feature_flags() { ), SnappedFeatureFlag::new("key2".to_string(), None, timestamp - 2.std_seconds()), ], + "client_report".to_string(), + false, + None, ) .unwrap(); @@ -246,7 +250,6 @@ async fn feature_flags() { decision: bd_api::upload::IntentDecision::UploadImmediately }).unwrap(); }); - let upload = setup.data_upload_rx.recv().await.unwrap(); assert_matches!(upload, DataUpload::ArtifactUpload(upload) => { assert_eq!(upload.payload.artifact_id, id.to_string()); @@ -296,11 +299,13 @@ async fn pending_upload_limit() { .client .enqueue_upload( setup.make_file(b"1"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -312,11 +317,13 @@ async fn pending_upload_limit() { .client .enqueue_upload( setup.make_file(b"2"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -327,11 +334,13 @@ async fn pending_upload_limit() { .client .enqueue_upload( setup.make_file(b"3"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -395,11 +404,13 @@ async fn inconsistent_state_missing_file() { .client .enqueue_upload( setup.make_file(b"1"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -410,11 +421,13 @@ async fn inconsistent_state_missing_file() { .client .enqueue_upload( setup.make_file(b"2"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -453,11 +466,13 @@ async fn inconsistent_state_extra_file() { .client .enqueue_upload( setup.make_file(b"1"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -524,11 +539,13 @@ async fn disk_persistence() { .client .enqueue_upload( setup.make_file(b"1"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -571,11 +588,13 @@ async fn inconsistent_state_missing_index() { .client .enqueue_upload( setup.make_file(b"1"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -595,11 +614,13 @@ async fn inconsistent_state_missing_index() { .client .enqueue_upload( setup.make_file(b"2"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -641,11 +662,13 @@ async fn new_entry_disk_full() { .client .enqueue_upload( setup.make_file(b"1"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -670,11 +693,13 @@ async fn new_entry_disk_full_after_received() { .client .enqueue_upload( setup.make_file(b"1"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -711,11 +736,13 @@ async fn intent_retries() { .client .enqueue_upload( setup.make_file(b"1"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -746,11 +773,13 @@ async fn intent_drop() { .client .enqueue_upload( setup.make_file(b"1"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -783,11 +812,13 @@ async fn upload_retries() { .client .enqueue_upload( setup.make_file(b"1"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( @@ -835,11 +866,13 @@ async fn normalize_type_id_on_load() { .client .enqueue_upload( setup.make_file(b"abc"), - "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], + "client_report".to_string(), + false, + None, ) .unwrap(); assert_eq!( diff --git a/bd-crash-handler/src/lib.rs b/bd-crash-handler/src/lib.rs index e1d62591b..9a89c2937 100644 --- a/bd-crash-handler/src/lib.rs +++ b/bd-crash-handler/src/lib.rs @@ -483,11 +483,13 @@ impl Monitor { let Ok(artifact_id) = self.artifact_client.enqueue_upload( file, - "client_report".to_string(), state_fields.clone(), timestamp, session_id.clone(), reporting_feature_flags.clone(), + "client_report".to_string(), + false, // Don't skip intent negotiation for crash reports + None, ) else { log::warn!( "Failed to enqueue issue report for upload: {}", diff --git a/bd-crash-handler/src/monitor_test.rs b/bd-crash-handler/src/monitor_test.rs index d75668c4b..68edb76f2 100644 --- a/bd-crash-handler/src/monitor_test.rs +++ b/bd-crash-handler/src/monitor_test.rs @@ -6,6 +6,7 @@ // https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt use crate::{Monitor, global_state}; +use bd_artifact_upload::SnappedFeatureFlag; use bd_client_common::init_lifecycle::InitLifecycleState; use bd_log_primitives::{AnnotatedLogFields, LogFields}; use bd_proto::flatbuffers::report::bitdrift_public::fbs::issue_reporting::v_1::{ @@ -177,7 +178,6 @@ impl Setup { bd_runtime::runtime::IntWatch::new_for_testing(0), ); - for (name, value) in flags { store .insert( @@ -374,11 +374,17 @@ impl Setup { make_mut(&mut self.upload_client) .expect_enqueue_upload() .withf( - move |mut file, ftype_id, fstate, ftimestamp, fsession_id, feature_flags| { + move |mut file, + fstate, + ftimestamp, + fsession_id, + feature_flags: &Vec, + _type_id, + _skip_intent, + _completion_tx| { let mut output = vec![]; file.read_to_end(&mut output).unwrap(); let content_match = output == content; - let type_id_match = ftype_id == "client_report"; let state_match = &state == fstate; let timestamp_match = ×tamp == ftimestamp; let session_match = session_id == *fsession_id; @@ -397,15 +403,10 @@ impl Setup { feature_flags.is_empty() }; - content_match - && type_id_match - && state_match - && timestamp_match - && session_match - && flags_match + content_match && state_match && timestamp_match && session_match && flags_match }, ) - .returning(move |_, _, _, _, _, _| Ok(uuid)); + .returning(move |_, _, _, _, _, _, _, _| Ok(uuid)); } } @@ -786,12 +787,12 @@ async fn file_watcher_processes_multiple_reports() { .expect_enqueue_upload() .times(1) .in_sequence(&mut seq) - .returning(move |_, _, _, _, _, _| Ok(uuid1)); + .returning(move |_, _, _, _, _, _, _, _| Ok(uuid1)); make_mut(&mut setup.upload_client) .expect_enqueue_upload() .times(1) .in_sequence(&mut seq) - .returning(move |_, _, _, _, _, _| Ok(uuid2)); + .returning(move |_, _, _, _, _, _, _, _| Ok(uuid2)); // Create two crash reports let data1 = CrashReportBuilder::new("Crash1").reason("error1").build(); diff --git a/bd-logger/Cargo.toml b/bd-logger/Cargo.toml index 942967b5d..42c6fa228 100644 --- a/bd-logger/Cargo.toml +++ b/bd-logger/Cargo.toml @@ -60,6 +60,7 @@ tokio.workspace = true tower.workspace = true tracing.workspace = true unwrap-infallible.workspace = true +uuid.workspace = true [dev-dependencies] assert_matches.workspace = true diff --git a/bd-logger/src/builder.rs b/bd-logger/src/builder.rs index a84c884fc..ea9150420 100644 --- a/bd-logger/src/builder.rs +++ b/bd-logger/src/builder.rs @@ -13,6 +13,7 @@ use crate::internal::InternalLogger; use crate::log_replay::LoggerReplay; use crate::logger::Logger; use crate::logging_state::UninitializedLoggingContext; +use crate::state_upload::StateUploadHandle; use crate::{InitParams, LogAttributesOverrides}; use bd_api::{ AggregatedNetworkQualityProvider, @@ -242,6 +243,9 @@ impl LoggerBuilder { let data_upload_tx_clone = data_upload_tx.clone(); let collector_clone = collector; + let state_storage_fallback = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let state_storage_fallback_for_future = state_storage_fallback.clone(); + let logger = Logger::new( maybe_shutdown_trigger, runtime_loader.clone(), @@ -253,6 +257,7 @@ impl LoggerBuilder { self.params.static_metadata.sdk_version(), self.params.store.clone(), sleep_mode_active_tx, + state_storage_fallback, ); let log = if self.internal_logger { Arc::new(InternalLogger::new( @@ -317,6 +322,11 @@ impl LoggerBuilder { log.log_internal("state store initialization failed, using in-memory fallback"); } + state_storage_fallback_for_future.store( + result.fallback_occurred, + std::sync::atomic::Ordering::Relaxed, + ); + let (artifact_uploader, artifact_client) = bd_artifact_upload::Uploader::new( Arc::new(RealFileSystem::new(self.params.sdk_directory.clone())), data_upload_tx_clone.clone(), @@ -325,11 +335,30 @@ impl LoggerBuilder { &collector_clone, shutdown_handle.make_shutdown(), ); + let artifact_client: Arc = Arc::new(artifact_client); + + // Create state-log correlator for uploading state snapshots alongside logs + let snapshot_creation_interval_ms = + *bd_runtime::runtime::state::SnapshotCreationIntervalMs::register(&runtime_loader) + .into_inner() + .borrow(); + let (state_correlator_inner, state_upload_worker) = StateUploadHandle::new( + Some(state_directory.clone()), + self.params.store.clone(), + Some(retention_registry.clone()), + Some(Arc::new(state_store.clone())), + snapshot_creation_interval_ms, + time_provider.clone(), + artifact_client.clone(), + &scope, + ) + .await; + let state_correlator = Arc::new(state_correlator_inner); let crash_monitor = Monitor::new( &self.params.sdk_directory, self.params.store.clone(), - Arc::new(artifact_client), + artifact_client.clone(), self.params.session_strategy.clone(), &init_lifecycle, state_store.clone(), @@ -365,6 +394,7 @@ impl LoggerBuilder { trigger_upload_rx, &scope, log.clone(), + Some(state_correlator), ); let updater = Arc::new(client_config::Config::new( @@ -429,6 +459,10 @@ impl LoggerBuilder { async move { artifact_uploader.run().await; Ok(()) + }, + async move { + state_upload_worker.run().await; + Ok(()) } ) .map(|_| ()) diff --git a/bd-logger/src/consumer.rs b/bd-logger/src/consumer.rs index 533f7539a..50e191342 100644 --- a/bd-logger/src/consumer.rs +++ b/bd-logger/src/consumer.rs @@ -10,8 +10,9 @@ mod consumer_test; use crate::service::{self, UploadRequest}; +use crate::state_upload::StateUploadHandle; +use bd_api::TriggerUpload; use bd_api::upload::LogBatch; -use bd_api::{DataUpload, TriggerUpload}; use bd_buffer::{AbslCode, Buffer, BufferEvent, BufferEventWithResponse, Consumer, Error}; use bd_client_common::error::InvariantError; use bd_client_common::maybe_await; @@ -92,17 +93,21 @@ pub struct BufferUploadManager { stream_buffer_shutdown_trigger: Option, old_logs_dropped: Counter, + + // State-log correlator for uploading state snapshots before logs. + state_correlator: Option>, } impl BufferUploadManager { pub(crate) fn new( - data_upload_tx: Sender, + data_upload_tx: tokio::sync::mpsc::Sender, runtime_loader: &Arc, shutdown: ComponentShutdown, buffer_event_rx: Receiver, trigger_upload_rx: Receiver, stats: &Scope, logging: Arc, + state_correlator: Option>, ) -> Self { Self { log_upload_service: service::new(data_upload_tx, shutdown.clone(), runtime_loader, stats), @@ -122,6 +127,7 @@ impl BufferUploadManager { logging, stream_buffer_shutdown_trigger: None, old_logs_dropped: stats.counter("old_logs_dropped"), + state_correlator, } } @@ -163,7 +169,6 @@ impl BufferUploadManager { ) -> anyhow::Result<()> { log::debug!("received trigger upload request"); - let mut buffer_upload_completions = vec![]; for buffer_id in trigger_upload.buffer_ids { @@ -306,12 +311,14 @@ impl BufferUploadManager { let consumer = buffer.clone().register_consumer()?; let batch_builder = BatchBuilder::new(self.feature_flags.clone()); + let state_correlator = self.state_correlator.clone(); tokio::task::spawn(async move { StreamedBufferUpload { consumer, log_upload_service, batch_builder, shutdown, + state_correlator, } .start() .await @@ -355,6 +362,7 @@ impl BufferUploadManager { self.feature_flags.clone(), shutdown_trigger.make_shutdown(), buffer_name.to_string(), + self.state_correlator.clone(), ), shutdown_trigger, )) @@ -373,6 +381,7 @@ impl BufferUploadManager { self.log_upload_service.clone(), buffer_name.to_string(), self.old_logs_dropped.clone(), + self.state_correlator.clone(), )) } } @@ -414,6 +423,24 @@ impl BatchBuilder { max_batch_size_bytes <= self.total_bytes || max_batch_size_logs <= self.logs.len() } + /// Extracts the timestamp range (oldest, newest) from the current batch of logs. + /// Returns None if there are no logs with extractable timestamps. + fn timestamp_range_micros(&self) -> Option<(u64, u64)> { + let mut oldest: Option = None; + let mut newest: Option = None; + + for log_bytes in &self.logs { + if let Some(ts) = EncodableLog::extract_timestamp(log_bytes) { + let ts_micros = + ts.unix_timestamp().cast_unsigned() * 1_000_000 + u64::from(ts.microsecond()); + oldest = Some(oldest.map_or(ts_micros, |o| o.min(ts_micros))); + newest = Some(newest.map_or(ts_micros, |n| n.max(ts_micros))); + } + } + + oldest.zip(newest) + } + /// Consumes the current batch, resetting all accounting. fn take(&mut self) -> Vec> { self.total_bytes = 0; @@ -444,6 +471,9 @@ struct ContinuousBufferUploader { feature_flags: Flags, buffer_id: String, + + // State-log correlator for uploading state snapshots before logs. + state_correlator: Option>, } impl ContinuousBufferUploader { @@ -453,6 +483,7 @@ impl ContinuousBufferUploader { feature_flags: Flags, shutdown: ComponentShutdown, buffer_id: String, + state_correlator: Option>, ) -> Self { Self { consumer, @@ -462,6 +493,7 @@ impl ContinuousBufferUploader { batch_builder: BatchBuilder::new(feature_flags.clone()), feature_flags, buffer_id, + state_correlator, } } // Attempts to upload all logs in the provided buffer. For every polling interval we @@ -507,11 +539,19 @@ impl ContinuousBufferUploader { // Disarm the deadline which forces a partial flush to fire. self.flush_batch_sleep = None; + // Extract timestamps before taking logs from the batch + let timestamp_range = self.batch_builder.timestamp_range_micros(); + let logs = self.batch_builder.take(); let logs_len = logs.len(); log::debug!("flushing {logs_len} logs"); + // Upload state snapshot if needed before uploading logs + if let (Some(correlator), Some((oldest, newest))) = (&self.state_correlator, timestamp_range) { + correlator.notify_upload_needed(oldest, newest); + } + // Attempt to perform an upload of these buffers, with retries ++. See logger/service.rs for // details about retry policies etc. let upload_future = async { @@ -565,6 +605,9 @@ struct StreamedBufferUpload { batch_builder: BatchBuilder, shutdown: ComponentShutdown, + + // State-log correlator for uploading state snapshots before logs. + state_correlator: Option>, } impl StreamedBufferUpload { @@ -622,6 +665,15 @@ impl StreamedBufferUpload { } } + // Extract timestamps before taking logs from the batch + let timestamp_range = self.batch_builder.timestamp_range_micros(); + + // Upload state snapshot if needed before uploading logs + if let (Some(correlator), Some((oldest, newest))) = (&self.state_correlator, timestamp_range) + { + correlator.notify_upload_needed(oldest, newest); + } + let upload_future = async { self .log_upload_service @@ -668,6 +720,9 @@ struct CompleteBufferUpload { lookback_window: Option, old_logs_dropped: Counter, + + // State-log correlator for uploading state snapshots before logs. + state_correlator: Option>, } impl CompleteBufferUpload { @@ -677,6 +732,7 @@ impl CompleteBufferUpload { log_upload_service: service::Upload, buffer_id: String, old_logs_dropped: Counter, + state_correlator: Option>, ) -> Self { let lookback_window_limit = *runtime_flags.upload_lookback_window_feature_flag.read(); @@ -693,6 +749,7 @@ impl CompleteBufferUpload { buffer_id, lookback_window, old_logs_dropped, + state_correlator, } } @@ -743,10 +800,18 @@ impl CompleteBufferUpload { } async fn flush_batch(&mut self) -> anyhow::Result<()> { + // Extract timestamps before taking logs from the batch + let timestamp_range = self.batch_builder.timestamp_range_micros(); + let logs = self.batch_builder.take(); log::debug!("flushing {} logs", logs.len()); + // Upload state snapshot if needed before uploading logs + if let (Some(correlator), Some((oldest, newest))) = (&self.state_correlator, timestamp_range) { + correlator.notify_upload_needed(oldest, newest); + } + // Attempt to perform an upload of these buffers, with retries ++. See logger/service.rs for // details about retry policies etc. let result = self diff --git a/bd-logger/src/consumer_test.rs b/bd-logger/src/consumer_test.rs index 31a182ece..a387a2c4e 100644 --- a/bd-logger/src/consumer_test.rs +++ b/bd-logger/src/consumer_test.rs @@ -86,6 +86,7 @@ impl SetupSingleConsumer { make_flags(&runtime_loader), shutdown_trigger.make_shutdown(), "buffer".to_string(), + None, ); tokio::spawn(async move { uploader.consume_continuous_logs().await }); @@ -626,6 +627,7 @@ impl SetupMultiConsumer { trigger_upload_rx, &collector_clone.scope("consumer"), bd_internal_logging::NoopLogger::new(), + None, ) .run() .await @@ -892,7 +894,6 @@ async fn log_streaming() { let runtime_loader = ConfigLoader::new(&PathBuf::from(".")); let (log_upload_tx, mut log_upload_rx) = tokio::sync::mpsc::channel(1); - let upload_service = service::new( log_upload_tx, shutdown_trigger.make_shutdown(), @@ -906,6 +907,7 @@ async fn log_streaming() { log_upload_service: upload_service, shutdown: shutdown_trigger.make_shutdown(), batch_builder: BatchBuilder::new(make_flags(&runtime_loader)), + state_correlator: None, } .start() .await @@ -962,6 +964,7 @@ async fn streaming_batch_size_flag() { log_upload_service: upload_service, batch_builder: BatchBuilder::new(make_flags(&runtime_loader)), shutdown: shutdown_trigger.make_shutdown(), + state_correlator: None, } .start() .await @@ -1004,7 +1007,6 @@ async fn log_streaming_shutdown() { let runtime_loader = ConfigLoader::new(&PathBuf::from(".")); let (log_upload_tx, mut log_upload_rx) = tokio::sync::mpsc::channel(1); - let upload_service = service::new( log_upload_tx, global_shutdown_trigger.make_shutdown(), @@ -1020,6 +1022,7 @@ async fn log_streaming_shutdown() { log_upload_service: upload_service, shutdown, batch_builder: BatchBuilder::new(make_flags(&runtime_loader)), + state_correlator: None, } .start() .await diff --git a/bd-logger/src/lib.rs b/bd-logger/src/lib.rs index 445a321f7..0fbb88487 100644 --- a/bd-logger/src/lib.rs +++ b/bd-logger/src/lib.rs @@ -32,6 +32,9 @@ mod network; mod ordered_receiver; mod pre_config_buffer; mod service; +mod state_upload; + +pub use state_upload::{SnapshotRef, StateUploadHandle}; #[cfg(test)] mod test; diff --git a/bd-logger/src/logger.rs b/bd-logger/src/logger.rs index fdc64abce..634d1dcce 100644 --- a/bd-logger/src/logger.rs +++ b/bd-logger/src/logger.rs @@ -35,6 +35,7 @@ use parking_lot::Mutex; use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use time::ext::NumericalDuration; use tokio::sync::mpsc::{Receiver, Sender}; @@ -176,6 +177,8 @@ pub struct LoggerHandle { stats: Stats, sleep_mode_active: watch::Sender, + + state_storage_fallback: Arc, } impl LoggerHandle { @@ -306,6 +309,15 @@ impl LoggerHandle { "_session_strategy".into(), AnnotatedLogField::new_ootb(self.session_strategy.type_name()), ), + ( + "_state_storage_fallback".into(), + AnnotatedLogField::new_ootb( + self + .state_storage_fallback + .load(Ordering::Relaxed) + .to_string(), + ), + ), ]); self.log( @@ -320,6 +332,12 @@ impl LoggerHandle { ); } + pub fn set_state_storage_fallback(&self, occurred: bool) { + self + .state_storage_fallback + .store(occurred, Ordering::Relaxed); + } + #[must_use] pub fn should_log_app_update( &self, @@ -533,6 +551,8 @@ pub struct Logger { stats_scope: Scope, sleep_mode_active: watch::Sender, + + state_storage_fallback: Arc, } impl Logger { @@ -547,6 +567,7 @@ impl Logger { sdk_version: &str, store: Arc, sleep_mode_active: watch::Sender, + state_storage_fallback: Arc, ) -> Self { let stats = Stats::new(&stats_scope); @@ -565,6 +586,7 @@ impl Logger { stats_scope, store, sleep_mode_active, + state_storage_fallback, } } @@ -619,6 +641,7 @@ impl Logger { app_version_repo: Repository::new(self.store.clone()), stats: self.stats.clone(), sleep_mode_active: self.sleep_mode_active.clone(), + state_storage_fallback: self.state_storage_fallback.clone(), } } } diff --git a/bd-logger/src/logger_test.rs b/bd-logger/src/logger_test.rs index 5820442d4..b1a851927 100644 --- a/bd-logger/src/logger_test.rs +++ b/bd-logger/src/logger_test.rs @@ -17,6 +17,7 @@ use bd_session::fixed::{self, UUIDCallbacks}; use bd_test_helpers::session::in_memory_store; use futures_util::poll; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use tokio::pin; use tokio::sync::watch; use tokio_test::assert_pending; @@ -39,6 +40,7 @@ async fn thread_local_logger_guard() { sdk_version: "1.0.0".into(), app_version_repo: Repository::new(store), sleep_mode_active: watch::channel(false).0, + state_storage_fallback: Arc::new(AtomicBool::new(false)), }; with_thread_local_logger_guard(|| { diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs new file mode 100644 index 000000000..4c5fa3218 --- /dev/null +++ b/bd-logger/src/state_upload.rs @@ -0,0 +1,501 @@ +// shared-core - bitdrift's common client/server libraries +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +//! State snapshot upload coordination for log uploads. +//! +//! This module provides the [`StateLogCorrelator`] which tracks the correlation between log uploads +//! and state snapshots. The server correlates logs with state by timestamp - logs at time T use the +//! most recent state snapshot uploaded before time T. +//! +//! The correlator ensures that: +//! - State snapshots are uploaded before logs that depend on them +//! - Duplicate snapshot uploads are avoided across multiple buffers +//! - Snapshot coverage is tracked across process restarts via persistence +//! +//! ## Architecture +//! +//! Upload coordination is split into two parts: +//! +//! - [`StateLogCorrelator`] — a cheap, cloneable handle held by each buffer uploader. Callers +//! fire-and-forget upload requests via [`StateLogCorrelator::notify_upload_needed`], which sends +//! to a bounded channel without blocking. +//! +//! - [`StateUploadWorker`] — a single background task that owns all snapshot creation and upload +//! logic. Because only one task processes requests, deduplication and cooldown enforcement happen +//! naturally without any locking between callers. + +#[cfg(test)] +#[path = "./state_upload_test.rs"] +mod tests; + +use bd_artifact_upload::Client as ArtifactClient; +use bd_client_stats_store::{Counter, Scope}; +use bd_log_primitives::LogFields; +use bd_resilient_kv::SnapshotFilename; +use bd_state::{RetentionHandle, RetentionRegistry}; +use bd_time::TimeProvider; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use time::OffsetDateTime; +use tokio::sync::mpsc; + +/// Capacity of the upload request channel. Requests beyond this are silently dropped — the worker +/// will process the queued requests which already cover the needed state range. +const UPLOAD_CHANNEL_CAPACITY: usize = 8; + +/// Key for persisting the state upload index via bd-key-value. +static STATE_UPLOAD_KEY: bd_key_value::Key = + bd_key_value::Key::new("state_upload.uploaded_through.1"); + + +/// A reference to a state snapshot that should be uploaded. +#[derive(Debug, Clone)] +pub struct SnapshotRef { + /// The timestamp of the snapshot (microseconds since epoch). + pub timestamp_micros: u64, + /// The generation number of the snapshot file. + pub generation: u64, + /// Path to the snapshot file. + pub path: PathBuf, +} + +/// A request from a buffer uploader to upload a state snapshot if needed. +struct StateUploadRequest { + batch_oldest_micros: u64, + batch_newest_micros: u64, +} + +/// Statistics for state upload operations. +struct Stats { + snapshots_uploaded: Counter, + snapshots_skipped: Counter, + upload_failures: Counter, +} + +impl Stats { + fn new(scope: &Scope) -> Self { + Self { + snapshots_uploaded: scope.counter("snapshots_uploaded"), + snapshots_skipped: scope.counter("snapshots_skipped"), + upload_failures: scope.counter("upload_failures"), + } + } +} + +/// Tracks correlation between log uploads and state snapshot coverage. +/// +/// This is a lightweight, cloneable sender handle. Buffer uploaders call +/// [`notify_upload_needed`][Self::notify_upload_needed] in a fire-and-forget manner — +/// the call is non-blocking and never waits for the snapshot to be created or uploaded. +/// +/// All actual snapshot creation and upload logic is handled by the companion +/// [`StateUploadWorker`], which runs as a single background task. +pub struct StateUploadHandle { + /// Channel for sending upload requests to the background worker. + upload_tx: mpsc::Sender, +} + +impl StateUploadHandle { + /// Creates a new correlator and its companion worker. + /// + /// The returned [`StateUploadWorker`] must be spawned (e.g. via `tokio::spawn` or included in a + /// `try_join!`) for snapshot uploads to be processed. The correlator handle can be cloned and + /// shared across multiple buffer uploaders. + /// + /// # Arguments + /// * `state_store_path` - Path to the state store directory containing snapshot files + /// * `store` - Key-value store for persisting upload index + /// * `retention_registry` - Registry for managing snapshot retention to prevent cleanup + /// * `state_store` - Optional state store for triggering on-demand snapshots + /// * `snapshot_creation_interval_ms` - Minimum interval between snapshot creations (ms) + /// * `time_provider` - Time provider for getting current time + /// * `artifact_client` - Client for uploading snapshot artifacts + /// * `stats_scope` - Stats scope for metrics + pub async fn new( + state_store_path: Option, + store: Arc, + retention_registry: Option>, + state_store: Option>, + snapshot_creation_interval_ms: u32, + time_provider: Arc, + artifact_client: Arc, + stats_scope: &Scope, + ) -> (Self, StateUploadWorker) { + let stats = Stats::new(&stats_scope.scope("state_upload")); + + let retention_handle = match &retention_registry { + Some(registry) => Some(registry.create_handle().await), + None => None, + }; + + let uploaded_through = store + .get_string(&STATE_UPLOAD_KEY) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + if uploaded_through > 0 { + log::debug!("loaded state upload coverage through {uploaded_through}"); + } + + if let Some(handle) = &retention_handle + && uploaded_through > 0 + { + handle.update_retention_micros(uploaded_through); + } + + let (upload_tx, upload_rx) = mpsc::channel(UPLOAD_CHANNEL_CAPACITY); + + let state_uploaded_through_micros = Arc::new(AtomicU64::new(uploaded_through)); + + let correlator = Self { upload_tx }; + + let worker = StateUploadWorker { + state_uploaded_through_micros, + last_snapshot_creation_micros: AtomicU64::new(0), + snapshot_creation_interval_micros: u64::from(snapshot_creation_interval_ms) * 1000, + state_store_path, + store, + retention_handle, + state_store, + time_provider, + artifact_client, + upload_rx, + stats, + }; + + (correlator, worker) + } + + /// Notifies the background worker that a state snapshot upload may be needed for a log batch. + /// + /// This is non-blocking: the request is queued in a bounded channel and the worker processes it + /// asynchronously. If the channel is full, the request is silently dropped — the worker will + /// still cover the needed state range via already-queued requests. + pub fn notify_upload_needed(&self, batch_oldest_micros: u64, batch_newest_micros: u64) { + let _ = self.upload_tx.try_send(StateUploadRequest { + batch_oldest_micros, + batch_newest_micros, + }); + } +} + + + +// +// StateUploadWorker +// + +/// Background task that processes state snapshot upload requests. +/// +/// There is exactly one worker per logger instance. Because all upload logic runs in a single +/// task, deduplication and cooldown enforcement require no synchronization. +/// +/// Obtain via [`StateLogCorrelator::new`] and spawn with `tokio::spawn` or `try_join!`. +pub struct StateUploadWorker { + /// Shared with the correlator — updated after successful uploads. + state_uploaded_through_micros: Arc, + + /// Timestamp of the last snapshot creation (microseconds since epoch). + last_snapshot_creation_micros: AtomicU64, + + /// Minimum interval between snapshot creations (microseconds). + snapshot_creation_interval_micros: u64, + + /// Path to the state store directory (for finding snapshot files). + state_store_path: Option, + + /// Key-value store for persisting upload index across restarts. + store: Arc, + + /// Retention handle for preventing snapshot cleanup. + retention_handle: Option, + + /// State store for triggering on-demand snapshot creation before uploads. + state_store: Option>, + + time_provider: Arc, + + /// Artifact client for uploading snapshots. + artifact_client: Arc, + + /// Receiver for upload requests from correlator handles. + upload_rx: mpsc::Receiver, + + stats: Stats, +} + + +impl StateUploadWorker { + /// Returns the path to the state store directory, if configured. + #[must_use] + pub fn state_store_path(&self) -> Option<&Path> { + self.state_store_path.as_deref() + } + + /// Runs the worker event loop, processing upload requests until the channel is closed. + pub async fn run(mut self) { + log::debug!("state upload worker started"); + while let Some(request) = self.upload_rx.recv().await { + // Drain any additional pending requests to coalesce: keep the widest timestamp range + // across all queued requests so we do the minimum number of snapshots. + let mut oldest = request.batch_oldest_micros; + let mut newest = request.batch_newest_micros; + + while let Ok(extra) = self.upload_rx.try_recv() { + oldest = oldest.min(extra.batch_oldest_micros); + newest = newest.max(extra.batch_newest_micros); + } + + self.process_upload(oldest, newest).await; + } + } + + async fn process_upload(&self, batch_oldest_micros: u64, batch_newest_micros: u64) { + let uploaded_through = self.state_uploaded_through_micros.load(Ordering::Relaxed); + let last_change = self + .state_store + .as_ref() + .map_or(0, |s| s.last_change_micros()); + + // If we've never seen any state changes, no upload needed. + if last_change == 0 { + log::debug!( + "state upload: last_change=0, skipping (uploaded_through={uploaded_through}, \ + batch_oldest={batch_oldest_micros})" + ); + return; + } + + // If we've already uploaded state that covers this batch, no upload needed. + if uploaded_through >= batch_oldest_micros { + self.stats.snapshots_skipped.inc(); + return; + } + + // If there are no pending state changes since our last upload, no upload needed. + if last_change <= uploaded_through { + self.stats.snapshots_skipped.inc(); + return; + } + + // Find all snapshot files in (uploaded_through, batch_newest_micros] that need uploading. + let mut snapshots = self.find_snapshots_in_range(uploaded_through, batch_newest_micros); + + // If no existing snapshots cover the range, create one on-demand. + if snapshots.is_empty() { + if let Some(snapshot) = self.get_or_create_snapshot(batch_oldest_micros).await { + snapshots.push(snapshot); + } else { + return; + } + } + + // Upload each snapshot in order, advancing the watermark after each confirmed upload. + for snapshot_ref in snapshots { + log::debug!( + "uploading state snapshot {} for log batch (oldest={}, newest={})", + snapshot_ref.timestamp_micros, + batch_oldest_micros, + batch_newest_micros + ); + + // Open the snapshot file. + let file = match File::open(&snapshot_ref.path) { + Ok(f) => f, + Err(e) => { + log::warn!( + "failed to open snapshot file {}: {e}", + snapshot_ref.path.display() + ); + self.stats.upload_failures.inc(); + return; + }, + }; + + // Convert timestamp from microseconds to OffsetDateTime. + let timestamp = + OffsetDateTime::from_unix_timestamp_nanos(i128::from(snapshot_ref.timestamp_micros) * 1000) + .ok(); + + // Create a oneshot channel so we know when the upload is confirmed or dropped. + let (completion_tx, completion_rx) = tokio::sync::oneshot::channel::(); + + // Enqueue the upload via artifact uploader (skip_intent=true for immediate upload). + match self.artifact_client.enqueue_upload( + file, + LogFields::new(), + timestamp, + "state_snapshot".to_string(), + vec![], + "state_snapshot".to_string(), + true, + Some(completion_tx), + ) { + Ok(_uuid) => { + log::debug!( + "state snapshot upload enqueued for timestamp {}", + snapshot_ref.timestamp_micros + ); + if completion_rx.await == Ok(true) { + self.on_state_uploaded(snapshot_ref.timestamp_micros); + } else { + log::warn!("state snapshot upload failed or was dropped — watermark not advanced"); + self.stats.upload_failures.inc(); + // Watermark NOT advanced — will retry on next notify. + return; + } + }, + Err(e) => { + log::warn!("failed to enqueue state snapshot upload: {e}"); + self.stats.upload_failures.inc(); + return; + }, + } + } + } + + /// Called after a state snapshot has been successfully uploaded. + fn on_state_uploaded(&self, snapshot_timestamp_micros: u64) { + self + .state_uploaded_through_micros + .fetch_max(snapshot_timestamp_micros, Ordering::Relaxed); + self.stats.snapshots_uploaded.inc(); + + // Update retention handle to allow cleanup of snapshots older than what we've uploaded. + if let Some(handle) = &self.retention_handle { + handle.update_retention_micros(snapshot_timestamp_micros); + } + + // Persist the updated coverage. + self + .store + .set_string(&STATE_UPLOAD_KEY, &snapshot_timestamp_micros.to_string()); + } + + /// Finds all snapshot files in the range `(after_micros, up_to_micros]`, sorted oldest first. + /// + /// This ensures we upload every state change that occurred during the batch window, not just the + /// most recent one. + pub(crate) fn find_snapshots_in_range( + &self, + after_micros: u64, + up_to_micros: u64, + ) -> Vec { + let Some(state_path) = self.state_store_path.as_ref() else { + return vec![]; + }; + let snapshots_dir = state_path.join("snapshots"); + + let Ok(entries) = std::fs::read_dir(&snapshots_dir) else { + return vec![]; + }; + + let mut found: Vec = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let filename = path.file_name().and_then(|f| f.to_str())?.to_owned(); + let parsed = SnapshotFilename::parse(&filename)?; + if parsed.timestamp_micros > after_micros && parsed.timestamp_micros <= up_to_micros { + Some(SnapshotRef { + timestamp_micros: parsed.timestamp_micros, + generation: parsed.generation, + path, + }) + } else { + None + } + }) + .collect(); + + found.sort_by_key(|s| s.timestamp_micros); + found + } + + /// Returns the most recently created snapshot with timestamp ≤ `up_to_micros`, if any. + fn find_most_recent_snapshot(&self, up_to_micros: u64) -> Option { + let state_path = self.state_store_path.as_ref()?; + let snapshots_dir = state_path.join("snapshots"); + let entries = std::fs::read_dir(&snapshots_dir).ok()?; + entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let filename = path.file_name().and_then(|f| f.to_str())?.to_owned(); + let parsed = SnapshotFilename::parse(&filename)?; + if parsed.timestamp_micros <= up_to_micros { + Some(SnapshotRef { + timestamp_micros: parsed.timestamp_micros, + generation: parsed.generation, + path, + }) + } else { + None + } + }) + .max_by_key(|s| s.timestamp_micros) + } + + /// Finds an existing snapshot or creates a new one for the given timestamp. + /// + /// Implements cooldown logic to prevent excessive snapshot creation during high-volume log + /// streaming. If a snapshot was created recently (within `snapshot_creation_interval_micros`), + /// returns `None` to skip this upload cycle. + pub(crate) async fn get_or_create_snapshot( + &self, + batch_oldest_micros: u64, + ) -> Option { + let state_store = self.state_store.as_ref()?; + + let now_micros = { + let now = self.time_provider.now(); + now.unix_timestamp().cast_unsigned() * 1_000_000 + u64::from(now.microsecond()) + }; + let last_creation = self.last_snapshot_creation_micros.load(Ordering::Relaxed); + if last_creation > 0 + && now_micros.saturating_sub(last_creation) < self.snapshot_creation_interval_micros + { + log::debug!( + "skipping snapshot creation due to cooldown (last={last_creation}, now={now_micros}, \ + interval={})", + self.snapshot_creation_interval_micros + ); + self.stats.snapshots_skipped.inc(); + // Return the most recent existing snapshot instead of creating a new one. + return self.find_most_recent_snapshot(now_micros); + } + + log::debug!( + "no existing snapshot covers log batch (oldest={batch_oldest_micros}), creating new snapshot" + ); + + let Some(snapshot_path) = state_store.rotate_journal().await else { + log::debug!("snapshot creation failed or not supported"); + self.stats.upload_failures.inc(); + return None; + }; + + self + .last_snapshot_creation_micros + .store(now_micros, Ordering::Relaxed); + + let filename = snapshot_path.file_name().and_then(|f| f.to_str())?; + let Some(parsed) = SnapshotFilename::parse(filename) else { + log::debug!("failed to parse snapshot filename: {filename}"); + self.stats.upload_failures.inc(); + return None; + }; + + Some(SnapshotRef { + timestamp_micros: parsed.timestamp_micros, + generation: parsed.generation, + path: snapshot_path, + }) + } +} diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs new file mode 100644 index 000000000..f1c4f1ab9 --- /dev/null +++ b/bd-logger/src/state_upload_test.rs @@ -0,0 +1,361 @@ +// shared-core - bitdrift's common client/server libraries +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +#![allow(clippy::unwrap_used)] + +use super::*; +use bd_runtime::runtime::{ConfigLoader, FeatureFlag as _}; +use bd_test_helpers::session::in_memory_store; +use bd_time::{SystemTimeProvider, TestTimeProvider}; +use time::OffsetDateTime; + +/// Creates a persistent `bd_state::Store` backed by the given directory with snapshotting enabled. +/// Inserts a dummy entry so that `rotate_journal` produces a non-empty snapshot file. +async fn make_state_store( + dir: &std::path::Path, +) -> (Arc, Arc) { + let runtime_loader = ConfigLoader::new(dir); + // Enable snapshotting — the default (0) disables it. + runtime_loader + .update_snapshot(bd_proto::protos::client::api::RuntimeUpdate { + version_nonce: "test".to_string(), + runtime: Some(bd_proto::protos::client::runtime::Runtime { + values: std::iter::once(( + bd_runtime::runtime::state::MaxSnapshotCount::path().to_string(), + bd_proto::protos::client::runtime::runtime::Value { + type_: Some(bd_proto::protos::client::runtime::runtime::value::Type::UintValue(10)), + ..Default::default() + }, + )) + .collect(), + ..Default::default() + }) + .into(), + ..Default::default() + }) + .await + .unwrap(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let result = bd_state::Store::persistent( + dir, + bd_state::PersistentStoreConfig::default(), + Arc::new(SystemTimeProvider {}), + &runtime_loader, + &stats, + ) + .await + .unwrap(); + // Insert a value so journal rotation produces a snapshot file. + result + .store + .insert( + bd_state::Scope::GlobalState, + "test_key".to_string(), + bd_state::string_value("test_value"), + ) + .await + .unwrap(); + (Arc::new(result.store), result.retention_registry) +} + +#[tokio::test] +async fn correlator_no_state_changes() { + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + + // Verify construction succeeds and notify_upload_needed is non-blocking. + let (correlator, _worker) = StateUploadHandle::new( + None, + store, + None, + None, + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + // With no state store, there are no state changes — channel send should succeed without blocking. + correlator.notify_upload_needed(0, 1_000_000); +} + +#[tokio::test] +async fn correlator_uploaded_coverage_prevents_reupload() { + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + + let (_correlator, worker) = StateUploadHandle::new( + None, + store, + None, + None, + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + worker.on_state_uploaded(1_704_067_300_000_000); + + // After uploading through a timestamp, find_snapshots_in_range should return nothing for + // ranges already covered. + let snapshots = worker.find_snapshots_in_range(1_704_067_200_000_000, 1_704_067_300_000_000); + assert!(snapshots.is_empty(), "no snapshots in covered range"); +} + +#[tokio::test] +async fn cooldown_prevents_rapid_snapshot_creation() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + + let (state_store, retention_registry) = make_state_store(&state_dir).await; + + let (_correlator, worker) = StateUploadHandle::new( + Some(state_dir), + store, + Some(retention_registry), + Some(state_store), + 1000, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + let batch_ts = + OffsetDateTime::now_utc().unix_timestamp().cast_unsigned() * 1_000_000 + u64::MAX / 2; + + let snapshot1 = worker.get_or_create_snapshot(batch_ts).await; + assert!(snapshot1.is_some(), "first snapshot should be created"); + + // Count snapshot files created. + let count_files = || std::fs::read_dir(&snapshots_dir).unwrap().count(); + let file_count_after_first = count_files(); + assert!(file_count_after_first >= 1, "snapshot file should exist"); + + let snapshot2 = worker.get_or_create_snapshot(batch_ts + 1000).await; + assert!( + snapshot2.is_some(), + "second call should return existing snapshot" + ); + assert_eq!( + count_files(), + file_count_after_first, + "should not create new snapshot due to cooldown" + ); +} + +#[tokio::test] +async fn cooldown_allows_snapshot_after_interval() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + + let (state_store, retention_registry) = make_state_store(&state_dir).await; + let time_provider = Arc::new(TestTimeProvider::new(OffsetDateTime::now_utc())); + + let (_correlator, worker) = StateUploadHandle::new( + Some(state_dir), + store, + Some(retention_registry), + Some(state_store.clone()), + 1, + time_provider.clone(), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + let batch_ts = time_provider.now().unix_timestamp().cast_unsigned() * 1_000_000 + u64::MAX / 2; + + let snapshot1 = worker.get_or_create_snapshot(batch_ts).await; + assert!(snapshot1.is_some()); + + let count_files = || std::fs::read_dir(&snapshots_dir).unwrap().count(); + let file_count_after_first = count_files(); + + // Advance time past cooldown. + time_provider.advance(time::Duration::milliseconds(2)); + + // Clear the first snapshot so the second call must create a new one. + for entry in std::fs::read_dir(&snapshots_dir).unwrap() { + let entry = entry.unwrap(); + std::fs::remove_file(entry.path()).unwrap(); + } + + let future_batch_ts = batch_ts + 10_000_000; + let snapshot2 = worker.get_or_create_snapshot(future_batch_ts).await; + assert!(snapshot2.is_some()); + assert_eq!( + count_files(), + file_count_after_first, + "should create new snapshot after cooldown expires" + ); +} + +#[tokio::test] +async fn zero_cooldown_allows_immediate_snapshot_creation() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + + let (state_store, retention_registry) = make_state_store(&state_dir).await; + + let (_correlator, worker) = StateUploadHandle::new( + Some(state_dir), + store, + Some(retention_registry), + Some(state_store.clone()), + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + let base_ts = + OffsetDateTime::now_utc().unix_timestamp().cast_unsigned() * 1_000_000 + u64::MAX / 2; + + let count_files = || std::fs::read_dir(&snapshots_dir).unwrap().count(); + let mut total_snapshots = 0; + + for i in 0 .. 3 { + // Clear snapshots so each iteration forces a new creation. + if let Ok(entries) = std::fs::read_dir(&snapshots_dir) { + for entry in entries { + let entry = entry.unwrap(); + std::fs::remove_file(entry.path()).unwrap(); + } + } + + let snapshot = worker + .get_or_create_snapshot(base_ts + i * 10_000_000) + .await; + assert!(snapshot.is_some()); + total_snapshots += count_files(); + } + + assert!( + total_snapshots >= 3, + "all snapshots should be created with zero cooldown" + ); +} + +#[tokio::test] +async fn uses_existing_snapshot_from_normal_rotation() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + std::fs::create_dir_all(&snapshots_dir).unwrap(); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + + let existing_timestamp = 1_700_000_000_000_000u64; + let existing_snapshot = snapshots_dir.join(format!("state.jrn.g0.t{existing_timestamp}.zz")); + std::fs::write(&existing_snapshot, b"pre-existing snapshot from rotation").unwrap(); + + let (_correlator, worker) = StateUploadHandle::new( + Some(state_dir), + store, + None, + None, + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + // find_snapshots_in_range should find the snapshot when it falls in range. + let snapshots = worker.find_snapshots_in_range(0, existing_timestamp); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].timestamp_micros, existing_timestamp); + assert_eq!(snapshots[0].generation, 0); +} + +#[tokio::test] +async fn creates_on_demand_snapshot_when_none_exists() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + + let (state_store, retention_registry) = make_state_store(&state_dir).await; + + let (_correlator, worker) = StateUploadHandle::new( + Some(state_dir), + store, + Some(retention_registry), + Some(state_store), + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + let batch_ts = + OffsetDateTime::now_utc().unix_timestamp().cast_unsigned() * 1_000_000 + u64::MAX / 2; + + let snapshot = worker.get_or_create_snapshot(batch_ts).await; + + assert!(snapshot.is_some()); + assert_eq!( + std::fs::read_dir(&snapshots_dir).unwrap().count(), + 1, + "should create new snapshot on-demand when none exists" + ); +} + +#[tokio::test] +async fn prefers_existing_snapshot_over_on_demand_creation() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + std::fs::create_dir_all(&snapshots_dir).unwrap(); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + + let old_snapshot_ts = 1_700_000_000_000_000u64; + let old_snapshot = snapshots_dir.join(format!("state.jrn.g0.t{old_snapshot_ts}.zz")); + std::fs::write(&old_snapshot, b"old snapshot").unwrap(); + + let newer_snapshot_ts = 1_700_001_000_000_000u64; + let newer_snapshot = snapshots_dir.join(format!("state.jrn.g1.t{newer_snapshot_ts}.zz")); + std::fs::write(&newer_snapshot, b"newer snapshot").unwrap(); + + let (_correlator, worker) = StateUploadHandle::new( + Some(state_dir), + store, + None, + None, + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + // find_snapshots_in_range returns both snapshots in the range, sorted oldest first. + let snapshots = worker.find_snapshots_in_range(0, newer_snapshot_ts + 1_000_000); + assert_eq!(snapshots.len(), 2); + assert_eq!( + snapshots[0].timestamp_micros, old_snapshot_ts, + "oldest snapshot first" + ); + assert_eq!(snapshots[1].timestamp_micros, newer_snapshot_ts); +} diff --git a/bd-logger/src/test/mod.rs b/bd-logger/src/test/mod.rs index c5f9cafac..2d3d22ba1 100644 --- a/bd-logger/src/test/mod.rs +++ b/bd-logger/src/test/mod.rs @@ -10,3 +10,4 @@ mod directory_lock_integration; mod embedded_logger_integration; mod logger_integration; mod setup; +mod state_upload_integration; diff --git a/bd-logger/src/test/setup.rs b/bd-logger/src/test/setup.rs index bafda23f5..abbe9e454 100644 --- a/bd-logger/src/test/setup.rs +++ b/bd-logger/src/test/setup.rs @@ -141,6 +141,24 @@ impl Setup { }) } + /// Creates a Setup with runtime values pre-cached to disk. + /// + /// This starts a temporary logger to cache the runtime, then restarts with the cached config. + /// Use this when runtime values must be present at logger initialization time (e.g., + /// `state.use_persistent_storage` which determines state store type at startup). + pub fn new_with_cached_runtime(options: SetupOptions) -> Self { + { + let _primer = Self::new_with_options(SetupOptions { + sdk_directory: options.sdk_directory.clone(), + disk_storage: options.disk_storage, + extra_runtime_values: options.extra_runtime_values.clone(), + ..Default::default() + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Self::new_with_options(options) + } + pub fn new_with_options(options: SetupOptions) -> Self { let mut server = bd_test_helpers::test_api_server::start_server(false, None); let shutdown = ComponentShutdownTrigger::default(); diff --git a/bd-logger/src/test/state_upload_integration.rs b/bd-logger/src/test/state_upload_integration.rs new file mode 100644 index 000000000..4a30aacb1 --- /dev/null +++ b/bd-logger/src/test/state_upload_integration.rs @@ -0,0 +1,655 @@ +// shared-core - bitdrift's common client/server libraries +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +#![allow(clippy::unwrap_used)] + +use super::setup::{Setup, SetupOptions}; +use crate::log_level; +use bd_log_matcher::builder::message_equals; +use bd_proto::protos::client::api::configuration_update::StateOfTheWorld; +use bd_proto::protos::config::v1::config::{BufferConfigList, buffer_config}; +use bd_proto::protos::logging::payload::LogType; +use bd_runtime::runtime::FeatureFlag as _; +use bd_test_helpers::config_helper::{ + ConfigurationUpdateParts, + configuration_update, + configuration_update_from_parts, + default_buffer_config, + make_buffer_matcher_matching_everything, + make_workflow_config_flushing_buffer, +}; +use bd_test_helpers::runtime::ValueKind; +use std::sync::Arc; +use tempfile::TempDir; + +#[test] +fn continuous_buffer_creates_and_uploads_state_snapshot() { + let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); + + let mut setup = Setup::new_with_cached_runtime(SetupOptions { + sdk_directory: sdk_directory.clone(), + disk_storage: true, + extra_runtime_values: vec![ + ( + bd_runtime::runtime::state::UsePersistentStorage::path(), + ValueKind::Bool(true), + ), + ( + bd_runtime::runtime::log_upload::BatchSizeFlag::path(), + ValueKind::Int(1), + ), + ( + bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), + ValueKind::Int(0), + ), + ( + bd_runtime::runtime::state::MaxSnapshotCount::path(), + ValueKind::Int(10), + ), + ], + ..Default::default() + }); + + setup.send_configuration_update(configuration_update( + "", + StateOfTheWorld { + buffer_config_list: Some(BufferConfigList { + buffer_config: vec![default_buffer_config( + buffer_config::Type::CONTINUOUS, + make_buffer_matcher_matching_everything().into(), + )], + ..Default::default() + }) + .into(), + ..Default::default() + }, + )); + + setup + .logger_handle + .set_feature_flag_exposure("test_flag".to_string(), Some("variant_a".to_string())); + setup + .logger_handle + .set_feature_flag_exposure("another_flag".to_string(), Some("variant_b".to_string())); + + setup.log( + log_level::INFO, + LogType::NORMAL, + "test message".into(), + [].into(), + [].into(), + None, + ); + + let log_upload = setup.server.blocking_next_log_upload(); + assert!(log_upload.is_some(), "expected log upload"); + + let timeout = std::time::Duration::from_secs(2); + let start = std::time::Instant::now(); + + while start.elapsed() < timeout { + if let Some(upload) = setup.server.blocking_next_artifact_upload() { + assert!( + !upload.contents.is_empty(), + "state snapshot should have content" + ); + + let snapshots_dir = sdk_directory.path().join("state/snapshots"); + if snapshots_dir.exists() { + let entries: Vec<_> = std::fs::read_dir(&snapshots_dir) + .unwrap() + .filter_map(Result::ok) + .collect(); + assert!( + !entries.is_empty(), + "snapshot files should exist in state/snapshots/" + ); + } + return; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} + +#[test] +fn trigger_buffer_flush_creates_snapshot() { + let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); + + let mut setup = Setup::new_with_cached_runtime(SetupOptions { + sdk_directory: sdk_directory.clone(), + disk_storage: true, + extra_runtime_values: vec![ + ( + bd_runtime::runtime::state::UsePersistentStorage::path(), + ValueKind::Bool(true), + ), + ( + bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), + ValueKind::Int(0), + ), + ( + bd_runtime::runtime::state::MaxSnapshotCount::path(), + ValueKind::Int(10), + ), + ], + ..Default::default() + }); + + setup.send_configuration_update(configuration_update_from_parts( + "", + ConfigurationUpdateParts { + buffer_config: vec![default_buffer_config( + buffer_config::Type::TRIGGER, + make_buffer_matcher_matching_everything().into(), + )], + workflows: make_workflow_config_flushing_buffer("default", message_equals("flush")), + ..Default::default() + }, + )); + + setup + .logger_handle + .set_feature_flag_exposure("trigger_flag".to_string(), Some("enabled".to_string())); + + for i in 0 .. 3 { + setup.log( + log_level::INFO, + LogType::NORMAL, + format!("trigger log {i}").into(), + [].into(), + [].into(), + None, + ); + } + + setup.log( + log_level::INFO, + LogType::NORMAL, + "flush".into(), + [].into(), + [].into(), + None, + ); + + let log_upload = setup.server.blocking_next_log_upload(); + assert!( + log_upload.is_some(), + "expected log upload from trigger buffer flush" + ); + + let upload = log_upload.unwrap(); + let logs = upload.logs(); + assert!(!logs.is_empty(), "expected logs in upload"); + + let timeout = std::time::Duration::from_secs(2); + let start = std::time::Instant::now(); + + while start.elapsed() < timeout { + if let Some(artifact) = setup.server.blocking_next_artifact_upload() { + assert!( + !artifact.contents.is_empty(), + "state snapshot should have content" + ); + + let snapshots_dir = sdk_directory.path().join("state/snapshots"); + if snapshots_dir.exists() { + let snapshot_files: Vec<_> = std::fs::read_dir(&snapshots_dir) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "zz")) + .collect(); + assert!( + !snapshot_files.is_empty(), + "snapshot .zz files should exist after trigger flush" + ); + } + return; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + panic!("expected state snapshot upload within timeout"); +} + +#[test] +fn trigger_buffer_with_multiple_flushes_uploads_state_once() { + let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); + + let mut setup = Setup::new_with_cached_runtime(SetupOptions { + sdk_directory, + disk_storage: true, + extra_runtime_values: vec![ + ( + bd_runtime::runtime::state::UsePersistentStorage::path(), + ValueKind::Bool(true), + ), + ( + bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), + ValueKind::Int(0), + ), + ], + ..Default::default() + }); + + setup.send_configuration_update(configuration_update_from_parts( + "", + ConfigurationUpdateParts { + buffer_config: vec![default_buffer_config( + buffer_config::Type::TRIGGER, + make_buffer_matcher_matching_everything().into(), + )], + workflows: make_workflow_config_flushing_buffer("default", message_equals("flush")), + ..Default::default() + }, + )); + + setup + .logger_handle + .set_feature_flag_exposure("multi_flush_flag".to_string(), Some("value".to_string())); + + setup.log( + log_level::INFO, + LogType::NORMAL, + "first batch log".into(), + [].into(), + [].into(), + None, + ); + setup.log( + log_level::INFO, + LogType::NORMAL, + "flush".into(), + [].into(), + [].into(), + None, + ); + + let _ = setup.server.blocking_next_log_upload(); + + let mut first_flush_artifacts = 0; + let timeout = std::time::Duration::from_millis(500); + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if setup.server.blocking_next_artifact_upload().is_some() { + first_flush_artifacts += 1; + } else { + break; + } + } + + setup.log( + log_level::INFO, + LogType::NORMAL, + "second batch log".into(), + [].into(), + [].into(), + None, + ); + setup.log( + log_level::INFO, + LogType::NORMAL, + "flush".into(), + [].into(), + [].into(), + None, + ); + + let _ = setup.server.blocking_next_log_upload(); + + let mut second_flush_artifacts = 0; + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if setup.server.blocking_next_artifact_upload().is_some() { + second_flush_artifacts += 1; + } else { + break; + } + } + + assert!( + second_flush_artifacts <= first_flush_artifacts, + "second flush ({second_flush_artifacts}) should not upload more state than first \ + ({first_flush_artifacts})" + ); +} + +#[test] +fn state_correlator_prevents_duplicate_uploads() { + let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); + + let mut setup = Setup::new_with_cached_runtime(SetupOptions { + sdk_directory, + disk_storage: true, + extra_runtime_values: vec![ + ( + bd_runtime::runtime::state::UsePersistentStorage::path(), + ValueKind::Bool(true), + ), + ( + bd_runtime::runtime::log_upload::BatchSizeFlag::path(), + ValueKind::Int(1), + ), + ( + bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), + ValueKind::Int(0), + ), + ], + ..Default::default() + }); + + setup.configure_stream_all_logs(); + + setup + .logger_handle + .set_feature_flag_exposure("dup_test_flag".to_string(), Some("value".to_string())); + + setup.log( + log_level::INFO, + LogType::NORMAL, + "first log".into(), + [].into(), + [].into(), + None, + ); + + let _ = setup.server.blocking_next_log_upload(); + + let mut first_batch_artifacts = 0; + let timeout = std::time::Duration::from_millis(500); + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if setup.server.blocking_next_artifact_upload().is_some() { + first_batch_artifacts += 1; + } else { + break; + } + } + + setup.log( + log_level::INFO, + LogType::NORMAL, + "second log".into(), + [].into(), + [].into(), + None, + ); + + let _ = setup.server.blocking_next_log_upload(); + + let mut second_batch_artifacts = 0; + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if setup.server.blocking_next_artifact_upload().is_some() { + second_batch_artifacts += 1; + } else { + break; + } + } + + assert!( + second_batch_artifacts <= first_batch_artifacts, + "second batch ({second_batch_artifacts}) should not upload more state than first batch \ + ({first_batch_artifacts})" + ); +} + +#[test] +fn new_state_changes_trigger_new_snapshot() { + let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); + + let mut setup = Setup::new_with_cached_runtime(SetupOptions { + sdk_directory, + disk_storage: true, + extra_runtime_values: vec![ + ( + bd_runtime::runtime::state::UsePersistentStorage::path(), + ValueKind::Bool(true), + ), + ( + bd_runtime::runtime::log_upload::BatchSizeFlag::path(), + ValueKind::Int(1), + ), + ( + bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), + ValueKind::Int(0), + ), + ], + ..Default::default() + }); + + setup.configure_stream_all_logs(); + + setup + .logger_handle + .set_feature_flag_exposure("flag_v1".to_string(), Some("value1".to_string())); + + setup.log( + log_level::INFO, + LogType::NORMAL, + "log after first state".into(), + [].into(), + [].into(), + None, + ); + + let _ = setup.server.blocking_next_log_upload(); + + let timeout = std::time::Duration::from_millis(500); + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if setup.server.blocking_next_artifact_upload().is_none() { + break; + } + } + + setup + .logger_handle + .set_feature_flag_exposure("flag_v2".to_string(), Some("value2".to_string())); + + setup.log( + log_level::INFO, + LogType::NORMAL, + "log after second state".into(), + [].into(), + [].into(), + None, + ); + + let _ = setup.server.blocking_next_log_upload(); +} + +#[test] +fn continuous_streaming_uploads_state_with_first_batch() { + let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); + + let mut setup = Setup::new_with_cached_runtime(SetupOptions { + sdk_directory: sdk_directory.clone(), + disk_storage: true, + extra_runtime_values: vec![ + ( + bd_runtime::runtime::state::UsePersistentStorage::path(), + ValueKind::Bool(true), + ), + ( + bd_runtime::runtime::log_upload::BatchSizeFlag::path(), + ValueKind::Int(2), + ), + ( + bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), + ValueKind::Int(0), + ), + ( + bd_runtime::runtime::state::MaxSnapshotCount::path(), + ValueKind::Int(10), + ), + ], + ..Default::default() + }); + + setup.configure_stream_all_logs(); + + setup + .logger_handle + .set_feature_flag_exposure("streaming_flag".to_string(), Some("active".to_string())); + + setup.log( + log_level::INFO, + LogType::NORMAL, + "streaming log 1".into(), + [].into(), + [].into(), + None, + ); + setup.log( + log_level::INFO, + LogType::NORMAL, + "streaming log 2".into(), + [].into(), + [].into(), + None, + ); + + let log_upload = setup.server.blocking_next_log_upload(); + assert!(log_upload.is_some(), "expected log upload"); + + let timeout = std::time::Duration::from_secs(2); + let start = std::time::Instant::now(); + let mut found_artifact = false; + + while start.elapsed() < timeout { + if let Some(artifact) = setup.server.blocking_next_artifact_upload() { + assert!( + !artifact.contents.is_empty(), + "state snapshot should have content" + ); + found_artifact = true; + + let snapshots_dir = sdk_directory.path().join("state/snapshots"); + if snapshots_dir.exists() { + let snapshot_files: Vec<_> = std::fs::read_dir(&snapshots_dir) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "zz")) + .collect(); + assert!( + !snapshot_files.is_empty(), + "snapshot .zz files should exist for continuous streaming" + ); + } + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + assert!( + found_artifact, + "expected artifact upload for state snapshot with continuous streaming" + ); +} + +#[test] +fn continuous_streaming_multiple_batches_single_state_upload() { + let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); + + let mut setup = Setup::new_with_cached_runtime(SetupOptions { + sdk_directory, + disk_storage: true, + extra_runtime_values: vec![ + ( + bd_runtime::runtime::state::UsePersistentStorage::path(), + ValueKind::Bool(true), + ), + ( + bd_runtime::runtime::log_upload::BatchSizeFlag::path(), + ValueKind::Int(1), + ), + ( + bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), + ValueKind::Int(0), + ), + ], + ..Default::default() + }); + + setup.configure_stream_all_logs(); + + setup + .logger_handle + .set_feature_flag_exposure("batch_flag".to_string(), Some("test".to_string())); + + setup.log( + log_level::INFO, + LogType::NORMAL, + "batch 1 log".into(), + [].into(), + [].into(), + None, + ); + + let _ = setup.server.blocking_next_log_upload(); + + let mut first_batch_artifacts = 0; + let timeout = std::time::Duration::from_millis(500); + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if setup.server.blocking_next_artifact_upload().is_some() { + first_batch_artifacts += 1; + } else { + break; + } + } + + setup.log( + log_level::INFO, + LogType::NORMAL, + "batch 2 log".into(), + [].into(), + [].into(), + None, + ); + + let _ = setup.server.blocking_next_log_upload(); + + let mut second_batch_artifacts = 0; + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if setup.server.blocking_next_artifact_upload().is_some() { + second_batch_artifacts += 1; + } else { + break; + } + } + + setup.log( + log_level::INFO, + LogType::NORMAL, + "batch 3 log".into(), + [].into(), + [].into(), + None, + ); + + let _ = setup.server.blocking_next_log_upload(); + + let mut third_batch_artifacts = 0; + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if setup.server.blocking_next_artifact_upload().is_some() { + third_batch_artifacts += 1; + } else { + break; + } + } + + assert!( + second_batch_artifacts <= first_batch_artifacts + && third_batch_artifacts <= first_batch_artifacts, + "subsequent batches should not upload more state than first batch \ + (first={first_batch_artifacts}, second={second_batch_artifacts}, \ + third={third_batch_artifacts})" + ); +} diff --git a/bd-runtime/src/runtime.rs b/bd-runtime/src/runtime.rs index 7ad9742b0..3a1810295 100644 --- a/bd-runtime/src/runtime.rs +++ b/bd-runtime/src/runtime.rs @@ -940,8 +940,8 @@ pub mod state { // When set to false, the state store operates in-memory only and does not persist // feature flags or global state to disk. When set to true, the state store will // attempt to use persistent storage, falling back to in-memory if initialization fails. - // Defaults to false for safety during crash loops. - bool_feature_flag!(UsePersistentStorage, "state.use_persistent_storage", false); + // Defaults to true; in-memory mode is only used as a fallback when persistent storage fails. + bool_feature_flag!(UsePersistentStorage, "state.use_persistent_storage", true); // Controls the initial buffer size for the persistent state store in bytes. // This determines the starting size of the memory-mapped file. The buffer will grow @@ -957,6 +957,15 @@ pub mod state { // this bounds the memory usage of the state store. int_feature_flag!(MaxCapacity, "state.max_capacity_bytes", 1024 * 1024); + // Minimum interval between state snapshot creations in milliseconds. This batching is primarily + // necessary for log streaming configurations where all logs are streamed rapidly. Defaults to + // 5000ms (5 seconds). + int_feature_flag!( + SnapshotCreationIntervalMs, + "state.snapshot_creation_interval_ms", + 5000 + ); + // Controls the maximum number of snapshots to retain for persistent state. // Snapshots will generally be cleaned up based on the retention window dictated by the active // retention handles, but this flag places an upper limit on the number of snapshots retained. The diff --git a/bd-state/src/lib.rs b/bd-state/src/lib.rs index b603eaf72..22163c867 100644 --- a/bd-state/src/lib.rs +++ b/bd-state/src/lib.rs @@ -22,8 +22,15 @@ pub mod test; pub use self::InitStrategy::{InMemoryOnly, PersistentWithFallback}; use ahash::AHashMap; -use bd_resilient_kv::{DataLoss, RetentionRegistry, ScopedMaps, StateValue}; -pub use bd_resilient_kv::{PersistentStoreConfig, Scope, StateValue as Value, Value_type}; +use bd_resilient_kv::{DataLoss, ScopedMaps, StateValue}; +pub use bd_resilient_kv::{ + PersistentStoreConfig, + RetentionHandle, + RetentionRegistry, + Scope, + StateValue as Value, + Value_type, +}; use bd_runtime::runtime::ConfigLoader; use bd_time::{OffsetDateTimeExt, TimeProvider}; use itertools::Itertools as _; @@ -32,6 +39,7 @@ use std::sync::Arc; use time::OffsetDateTime; use tokio::sync::RwLock; + /// The key used for storing the current system session ID in the state store. pub const SYSTEM_SESSION_ID_KEY: &str = "sid"; @@ -190,7 +198,9 @@ pub struct StoreInitResult { pub data_loss: DataLoss, /// Snapshot of state from the previous process, captured before clearing ephemeral scopes pub previous_state: ScopedMaps, - /// Retention registry used for snapshot retention + /// Registry for managing snapshot retention across buffers. Each buffer should create a + /// retention handle from this registry to prevent snapshots from being cleaned up while + /// logs that reference them are still in the buffer. pub retention_registry: Arc, } @@ -211,7 +221,7 @@ pub struct StoreInitWithFallbackResult { pub previous_state: ScopedMaps, /// Whether fallback to in-memory storage occurred pub fallback_occurred: bool, - /// Retention registry used for snapshot retention + /// Registry for managing snapshot retention across buffers. Empty if fallback occurred. pub retention_registry: Arc, } @@ -381,6 +391,8 @@ impl Store { data_loss: None, previous_state: ScopedMaps::default(), fallback_occurred: true, + // In-memory store doesn't have snapshots to retain, but we still provide a registry + // so callers don't need to handle the Option case. retention_registry: Arc::new(RetentionRegistry::new( bd_runtime::runtime::state::MaxSnapshotCount::register(runtime_loader), )), @@ -648,6 +660,7 @@ impl Store { .collect_vec(); let mut changes = Vec::new(); + let mut latest_timestamp = None; // TODO(snowp): Ideally we should have built in support for batch deletions in the // underlying store. This leaves us open for partial deletions if something fails halfway @@ -659,6 +672,10 @@ impl Store { .unwrap_or_else(|_| OffsetDateTime::now_utc()); if old_state_value.value_type.is_some() { + // Track the latest timestamp among all changes + if latest_timestamp.is_none() || timestamp > latest_timestamp.unwrap_or(timestamp) { + latest_timestamp = Some(timestamp); + } changes.push(StateChange { scope, key, @@ -675,6 +692,11 @@ impl Store { self.record_change(ts); } + // Notify the listener once with the latest timestamp if any changes occurred + if let Some(ts) = latest_timestamp { + self.record_change(ts); + } + Ok(StateChanges { changes }) } @@ -684,6 +706,28 @@ impl Store { pub async fn read(&self) -> impl StateReader + '_ { self.inner.read().await } + + /// Triggers a journal rotation to create a snapshot. + /// + /// This is primarily used to create a state snapshot before uploading logs, ensuring the server + /// has the state context needed to hydrate those logs. The rotation creates a compressed `.zz` + /// snapshot file in the `state/snapshots/` directory. + /// + /// Returns the path to the created snapshot file, or `None` if: + /// - The store is in-memory only (no persistence) + /// - The rotation failed for some reason + /// + /// Note: For in-memory stores, this is a no-op that returns `None`. + pub async fn rotate_journal(&self) -> Option { + let mut locked = self.inner.write().await; + match locked.rotate_journal().await { + Ok(rotation) => Some(rotation.snapshot_path), + Err(e) => { + log::debug!("Failed to rotate journal for snapshot: {e}"); + None + }, + } + } } impl StateReader for tokio::sync::RwLockReadGuard<'_, bd_resilient_kv::VersionedKVStore> { diff --git a/bd-test-helpers/src/runtime.rs b/bd-test-helpers/src/runtime.rs index 573bc2769..9f641d6a9 100644 --- a/bd-test-helpers/src/runtime.rs +++ b/bd-test-helpers/src/runtime.rs @@ -11,6 +11,7 @@ use bd_proto::protos::client::runtime::runtime::{Value, value}; /// A simple representation of a runtime value. This is used to provide better ergonomics than the /// protobuf enums. +#[derive(Clone)] pub enum ValueKind { Bool(bool), Int(u32), From fe31d0d015efc355d8c4955f9d0310178065d27b Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 08:33:39 -0800 Subject: [PATCH 02/32] cleanup --- bd-artifact-upload/src/uploader.rs | 105 +++++++++++------------- bd-artifact-upload/src/uploader_test.rs | 69 ++++------------ bd-crash-handler/src/lib.rs | 4 +- bd-crash-handler/src/monitor_test.rs | 25 +++--- bd-logger/src/state_upload.rs | 5 -- bd-logger/src/state_upload_test.rs | 1 - 6 files changed, 80 insertions(+), 129 deletions(-) diff --git a/bd-artifact-upload/src/uploader.rs b/bd-artifact-upload/src/uploader.rs index 8020af28e..c05aa8af6 100644 --- a/bd-artifact-upload/src/uploader.rs +++ b/bd-artifact-upload/src/uploader.rs @@ -49,6 +49,20 @@ pub static REPORT_DIRECTORY: LazyLock = LazyLock::new(|| "report_upload /// The index file used for tracking all of the individual files. pub static REPORT_INDEX_FILE: LazyLock = LazyLock::new(|| "report_index.pb".into()); +#[derive(Default, Clone, Copy)] +pub enum ArtifactType { + #[default] + Report, +} + +impl ArtifactType { + fn to_type_id(self) -> &'static str { + match self { + Self::Report => "client_report", + } + } +} + // // FeatureFlag // @@ -96,14 +110,11 @@ impl SnappedFeatureFlag { struct NewUpload { uuid: Uuid, file: std::fs::File, + type_id: String, state: LogFields, timestamp: Option, session_id: String, feature_flags: Vec, - type_id: String, - skip_intent: bool, - /// Optional oneshot sender to notify the caller when the upload completes or is dropped. - completion_tx: Option>, } // Used for bounded_buffer logs @@ -129,11 +140,11 @@ impl MemorySized for SnappedFeatureFlag { impl MemorySized for NewUpload { fn size(&self) -> usize { std::mem::size_of::() + + self.type_id.len() + self.state.size() + std::mem::size_of::>() + self.session_id.len() + self.feature_flags.size() - + self.type_id.len() } } @@ -168,13 +179,11 @@ pub trait Client: Send + Sync { fn enqueue_upload( &self, file: std::fs::File, + type_id: String, state: LogFields, timestamp: Option, session_id: String, feature_flags: Vec, - type_id: String, - skip_intent: bool, - completion_tx: Option>, ) -> anyhow::Result; } @@ -184,16 +193,15 @@ pub struct UploadClient { } impl Client for UploadClient { + /// Dispatches a payload to be uploaded, returning the associated artifact UUID. fn enqueue_upload( &self, file: std::fs::File, + type_id: String, state: LogFields, timestamp: Option, session_id: String, feature_flags: Vec, - type_id: String, - skip_intent: bool, - completion_tx: Option>, ) -> anyhow::Result { let uuid = uuid::Uuid::new_v4(); @@ -202,13 +210,11 @@ impl Client for UploadClient { .try_send(NewUpload { uuid, file, + type_id, state, timestamp, session_id, feature_flags, - type_id, - skip_intent, - completion_tx, }) .inspect_err(|e| log::warn!("failed to enqueue artifact upload: {e:?}")); @@ -256,9 +262,6 @@ pub struct Uploader { index: VecDeque, - /// Oneshot senders waiting for upload confirmation, keyed by artifact name (uuid string). - completion_senders: HashMap>, - max_entries: IntWatch, initial_backoff_interval: DurationWatch, max_backoff_interval: DurationWatch, @@ -302,7 +305,6 @@ impl Uploader { time_provider, file_system, index: VecDeque::default(), - completion_senders: HashMap::default(), max_entries: runtime.register_int_watch(), initial_backoff_interval: runtime.register_duration_watch(), max_backoff_interval: runtime.register_duration_watch(), @@ -343,10 +345,13 @@ impl Uploader { { if next.pending_intent_negotiation { log::debug!("starting intent negotiation for {:?}", next.name); + self.intent_task_handle = Some(tokio::spawn(Self::perform_intent_negotiation( self.data_upload_tx.clone(), next.name.clone(), + next.type_id.clone().unwrap_or_default(), next.time.to_offset_date_time(), + next.metadata.clone(), bd_api::backoff_policy( &mut self.initial_backoff_interval, &mut self.max_backoff_interval, @@ -380,13 +385,12 @@ impl Uploader { return Ok(()); }; - - log::debug!("starting file upload for {:?}", next.name); self.upload_task_handle = Some(tokio::spawn(Self::upload_artifact( self.data_upload_tx.clone(), contents, next.name.clone(), + next.type_id.clone().unwrap_or_default(), next.time.to_offset_date_time(), next.session_id.clone(), bd_api::backoff_policy( @@ -413,26 +417,22 @@ impl Uploader { Some(NewUpload { uuid, file, + type_id, state, timestamp, session_id, feature_flags, - type_id, - skip_intent, - completion_tx, }) = self.upload_queued_rx.recv() => { log::debug!("tracking artifact: {uuid} for upload"); self .track_new_upload( uuid, file, + type_id, state, session_id, timestamp, feature_flags, - type_id, - skip_intent, - completion_tx, ) .await; } @@ -489,7 +489,7 @@ impl Uploader { let mut modified = false; let mut new_index = VecDeque::default(); let mut filenames = HashSet::new(); - for entry in self.index.drain(..) { + for mut entry in self.index.drain(..) { let file_path = REPORT_DIRECTORY.join(&entry.name); if !self .file_system @@ -504,6 +504,13 @@ impl Uploader { modified = true; continue; } + // Handle inserting a default type_id for entries that are missing it. This can happen for + // older versions of the uploader that didn't persist the type_id to disk. + // TODO(snowp): Remove this at some point in the future after. + if entry.type_id.as_deref().unwrap_or_default().is_empty() { + entry.type_id = Some(ArtifactType::default().to_type_id().to_string()); + modified = true; + } filenames.insert(entry.name.clone()); new_index.push_back(entry); } @@ -546,11 +553,6 @@ impl Uploader { self.stats.dropped_intent.inc(); let entry = &self.index.pop_front().ok_or(InvariantError::Invariant)?; - // Notify the caller that this upload was rejected. - if let Some(tx) = self.completion_senders.remove(&entry.name) { - let _ = tx.send(false); - } - if let Err(e) = self .file_system .delete_file(&REPORT_DIRECTORY.join(&entry.name)) @@ -587,11 +589,6 @@ impl Uploader { log::warn!("failed to delete artifact {:?}: {}", entry.name, e); } - // Notify the caller that the upload succeeded. - if let Some(tx) = self.completion_senders.remove(&entry.name) { - let _ = tx.send(true); - } - self.write_index().await; Ok(entry.name) @@ -610,13 +607,11 @@ impl Uploader { &mut self, uuid: Uuid, file: std::fs::File, + type_id: String, state: LogFields, session_id: String, timestamp: Option, feature_flags: Vec, - _type_id: String, - skip_intent: bool, - completion_tx: Option>, ) { // If we've reached our limit of entries, stop the entry currently being uploaded (the oldest // one) to make space for the newer one. @@ -626,12 +621,7 @@ impl Uploader { self.stats.dropped.inc(); self.stop_current_upload(); - if let Some(evicted) = self.index.pop_front() { - // Notify any waiting caller that their upload was dropped. - if let Some(tx) = self.completion_senders.remove(&evicted.name) { - let _ = tx.send(false); - } - } + self.index.pop_front(); } let uuid = uuid.to_string(); @@ -666,13 +656,19 @@ impl Uploader { // Only write the index after we've written the report file to disk to try to minimze the risk // of the file being written without a corresponding entry. + let type_id = if type_id.is_empty() { + ArtifactType::default().to_type_id().to_string() + } else { + type_id + }; self.index.push_back(Artifact { name: uuid.clone(), + type_id: Some(type_id), time: timestamp .unwrap_or_else(|| self.time_provider.now()) .into_proto(), session_id, - pending_intent_negotiation: !skip_intent, + pending_intent_negotiation: true, metadata: state .into_iter() .map(|(key, value)| (key.into(), value.into_proto())) @@ -698,11 +694,6 @@ impl Uploader { self.write_index().await; - // Store the completion sender before returning so it fires on upload or rejection. - if let Some(tx) = completion_tx { - self.completion_senders.insert(uuid.clone(), tx); - } - #[cfg(test)] if let Some(hooks) = &self.test_hooks { hooks.entry_received_tx.send(uuid.clone()).await.unwrap(); @@ -736,6 +727,7 @@ impl Uploader { data_upload_tx: tokio::sync::mpsc::Sender, contents: Vec, name: String, + type_id: String, timestamp: OffsetDateTime, session_id: String, mut retry_policy: ExponentialBackoff, @@ -755,7 +747,7 @@ impl Uploader { upload_uuid.clone(), UploadArtifactRequest { upload_uuid, - type_id: "client_report".to_string(), + type_id: type_id.clone(), contents: contents.clone(), artifact_id: name.clone(), time: timestamp.into_proto(), @@ -788,7 +780,9 @@ impl Uploader { async fn perform_intent_negotiation( data_upload_tx: tokio::sync::mpsc::Sender, id: String, + type_id: String, timestamp: OffsetDateTime, + state_metadata: HashMap, mut retry_policy: ExponentialBackoff, ) -> Result { loop { @@ -796,12 +790,11 @@ impl Uploader { let (tracked, response) = TrackedArtifactIntent::new( upload_uuid.clone(), UploadArtifactIntentRequest { - type_id: "client_report".to_string(), + type_id: type_id.clone(), artifact_id: id.clone(), intent_uuid: upload_uuid.clone(), time: timestamp.into_proto(), - // TODO(snowp): Figure out how to send relevant metadata about the artifact here. - metadata: HashMap::new(), + metadata: state_metadata.clone(), ..Default::default() }, ); diff --git a/bd-artifact-upload/src/uploader_test.rs b/bd-artifact-upload/src/uploader_test.rs index 76f50c123..7ca5b6549 100644 --- a/bd-artifact-upload/src/uploader_test.rs +++ b/bd-artifact-upload/src/uploader_test.rs @@ -161,13 +161,11 @@ async fn basic_flow() { .client .enqueue_upload( setup.make_file(b"abc"), + "client_report".to_string(), [("foo".into(), "bar".into())].into(), Some(timestamp), "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); @@ -222,6 +220,7 @@ async fn feature_flags() { .client .enqueue_upload( setup.make_file(b"abc"), + "client_report".to_string(), [("foo".into(), "bar".into())].into(), Some(timestamp), "session_id".to_string(), @@ -233,9 +232,6 @@ async fn feature_flags() { ), SnappedFeatureFlag::new("key2".to_string(), None, timestamp - 2.std_seconds()), ], - "client_report".to_string(), - false, - None, ) .unwrap(); @@ -250,6 +246,7 @@ async fn feature_flags() { decision: bd_api::upload::IntentDecision::UploadImmediately }).unwrap(); }); + let upload = setup.data_upload_rx.recv().await.unwrap(); assert_matches!(upload, DataUpload::ArtifactUpload(upload) => { assert_eq!(upload.payload.artifact_id, id.to_string()); @@ -299,13 +296,11 @@ async fn pending_upload_limit() { .client .enqueue_upload( setup.make_file(b"1"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -317,13 +312,11 @@ async fn pending_upload_limit() { .client .enqueue_upload( setup.make_file(b"2"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -334,13 +327,11 @@ async fn pending_upload_limit() { .client .enqueue_upload( setup.make_file(b"3"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -404,13 +395,11 @@ async fn inconsistent_state_missing_file() { .client .enqueue_upload( setup.make_file(b"1"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -421,13 +410,11 @@ async fn inconsistent_state_missing_file() { .client .enqueue_upload( setup.make_file(b"2"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -466,13 +453,11 @@ async fn inconsistent_state_extra_file() { .client .enqueue_upload( setup.make_file(b"1"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -539,13 +524,11 @@ async fn disk_persistence() { .client .enqueue_upload( setup.make_file(b"1"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -588,13 +571,11 @@ async fn inconsistent_state_missing_index() { .client .enqueue_upload( setup.make_file(b"1"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -614,13 +595,11 @@ async fn inconsistent_state_missing_index() { .client .enqueue_upload( setup.make_file(b"2"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -662,13 +641,11 @@ async fn new_entry_disk_full() { .client .enqueue_upload( setup.make_file(b"1"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -693,13 +670,11 @@ async fn new_entry_disk_full_after_received() { .client .enqueue_upload( setup.make_file(b"1"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -736,13 +711,11 @@ async fn intent_retries() { .client .enqueue_upload( setup.make_file(b"1"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -773,13 +746,11 @@ async fn intent_drop() { .client .enqueue_upload( setup.make_file(b"1"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -812,13 +783,11 @@ async fn upload_retries() { .client .enqueue_upload( setup.make_file(b"1"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( @@ -866,13 +835,11 @@ async fn normalize_type_id_on_load() { .client .enqueue_upload( setup.make_file(b"abc"), + "client_report".to_string(), [].into(), None, "session_id".to_string(), vec![], - "client_report".to_string(), - false, - None, ) .unwrap(); assert_eq!( diff --git a/bd-crash-handler/src/lib.rs b/bd-crash-handler/src/lib.rs index 9a89c2937..e1d62591b 100644 --- a/bd-crash-handler/src/lib.rs +++ b/bd-crash-handler/src/lib.rs @@ -483,13 +483,11 @@ impl Monitor { let Ok(artifact_id) = self.artifact_client.enqueue_upload( file, + "client_report".to_string(), state_fields.clone(), timestamp, session_id.clone(), reporting_feature_flags.clone(), - "client_report".to_string(), - false, // Don't skip intent negotiation for crash reports - None, ) else { log::warn!( "Failed to enqueue issue report for upload: {}", diff --git a/bd-crash-handler/src/monitor_test.rs b/bd-crash-handler/src/monitor_test.rs index 68edb76f2..d75668c4b 100644 --- a/bd-crash-handler/src/monitor_test.rs +++ b/bd-crash-handler/src/monitor_test.rs @@ -6,7 +6,6 @@ // https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt use crate::{Monitor, global_state}; -use bd_artifact_upload::SnappedFeatureFlag; use bd_client_common::init_lifecycle::InitLifecycleState; use bd_log_primitives::{AnnotatedLogFields, LogFields}; use bd_proto::flatbuffers::report::bitdrift_public::fbs::issue_reporting::v_1::{ @@ -178,6 +177,7 @@ impl Setup { bd_runtime::runtime::IntWatch::new_for_testing(0), ); + for (name, value) in flags { store .insert( @@ -374,17 +374,11 @@ impl Setup { make_mut(&mut self.upload_client) .expect_enqueue_upload() .withf( - move |mut file, - fstate, - ftimestamp, - fsession_id, - feature_flags: &Vec, - _type_id, - _skip_intent, - _completion_tx| { + move |mut file, ftype_id, fstate, ftimestamp, fsession_id, feature_flags| { let mut output = vec![]; file.read_to_end(&mut output).unwrap(); let content_match = output == content; + let type_id_match = ftype_id == "client_report"; let state_match = &state == fstate; let timestamp_match = ×tamp == ftimestamp; let session_match = session_id == *fsession_id; @@ -403,10 +397,15 @@ impl Setup { feature_flags.is_empty() }; - content_match && state_match && timestamp_match && session_match && flags_match + content_match + && type_id_match + && state_match + && timestamp_match + && session_match + && flags_match }, ) - .returning(move |_, _, _, _, _, _, _, _| Ok(uuid)); + .returning(move |_, _, _, _, _, _| Ok(uuid)); } } @@ -787,12 +786,12 @@ async fn file_watcher_processes_multiple_reports() { .expect_enqueue_upload() .times(1) .in_sequence(&mut seq) - .returning(move |_, _, _, _, _, _, _, _| Ok(uuid1)); + .returning(move |_, _, _, _, _, _| Ok(uuid1)); make_mut(&mut setup.upload_client) .expect_enqueue_upload() .times(1) .in_sequence(&mut seq) - .returning(move |_, _, _, _, _, _, _, _| Ok(uuid2)); + .returning(move |_, _, _, _, _, _| Ok(uuid2)); // Create two crash reports let data1 = CrashReportBuilder::new("Crash1").reason("error1").build(); diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index 4c5fa3218..184fbc29e 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -59,8 +59,6 @@ static STATE_UPLOAD_KEY: bd_key_value::Key = pub struct SnapshotRef { /// The timestamp of the snapshot (microseconds since epoch). pub timestamp_micros: u64, - /// The generation number of the snapshot file. - pub generation: u64, /// Path to the snapshot file. pub path: PathBuf, } @@ -405,7 +403,6 @@ impl StateUploadWorker { if parsed.timestamp_micros > after_micros && parsed.timestamp_micros <= up_to_micros { Some(SnapshotRef { timestamp_micros: parsed.timestamp_micros, - generation: parsed.generation, path, }) } else { @@ -432,7 +429,6 @@ impl StateUploadWorker { if parsed.timestamp_micros <= up_to_micros { Some(SnapshotRef { timestamp_micros: parsed.timestamp_micros, - generation: parsed.generation, path, }) } else { @@ -494,7 +490,6 @@ impl StateUploadWorker { Some(SnapshotRef { timestamp_micros: parsed.timestamp_micros, - generation: parsed.generation, path: snapshot_path, }) } diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index f1c4f1ab9..70e4d775e 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -283,7 +283,6 @@ async fn uses_existing_snapshot_from_normal_rotation() { let snapshots = worker.find_snapshots_in_range(0, existing_timestamp); assert_eq!(snapshots.len(), 1); assert_eq!(snapshots[0].timestamp_micros, existing_timestamp); - assert_eq!(snapshots[0].generation, 0); } #[tokio::test] From 00d667e177927b405d2619b372df834522be65e3 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 09:45:29 -0800 Subject: [PATCH 03/32] remove fallback reporting --- bd-logger/src/builder.rs | 9 --------- bd-logger/src/logger.rs | 22 ---------------------- bd-logger/src/logger_test.rs | 2 -- 3 files changed, 33 deletions(-) diff --git a/bd-logger/src/builder.rs b/bd-logger/src/builder.rs index ea9150420..360f7b06c 100644 --- a/bd-logger/src/builder.rs +++ b/bd-logger/src/builder.rs @@ -243,9 +243,6 @@ impl LoggerBuilder { let data_upload_tx_clone = data_upload_tx.clone(); let collector_clone = collector; - let state_storage_fallback = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let state_storage_fallback_for_future = state_storage_fallback.clone(); - let logger = Logger::new( maybe_shutdown_trigger, runtime_loader.clone(), @@ -257,7 +254,6 @@ impl LoggerBuilder { self.params.static_metadata.sdk_version(), self.params.store.clone(), sleep_mode_active_tx, - state_storage_fallback, ); let log = if self.internal_logger { Arc::new(InternalLogger::new( @@ -322,11 +318,6 @@ impl LoggerBuilder { log.log_internal("state store initialization failed, using in-memory fallback"); } - state_storage_fallback_for_future.store( - result.fallback_occurred, - std::sync::atomic::Ordering::Relaxed, - ); - let (artifact_uploader, artifact_client) = bd_artifact_upload::Uploader::new( Arc::new(RealFileSystem::new(self.params.sdk_directory.clone())), data_upload_tx_clone.clone(), diff --git a/bd-logger/src/logger.rs b/bd-logger/src/logger.rs index 634d1dcce..85079552f 100644 --- a/bd-logger/src/logger.rs +++ b/bd-logger/src/logger.rs @@ -35,7 +35,6 @@ use parking_lot::Mutex; use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use time::ext::NumericalDuration; use tokio::sync::mpsc::{Receiver, Sender}; @@ -177,8 +176,6 @@ pub struct LoggerHandle { stats: Stats, sleep_mode_active: watch::Sender, - - state_storage_fallback: Arc, } impl LoggerHandle { @@ -309,15 +306,6 @@ impl LoggerHandle { "_session_strategy".into(), AnnotatedLogField::new_ootb(self.session_strategy.type_name()), ), - ( - "_state_storage_fallback".into(), - AnnotatedLogField::new_ootb( - self - .state_storage_fallback - .load(Ordering::Relaxed) - .to_string(), - ), - ), ]); self.log( @@ -332,11 +320,6 @@ impl LoggerHandle { ); } - pub fn set_state_storage_fallback(&self, occurred: bool) { - self - .state_storage_fallback - .store(occurred, Ordering::Relaxed); - } #[must_use] pub fn should_log_app_update( @@ -551,8 +534,6 @@ pub struct Logger { stats_scope: Scope, sleep_mode_active: watch::Sender, - - state_storage_fallback: Arc, } impl Logger { @@ -567,7 +548,6 @@ impl Logger { sdk_version: &str, store: Arc, sleep_mode_active: watch::Sender, - state_storage_fallback: Arc, ) -> Self { let stats = Stats::new(&stats_scope); @@ -586,7 +566,6 @@ impl Logger { stats_scope, store, sleep_mode_active, - state_storage_fallback, } } @@ -641,7 +620,6 @@ impl Logger { app_version_repo: Repository::new(self.store.clone()), stats: self.stats.clone(), sleep_mode_active: self.sleep_mode_active.clone(), - state_storage_fallback: self.state_storage_fallback.clone(), } } } diff --git a/bd-logger/src/logger_test.rs b/bd-logger/src/logger_test.rs index b1a851927..5820442d4 100644 --- a/bd-logger/src/logger_test.rs +++ b/bd-logger/src/logger_test.rs @@ -17,7 +17,6 @@ use bd_session::fixed::{self, UUIDCallbacks}; use bd_test_helpers::session::in_memory_store; use futures_util::poll; use std::sync::Arc; -use std::sync::atomic::AtomicBool; use tokio::pin; use tokio::sync::watch; use tokio_test::assert_pending; @@ -40,7 +39,6 @@ async fn thread_local_logger_guard() { sdk_version: "1.0.0".into(), app_version_repo: Repository::new(store), sleep_mode_active: watch::channel(false).0, - state_storage_fallback: Arc::new(AtomicBool::new(false)), }; with_thread_local_logger_guard(|| { From 32fbcbb567db1bf7c9b79b869e602e6b69343f5d Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 09:46:09 -0800 Subject: [PATCH 04/32] remove agents --- AGENTS.md | 11 ----------- CLAUDE.md | 1 - 2 files changed, 12 deletions(-) delete mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 114e895af..d55214787 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,17 +42,6 @@ ``` This should be placed at the top of the test file, after the license header and before imports. -## Formatting - -- **ONLY** use `cargo +nightly fmt` to format code. This command automatically picks up `rustfmt.toml` from the repo root. -- **NEVER** use any of these alternatives - they may not use the correct config: - - `cargo fmt` (stable rustfmt lacks required features) - - `rustfmt ` (may not find config) - - `cargo +nightly fmt -- .` (using `.` as path can break config discovery) - - Any editor/IDE auto-format (may use wrong rustfmt version or config) -- If you see unexpected whitespace-only changes across many files after formatting, STOP and investigate - the wrong formatter was likely used. -- The repo uses `edition = "2024"` and `imports_layout = "HorizontalVertical"` in rustfmt.toml - imports should be vertical (one per line), not horizontal. - ## Code Quality Checks - After generating or modifying code, always run clippy to check for static lint violations: `cargo clippy --workspace --bins --examples --tests -- --no-deps` diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file From daa9a71751ab908f1363aba53ae74ed02ab10bb7 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 10:07:40 -0800 Subject: [PATCH 05/32] remove refernces to correlator, better range computation --- bd-logger/src/builder.rs | 8 +- bd-logger/src/consumer.rs | 95 ++++++++----------- bd-logger/src/consumer_test.rs | 6 +- bd-logger/src/state_upload.rs | 26 ++--- bd-logger/src/state_upload_test.rs | 22 ++--- .../src/test/state_upload_integration.rs | 2 +- 6 files changed, 74 insertions(+), 85 deletions(-) diff --git a/bd-logger/src/builder.rs b/bd-logger/src/builder.rs index 360f7b06c..2a23e3d95 100644 --- a/bd-logger/src/builder.rs +++ b/bd-logger/src/builder.rs @@ -328,12 +328,12 @@ impl LoggerBuilder { ); let artifact_client: Arc = Arc::new(artifact_client); - // Create state-log correlator for uploading state snapshots alongside logs + // Create state upload handle for uploading state snapshots alongside logs let snapshot_creation_interval_ms = *bd_runtime::runtime::state::SnapshotCreationIntervalMs::register(&runtime_loader) .into_inner() .borrow(); - let (state_correlator_inner, state_upload_worker) = StateUploadHandle::new( + let (state_upload_handle_inner, state_upload_worker) = StateUploadHandle::new( Some(state_directory.clone()), self.params.store.clone(), Some(retention_registry.clone()), @@ -344,7 +344,7 @@ impl LoggerBuilder { &scope, ) .await; - let state_correlator = Arc::new(state_correlator_inner); + let state_upload_handle = Arc::new(state_upload_handle_inner); let crash_monitor = Monitor::new( &self.params.sdk_directory, @@ -385,7 +385,7 @@ impl LoggerBuilder { trigger_upload_rx, &scope, log.clone(), - Some(state_correlator), + Some(state_upload_handle), ); let updater = Arc::new(client_config::Config::new( diff --git a/bd-logger/src/consumer.rs b/bd-logger/src/consumer.rs index 50e191342..6e2e15957 100644 --- a/bd-logger/src/consumer.rs +++ b/bd-logger/src/consumer.rs @@ -94,8 +94,8 @@ pub struct BufferUploadManager { old_logs_dropped: Counter, - // State-log correlator for uploading state snapshots before logs. - state_correlator: Option>, + // State upload handle for uploading state snapshots before logs. + state_upload_handle: Option>, } impl BufferUploadManager { @@ -107,7 +107,7 @@ impl BufferUploadManager { trigger_upload_rx: Receiver, stats: &Scope, logging: Arc, - state_correlator: Option>, + state_upload_handle: Option>, ) -> Self { Self { log_upload_service: service::new(data_upload_tx, shutdown.clone(), runtime_loader, stats), @@ -127,7 +127,7 @@ impl BufferUploadManager { logging, stream_buffer_shutdown_trigger: None, old_logs_dropped: stats.counter("old_logs_dropped"), - state_correlator, + state_upload_handle, } } @@ -311,14 +311,14 @@ impl BufferUploadManager { let consumer = buffer.clone().register_consumer()?; let batch_builder = BatchBuilder::new(self.feature_flags.clone()); - let state_correlator = self.state_correlator.clone(); + let state_upload_handle = self.state_upload_handle.clone(); tokio::task::spawn(async move { StreamedBufferUpload { consumer, log_upload_service, batch_builder, shutdown, - state_correlator, + state_upload_handle, } .start() .await @@ -362,7 +362,7 @@ impl BufferUploadManager { self.feature_flags.clone(), shutdown_trigger.make_shutdown(), buffer_name.to_string(), - self.state_correlator.clone(), + self.state_upload_handle.clone(), ), shutdown_trigger, )) @@ -381,7 +381,7 @@ impl BufferUploadManager { self.log_upload_service.clone(), buffer_name.to_string(), self.old_logs_dropped.clone(), - self.state_correlator.clone(), + self.state_upload_handle.clone(), )) } } @@ -396,6 +396,8 @@ struct BatchBuilder { flags: Flags, total_bytes: usize, logs: Vec>, + oldest_micros: Option, + newest_micros: Option, } impl BatchBuilder { @@ -404,10 +406,17 @@ impl BatchBuilder { flags, total_bytes: 0, logs: Vec::new(), + oldest_micros: None, + newest_micros: None, } } fn add_log(&mut self, data: Vec) { + if let Some(ts) = EncodableLog::extract_timestamp(&data) { + let ts_micros = ts.unix_timestamp().cast_unsigned() * 1_000_000 + u64::from(ts.microsecond()); + self.oldest_micros = Some(self.oldest_micros.map_or(ts_micros, |o| o.min(ts_micros))); + self.newest_micros = Some(self.newest_micros.map_or(ts_micros, |n| n.max(ts_micros))); + } self.total_bytes += data.len(); self.logs.push(data); } @@ -423,27 +432,17 @@ impl BatchBuilder { max_batch_size_bytes <= self.total_bytes || max_batch_size_logs <= self.logs.len() } - /// Extracts the timestamp range (oldest, newest) from the current batch of logs. - /// Returns None if there are no logs with extractable timestamps. - fn timestamp_range_micros(&self) -> Option<(u64, u64)> { - let mut oldest: Option = None; - let mut newest: Option = None; - - for log_bytes in &self.logs { - if let Some(ts) = EncodableLog::extract_timestamp(log_bytes) { - let ts_micros = - ts.unix_timestamp().cast_unsigned() * 1_000_000 + u64::from(ts.microsecond()); - oldest = Some(oldest.map_or(ts_micros, |o| o.min(ts_micros))); - newest = Some(newest.map_or(ts_micros, |n| n.max(ts_micros))); - } - } - - oldest.zip(newest) + /// Returns the timestamp range (oldest, newest) of logs added to the current batch, + /// or `None` if no logs with extractable timestamps have been added. + fn timestamp_range(&self) -> Option<(u64, u64)> { + self.oldest_micros.zip(self.newest_micros) } /// Consumes the current batch, resetting all accounting. fn take(&mut self) -> Vec> { self.total_bytes = 0; + self.oldest_micros = None; + self.newest_micros = None; self.logs.drain(..).collect() } } @@ -472,8 +471,8 @@ struct ContinuousBufferUploader { buffer_id: String, - // State-log correlator for uploading state snapshots before logs. - state_correlator: Option>, + // State upload handle for uploading state snapshots before logs. + state_upload_handle: Option>, } impl ContinuousBufferUploader { @@ -483,7 +482,7 @@ impl ContinuousBufferUploader { feature_flags: Flags, shutdown: ComponentShutdown, buffer_id: String, - state_correlator: Option>, + state_upload_handle: Option>, ) -> Self { Self { consumer, @@ -493,7 +492,7 @@ impl ContinuousBufferUploader { batch_builder: BatchBuilder::new(feature_flags.clone()), feature_flags, buffer_id, - state_correlator, + state_upload_handle, } } // Attempts to upload all logs in the provided buffer. For every polling interval we @@ -539,17 +538,14 @@ impl ContinuousBufferUploader { // Disarm the deadline which forces a partial flush to fire. self.flush_batch_sleep = None; - // Extract timestamps before taking logs from the batch - let timestamp_range = self.batch_builder.timestamp_range_micros(); - + let timestamp_range = self.batch_builder.timestamp_range(); let logs = self.batch_builder.take(); let logs_len = logs.len(); - log::debug!("flushing {logs_len} logs"); // Upload state snapshot if needed before uploading logs - if let (Some(correlator), Some((oldest, newest))) = (&self.state_correlator, timestamp_range) { - correlator.notify_upload_needed(oldest, newest); + if let (Some(handle), Some((oldest, newest))) = (&self.state_upload_handle, timestamp_range) { + handle.notify_upload_needed(oldest, newest); } // Attempt to perform an upload of these buffers, with retries ++. See logger/service.rs for @@ -606,8 +602,8 @@ struct StreamedBufferUpload { shutdown: ComponentShutdown, - // State-log correlator for uploading state snapshots before logs. - state_correlator: Option>, + // State upload handle for uploading state snapshots before logs. + state_upload_handle: Option>, } impl StreamedBufferUpload { @@ -665,13 +661,9 @@ impl StreamedBufferUpload { } } - // Extract timestamps before taking logs from the batch - let timestamp_range = self.batch_builder.timestamp_range_micros(); - - // Upload state snapshot if needed before uploading logs - if let (Some(correlator), Some((oldest, newest))) = (&self.state_correlator, timestamp_range) - { - correlator.notify_upload_needed(oldest, newest); + let timestamp_range = self.batch_builder.timestamp_range(); + if let (Some(handle), Some((oldest, newest))) = (&self.state_upload_handle, timestamp_range) { + handle.notify_upload_needed(oldest, newest); } let upload_future = async { @@ -721,8 +713,8 @@ struct CompleteBufferUpload { old_logs_dropped: Counter, - // State-log correlator for uploading state snapshots before logs. - state_correlator: Option>, + // State upload handle for uploading state snapshots before logs. + state_upload_handle: Option>, } impl CompleteBufferUpload { @@ -732,7 +724,7 @@ impl CompleteBufferUpload { log_upload_service: service::Upload, buffer_id: String, old_logs_dropped: Counter, - state_correlator: Option>, + state_upload_handle: Option>, ) -> Self { let lookback_window_limit = *runtime_flags.upload_lookback_window_feature_flag.read(); @@ -749,7 +741,7 @@ impl CompleteBufferUpload { buffer_id, lookback_window, old_logs_dropped, - state_correlator, + state_upload_handle, } } @@ -800,16 +792,13 @@ impl CompleteBufferUpload { } async fn flush_batch(&mut self) -> anyhow::Result<()> { - // Extract timestamps before taking logs from the batch - let timestamp_range = self.batch_builder.timestamp_range_micros(); - + let timestamp_range = self.batch_builder.timestamp_range(); let logs = self.batch_builder.take(); - log::debug!("flushing {} logs", logs.len()); // Upload state snapshot if needed before uploading logs - if let (Some(correlator), Some((oldest, newest))) = (&self.state_correlator, timestamp_range) { - correlator.notify_upload_needed(oldest, newest); + if let (Some(handle), Some((oldest, newest))) = (&self.state_upload_handle, timestamp_range) { + handle.notify_upload_needed(oldest, newest); } // Attempt to perform an upload of these buffers, with retries ++. See logger/service.rs for diff --git a/bd-logger/src/consumer_test.rs b/bd-logger/src/consumer_test.rs index a387a2c4e..a58d4bd07 100644 --- a/bd-logger/src/consumer_test.rs +++ b/bd-logger/src/consumer_test.rs @@ -907,7 +907,7 @@ async fn log_streaming() { log_upload_service: upload_service, shutdown: shutdown_trigger.make_shutdown(), batch_builder: BatchBuilder::new(make_flags(&runtime_loader)), - state_correlator: None, + state_upload_handle: None, } .start() .await @@ -964,7 +964,7 @@ async fn streaming_batch_size_flag() { log_upload_service: upload_service, batch_builder: BatchBuilder::new(make_flags(&runtime_loader)), shutdown: shutdown_trigger.make_shutdown(), - state_correlator: None, + state_upload_handle: None, } .start() .await @@ -1022,7 +1022,7 @@ async fn log_streaming_shutdown() { log_upload_service: upload_service, shutdown, batch_builder: BatchBuilder::new(make_flags(&runtime_loader)), - state_correlator: None, + state_upload_handle: None, } .start() .await diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index 184fbc29e..e5db7e5a7 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -7,11 +7,11 @@ //! State snapshot upload coordination for log uploads. //! -//! This module provides the [`StateLogCorrelator`] which tracks the correlation between log uploads -//! and state snapshots. The server correlates logs with state by timestamp - logs at time T use the +//! This module provides the [`StateUploadHandle`] which tracks the coordination between log +//! and state snapshots. The server associates logs with state by timestamp - logs at time T use the //! most recent state snapshot uploaded before time T. //! -//! The correlator ensures that: +//! The [`StateUploadHandle`] ensures that: //! - State snapshots are uploaded before logs that depend on them //! - Duplicate snapshot uploads are avoided across multiple buffers //! - Snapshot coverage is tracked across process restarts via persistence @@ -20,8 +20,8 @@ //! //! Upload coordination is split into two parts: //! -//! - [`StateLogCorrelator`] — a cheap, cloneable handle held by each buffer uploader. Callers -//! fire-and-forget upload requests via [`StateLogCorrelator::notify_upload_needed`], which sends +//! - [`StateUploadHandle`] — a cheap, cloneable handle held by each buffer uploader. Callers +//! fire-and-forget upload requests via [`StateUploadHandle::notify_upload_needed`], which sends //! to a bounded channel without blocking. //! //! - [`StateUploadWorker`] — a single background task that owns all snapshot creation and upload @@ -86,7 +86,7 @@ impl Stats { } } -/// Tracks correlation between log uploads and state snapshot coverage. +/// Coordinates state snapshot uploads before log uploads. /// /// This is a lightweight, cloneable sender handle. Buffer uploaders call /// [`notify_upload_needed`][Self::notify_upload_needed] in a fire-and-forget manner — @@ -100,10 +100,10 @@ pub struct StateUploadHandle { } impl StateUploadHandle { - /// Creates a new correlator and its companion worker. + /// Creates a new handle and its companion worker. /// /// The returned [`StateUploadWorker`] must be spawned (e.g. via `tokio::spawn` or included in a - /// `try_join!`) for snapshot uploads to be processed. The correlator handle can be cloned and + /// `try_join!`) for snapshot uploads to be processed. The handle can be cloned and /// shared across multiple buffer uploaders. /// /// # Arguments @@ -151,7 +151,7 @@ impl StateUploadHandle { let state_uploaded_through_micros = Arc::new(AtomicU64::new(uploaded_through)); - let correlator = Self { upload_tx }; + let handle = Self { upload_tx }; let worker = StateUploadWorker { state_uploaded_through_micros, @@ -167,7 +167,7 @@ impl StateUploadHandle { stats, }; - (correlator, worker) + (handle, worker) } /// Notifies the background worker that a state snapshot upload may be needed for a log batch. @@ -194,9 +194,9 @@ impl StateUploadHandle { /// There is exactly one worker per logger instance. Because all upload logic runs in a single /// task, deduplication and cooldown enforcement require no synchronization. /// -/// Obtain via [`StateLogCorrelator::new`] and spawn with `tokio::spawn` or `try_join!`. +/// Obtain via [`StateUploadHandle::new`] and spawn with `tokio::spawn` or `try_join!`. pub struct StateUploadWorker { - /// Shared with the correlator — updated after successful uploads. + /// Shared with the handle — updated after successful uploads. state_uploaded_through_micros: Arc, /// Timestamp of the last snapshot creation (microseconds since epoch). @@ -222,7 +222,7 @@ pub struct StateUploadWorker { /// Artifact client for uploading snapshots. artifact_client: Arc, - /// Receiver for upload requests from correlator handles. + /// Receiver for upload requests from handle instances. upload_rx: mpsc::Receiver, stats: Stats, diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index 70e4d775e..30f71bf0b 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -63,12 +63,12 @@ async fn make_state_store( } #[tokio::test] -async fn correlator_no_state_changes() { +async fn no_state_changes() { let store = in_memory_store(); let stats = bd_client_stats_store::Collector::default().scope("test"); // Verify construction succeeds and notify_upload_needed is non-blocking. - let (correlator, _worker) = StateUploadHandle::new( + let (handle, _worker) = StateUploadHandle::new( None, store, None, @@ -81,15 +81,15 @@ async fn correlator_no_state_changes() { .await; // With no state store, there are no state changes — channel send should succeed without blocking. - correlator.notify_upload_needed(0, 1_000_000); + handle.notify_upload_needed(0, 1_000_000); } #[tokio::test] -async fn correlator_uploaded_coverage_prevents_reupload() { +async fn uploaded_coverage_prevents_reupload() { let store = in_memory_store(); let stats = bd_client_stats_store::Collector::default().scope("test"); - let (_correlator, worker) = StateUploadHandle::new( + let (_handle, worker) = StateUploadHandle::new( None, store, None, @@ -119,7 +119,7 @@ async fn cooldown_prevents_rapid_snapshot_creation() { let (state_store, retention_registry) = make_state_store(&state_dir).await; - let (_correlator, worker) = StateUploadHandle::new( + let (_handle, worker) = StateUploadHandle::new( Some(state_dir), store, Some(retention_registry), @@ -165,7 +165,7 @@ async fn cooldown_allows_snapshot_after_interval() { let (state_store, retention_registry) = make_state_store(&state_dir).await; let time_provider = Arc::new(TestTimeProvider::new(OffsetDateTime::now_utc())); - let (_correlator, worker) = StateUploadHandle::new( + let (_handle, worker) = StateUploadHandle::new( Some(state_dir), store, Some(retention_registry), @@ -214,7 +214,7 @@ async fn zero_cooldown_allows_immediate_snapshot_creation() { let (state_store, retention_registry) = make_state_store(&state_dir).await; - let (_correlator, worker) = StateUploadHandle::new( + let (_handle, worker) = StateUploadHandle::new( Some(state_dir), store, Some(retention_registry), @@ -267,7 +267,7 @@ async fn uses_existing_snapshot_from_normal_rotation() { let existing_snapshot = snapshots_dir.join(format!("state.jrn.g0.t{existing_timestamp}.zz")); std::fs::write(&existing_snapshot, b"pre-existing snapshot from rotation").unwrap(); - let (_correlator, worker) = StateUploadHandle::new( + let (_handle, worker) = StateUploadHandle::new( Some(state_dir), store, None, @@ -295,7 +295,7 @@ async fn creates_on_demand_snapshot_when_none_exists() { let (state_store, retention_registry) = make_state_store(&state_dir).await; - let (_correlator, worker) = StateUploadHandle::new( + let (_handle, worker) = StateUploadHandle::new( Some(state_dir), store, Some(retention_registry), @@ -337,7 +337,7 @@ async fn prefers_existing_snapshot_over_on_demand_creation() { let newer_snapshot = snapshots_dir.join(format!("state.jrn.g1.t{newer_snapshot_ts}.zz")); std::fs::write(&newer_snapshot, b"newer snapshot").unwrap(); - let (_correlator, worker) = StateUploadHandle::new( + let (_handle, worker) = StateUploadHandle::new( Some(state_dir), store, None, diff --git a/bd-logger/src/test/state_upload_integration.rs b/bd-logger/src/test/state_upload_integration.rs index 4a30aacb1..f2379be7b 100644 --- a/bd-logger/src/test/state_upload_integration.rs +++ b/bd-logger/src/test/state_upload_integration.rs @@ -318,7 +318,7 @@ fn trigger_buffer_with_multiple_flushes_uploads_state_once() { } #[test] -fn state_correlator_prevents_duplicate_uploads() { +fn state_upload_handle_prevents_duplicate_uploads() { let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); let mut setup = Setup::new_with_cached_runtime(SetupOptions { From a7ad81b04d14394627159d3da4061affab954a3f Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 10:10:10 -0800 Subject: [PATCH 06/32] use helper --- bd-logger/src/consumer.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bd-logger/src/consumer.rs b/bd-logger/src/consumer.rs index 6e2e15957..f12ebc82d 100644 --- a/bd-logger/src/consumer.rs +++ b/bd-logger/src/consumer.rs @@ -21,6 +21,7 @@ use bd_error_reporter::reporter::handle_unexpected_error_with_details; use bd_log_primitives::EncodableLog; use bd_runtime::runtime::{ConfigLoader, DurationWatch, IntWatch, Watch}; use bd_shutdown::{ComponentShutdown, ComponentShutdownTrigger}; +use bd_time::OffsetDateTimeExt; use futures_util::future::try_join_all; use std::collections::{HashMap, HashSet}; use std::pin::Pin; @@ -413,7 +414,7 @@ impl BatchBuilder { fn add_log(&mut self, data: Vec) { if let Some(ts) = EncodableLog::extract_timestamp(&data) { - let ts_micros = ts.unix_timestamp().cast_unsigned() * 1_000_000 + u64::from(ts.microsecond()); + let ts_micros = ts.unix_timestamp_micros().cast_unsigned(); self.oldest_micros = Some(self.oldest_micros.map_or(ts_micros, |o| o.min(ts_micros))); self.newest_micros = Some(self.newest_micros.map_or(ts_micros, |n| n.max(ts_micros))); } From 11e2113edaac4dda3210f987ffb20e9d1272baa3 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 10:11:44 -0800 Subject: [PATCH 07/32] use time helper in more places --- bd-logger/src/state_upload.rs | 4 ++-- bd-state/src/lib.rs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index e5db7e5a7..1cabfa768 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -37,7 +37,7 @@ use bd_client_stats_store::{Counter, Scope}; use bd_log_primitives::LogFields; use bd_resilient_kv::SnapshotFilename; use bd_state::{RetentionHandle, RetentionRegistry}; -use bd_time::TimeProvider; +use bd_time::{OffsetDateTimeExt, TimeProvider}; use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -451,7 +451,7 @@ impl StateUploadWorker { let now_micros = { let now = self.time_provider.now(); - now.unix_timestamp().cast_unsigned() * 1_000_000 + u64::from(now.microsecond()) + now.unix_timestamp_micros().cast_unsigned() }; let last_creation = self.last_snapshot_creation_micros.load(Ordering::Relaxed); if last_creation > 0 diff --git a/bd-state/src/lib.rs b/bd-state/src/lib.rs index 22163c867..9a6e7a172 100644 --- a/bd-state/src/lib.rs +++ b/bd-state/src/lib.rs @@ -542,8 +542,7 @@ impl Store { } fn record_change(&self, timestamp: OffsetDateTime) { - let micros = - timestamp.unix_timestamp().cast_unsigned() * 1_000_000 + u64::from(timestamp.microsecond()); + let micros = timestamp.unix_timestamp_micros().cast_unsigned(); self .last_change_micros .fetch_max(micros, std::sync::atomic::Ordering::Relaxed); From 6457e3737d6ae8b093566fc35fbc0660991ed2f3 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 10:27:56 -0800 Subject: [PATCH 08/32] document in agents --- bd-logger/AGENTS.md | 99 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 bd-logger/AGENTS.md diff --git a/bd-logger/AGENTS.md b/bd-logger/AGENTS.md new file mode 100644 index 000000000..e299a32a4 --- /dev/null +++ b/bd-logger/AGENTS.md @@ -0,0 +1,99 @@ +# bd-logger - Agent Guidelines + +This document covers design decisions and behavioral invariants for `bd-logger` that are not +obvious from reading the code alone. + +## State Snapshot Uploads + +### Why State Snapshots Exist + +A snapshot file contains N state entries, each carrying its own original write timestamp. The +snapshot's filename timestamp is the *rotation* timestamp — the moment the journal was compacted +— which is always **after** the log timestamps it covers. The server reconstructs which state +was active at log time T by replaying the per-entry timestamps within the snapshot (entries with +write timestamp ≤ T), not by comparing T against the snapshot's rotation timestamp. Logs and +state travel separately — the logger just needs to ensure the relevant snapshot files are +uploaded so the server has them available when it processes those logs. + +### Architecture: Handle + Worker + +State upload coordination is split into two types: + +- **`StateUploadHandle`** — a cheap, `Arc`-cloneable sender. Each buffer uploader holds one. When + a batch is about to be flushed, the uploader calls + `handle.notify_upload_needed(batch_oldest_micros, batch_newest_micros)` in a fire-and-forget + manner. The call queues a request into a bounded channel and returns immediately — it never + blocks the log upload path. + +- **`StateUploadWorker`** — a single background task that owns all snapshot creation and upload + logic. Because exactly one task processes requests, deduplication and cooldown enforcement + require no locks or atomics between callers. The worker drains the channel on each wakeup, + coalescing all queued requests into the widest possible timestamp range before deciding whether + to act. + +The handle and worker are created together via `StateUploadHandle::new`, which also restores +previously-persisted upload coverage from `bd-key-value`. + +### Upload Decision Logic + +When the worker receives a batch's timestamp range `[oldest, newest]`, it evaluates in order: + +1. **No state changes ever recorded** (`last_change_micros == 0`) → skip. Nothing to upload. +2. **Coverage already sufficient** (`uploaded_through >= batch_oldest`) → skip. The server + already has state that covers this batch. +3. **No new changes since last upload** (`last_change <= uploaded_through`) → skip. State hasn't + changed since we last uploaded. +4. **Existing snapshots cover the gap** → upload those snapshot files. Snapshots are found by + scanning `{state_store_path}/snapshots/` for files whose parsed timestamp falls in + `(uploaded_through, batch_newest_micros]`. +5. **No existing snapshots cover the gap** → create one on-demand via + `state_store.rotate_journal()`, subject to a cooldown (see below). + +### Snapshot Cooldown + +Creating a snapshot on every batch flush during high-volume streaming is wasteful. The worker +tracks `last_snapshot_creation_micros` and will not create a new snapshot if one was created +within `snapshot_creation_interval_micros` (a runtime-configurable value). During cooldown, the +worker falls back to the most recent existing snapshot instead of creating a new one. + +### Coverage Persistence and Retention + +`uploaded_through_micros` — the watermark of what has been confirmed uploaded — is persisted via +`bd-key-value` under the key `state_upload.uploaded_through.1`. It is loaded on startup and used +immediately, so the worker never re-uploads state snapshots that were confirmed in a previous +process run. + +The watermark is also fed into the `RetentionHandle` from `bd-resilient-kv`, which prevents the +snapshot retention cleanup from deleting any snapshot that is still needed. This prevents a race +where cleanup removes a snapshot before the logger has had a chance to upload it. + +### BatchBuilder Timestamp Tracking + +`BatchBuilder` (in `consumer.rs`) tracks `oldest_micros` and `newest_micros` incrementally as +logs are added via `add_log`. This avoids a second scan of the batch at flush time. Both fields +are reset to `None` by `take()` when the batch is consumed. Callers must read `timestamp_range()` +*before* calling `take()` — `take()` resets the fields. + +The three flush paths that interact with state uploads are: +- `ContinuousBufferUploader::flush_current_batch` +- `StreamedBufferUpload::start` +- `CompleteBufferUpload::flush_batch` + +All three follow the same pattern: read `timestamp_range()`, call `notify_upload_needed` if a +range is available, then call `take()` to produce the log batch. + +### Channel Backpressure + +The upload request channel has capacity `UPLOAD_CHANNEL_CAPACITY` (8). If the channel is full, +`notify_upload_needed` silently drops the request. This is intentional: the already-queued +requests span a timestamp range that covers the dropped batch's range. The worker coalesces all +pending requests into the widest range before acting, so no coverage is lost. + +### Key Invariants + +- `uploaded_through_micros` is monotonically non-decreasing. It is only advanced via + `fetch_max`, never set to a smaller value. +- Snapshot uploads are confirmed via a oneshot channel before the watermark advances. If the + upload is dropped or fails, the watermark stays put and the next batch will retry. +- All snapshot creation and deduplication logic runs in the single worker task. There is no + concurrent access to the upload state. From be8013039d6f67fc8336b4c68d989eb6c1bc51ed Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 11:20:05 -0800 Subject: [PATCH 09/32] fix oneshot consideration --- bd-logger/AGENTS.md | 5 +++-- bd-logger/src/state_upload.rs | 17 ++--------------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/bd-logger/AGENTS.md b/bd-logger/AGENTS.md index e299a32a4..bb7bcc817 100644 --- a/bd-logger/AGENTS.md +++ b/bd-logger/AGENTS.md @@ -93,7 +93,8 @@ pending requests into the widest range before acting, so no coverage is lost. - `uploaded_through_micros` is monotonically non-decreasing. It is only advanced via `fetch_max`, never set to a smaller value. -- Snapshot uploads are confirmed via a oneshot channel before the watermark advances. If the - upload is dropped or fails, the watermark stays put and the next batch will retry. +- Snapshot uploads are considered confirmed once they are successfully enqueued to the + `bd-artifact-upload` queue (which persists them to disk and retries the network upload). If the + enqueue fails, the watermark stays put and the next batch will retry. - All snapshot creation and deduplication logic runs in the single worker task. There is no concurrent access to the upload state. diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index 1cabfa768..0e91f2c6e 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -321,33 +321,20 @@ impl StateUploadWorker { OffsetDateTime::from_unix_timestamp_nanos(i128::from(snapshot_ref.timestamp_micros) * 1000) .ok(); - // Create a oneshot channel so we know when the upload is confirmed or dropped. - let (completion_tx, completion_rx) = tokio::sync::oneshot::channel::(); - - // Enqueue the upload via artifact uploader (skip_intent=true for immediate upload). match self.artifact_client.enqueue_upload( file, + "state_snapshot".to_string(), LogFields::new(), timestamp, "state_snapshot".to_string(), vec![], - "state_snapshot".to_string(), - true, - Some(completion_tx), ) { Ok(_uuid) => { log::debug!( "state snapshot upload enqueued for timestamp {}", snapshot_ref.timestamp_micros ); - if completion_rx.await == Ok(true) { - self.on_state_uploaded(snapshot_ref.timestamp_micros); - } else { - log::warn!("state snapshot upload failed or was dropped — watermark not advanced"); - self.stats.upload_failures.inc(); - // Watermark NOT advanced — will retry on next notify. - return; - } + self.on_state_uploaded(snapshot_ref.timestamp_micros); }, Err(e) => { log::warn!("failed to enqueue state snapshot upload: {e}"); From 9e1e3c38ec64e38f392506dacee00c649eb4f27f Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 22:20:32 -0800 Subject: [PATCH 10/32] improve durability --- bd-artifact-upload/src/uploader.rs | 53 +++++++++-- bd-artifact-upload/src/uploader_test.rs | 87 +++++++++++++++++++ bd-crash-handler/src/lib.rs | 1 + bd-crash-handler/src/monitor_test.rs | 8 +- bd-logger/src/builder.rs | 46 ++++++---- bd-logger/src/consumer.rs | 3 +- bd-logger/src/state_upload.rs | 31 +++++-- .../src/test/state_upload_integration.rs | 45 ++++++---- bd-runtime/src/runtime.rs | 4 + 9 files changed, 229 insertions(+), 49 deletions(-) diff --git a/bd-artifact-upload/src/uploader.rs b/bd-artifact-upload/src/uploader.rs index c05aa8af6..0fa90c2ac 100644 --- a/bd-artifact-upload/src/uploader.rs +++ b/bd-artifact-upload/src/uploader.rs @@ -41,6 +41,7 @@ use std::sync::{Arc, LazyLock}; #[cfg(test)] use tests::TestHooks; use time::OffsetDateTime; +use tokio::sync::oneshot; use uuid::Uuid; /// Root directory for all files used for storage and uploading. @@ -48,6 +49,7 @@ pub static REPORT_DIRECTORY: LazyLock = LazyLock::new(|| "report_upload /// The index file used for tracking all of the individual files. pub static REPORT_INDEX_FILE: LazyLock = LazyLock::new(|| "report_index.pb".into()); +const STATE_SNAPSHOT_TYPE_ID: &str = "state_snapshot"; #[derive(Default, Clone, Copy)] pub enum ArtifactType { @@ -115,6 +117,7 @@ struct NewUpload { timestamp: Option, session_id: String, feature_flags: Vec, + persisted_tx: Option>>, } // Used for bounded_buffer logs @@ -184,6 +187,7 @@ pub trait Client: Send + Sync { timestamp: Option, session_id: String, feature_flags: Vec, + persisted_tx: Option>>, ) -> anyhow::Result; } @@ -202,6 +206,7 @@ impl Client for UploadClient { timestamp: Option, session_id: String, feature_flags: Vec, + persisted_tx: Option>>, ) -> anyhow::Result { let uuid = uuid::Uuid::new_v4(); @@ -215,6 +220,7 @@ impl Client for UploadClient { timestamp, session_id, feature_flags, + persisted_tx, }) .inspect_err(|e| log::warn!("failed to enqueue artifact upload: {e:?}")); @@ -422,6 +428,7 @@ impl Uploader { timestamp, session_id, feature_flags, + persisted_tx, }) = self.upload_queued_rx.recv() => { log::debug!("tracking artifact: {uuid} for upload"); self @@ -433,6 +440,7 @@ impl Uploader { session_id, timestamp, feature_flags, + persisted_tx, ) .await; } @@ -612,16 +620,38 @@ impl Uploader { session_id: String, timestamp: Option, feature_flags: Vec, + mut persisted_tx: Option>>, ) { // If we've reached our limit of entries, stop the entry currently being uploaded (the oldest // one) to make space for the newer one. // TODO(snowp): Consider also having a bound on the size of the files persisted to disk. if self.index.len() == usize::try_from(*self.max_entries.read()).unwrap_or_default() { - log::debug!("upload queue is full, dropping current upload"); - - self.stats.dropped.inc(); - self.stop_current_upload(); - self.index.pop_front(); + if let Some(index_to_drop) = self + .index + .iter() + .position(|entry| entry.type_id.as_deref() != Some(STATE_SNAPSHOT_TYPE_ID)) + { + log::debug!("upload queue is full, dropping oldest non-state upload"); + self.stats.dropped.inc(); + if index_to_drop == 0 { + self.stop_current_upload(); + } + if let Some(entry) = self.index.remove(index_to_drop) { + let file_path = REPORT_DIRECTORY.join(&entry.name); + if let Err(e) = self.file_system.delete_file(&file_path).await { + log::warn!("failed to delete artifact {:?}: {}", entry.name, e); + } + } + self.write_index().await; + } else { + self.stats.dropped.inc(); + if let Some(tx) = persisted_tx.take() { + let _ = tx.send(Err(anyhow::anyhow!( + "upload queue full and all pending uploads are state snapshots" + ))); + } + return; + } } let uuid = uuid.to_string(); @@ -634,6 +664,11 @@ impl Uploader { Ok(file) => file, Err(e) => { log::warn!("failed to create file for artifact: {uuid} on disk: {e}"); + if let Some(tx) = persisted_tx.take() { + let _ = tx.send(Err(anyhow::anyhow!( + "failed to create file for artifact {uuid}: {e}" + ))); + } #[cfg(test)] if let Some(hooks) = &self.test_hooks { @@ -646,6 +681,11 @@ impl Uploader { if let Err(e) = async_write_checksummed_data(tokio::fs::File::from_std(file), target_file).await { log::warn!("failed to write artifact to disk: {uuid} to disk: {e}"); + if let Some(tx) = persisted_tx.take() { + let _ = tx.send(Err(anyhow::anyhow!( + "failed to write artifact to disk {uuid}: {e}" + ))); + } #[cfg(test)] if let Some(hooks) = &self.test_hooks { @@ -692,6 +732,9 @@ impl Uploader { }); self.write_index().await; + if let Some(tx) = persisted_tx { + let _ = tx.send(Ok(())); + } #[cfg(test)] diff --git a/bd-artifact-upload/src/uploader_test.rs b/bd-artifact-upload/src/uploader_test.rs index 7ca5b6549..4e7aa7ff3 100644 --- a/bd-artifact-upload/src/uploader_test.rs +++ b/bd-artifact-upload/src/uploader_test.rs @@ -166,6 +166,7 @@ async fn basic_flow() { Some(timestamp), "session_id".to_string(), vec![], + None, ) .unwrap(); @@ -232,6 +233,7 @@ async fn feature_flags() { ), SnappedFeatureFlag::new("key2".to_string(), None, timestamp - 2.std_seconds()), ], + None, ) .unwrap(); @@ -301,6 +303,7 @@ async fn pending_upload_limit() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -317,6 +320,7 @@ async fn pending_upload_limit() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -332,6 +336,7 @@ async fn pending_upload_limit() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -400,6 +405,7 @@ async fn inconsistent_state_missing_file() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -415,6 +421,7 @@ async fn inconsistent_state_missing_file() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -458,6 +465,7 @@ async fn inconsistent_state_extra_file() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -529,6 +537,7 @@ async fn disk_persistence() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -576,6 +585,7 @@ async fn inconsistent_state_missing_index() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -600,6 +610,7 @@ async fn inconsistent_state_missing_index() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -646,6 +657,7 @@ async fn new_entry_disk_full() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -675,6 +687,7 @@ async fn new_entry_disk_full_after_received() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -716,6 +729,7 @@ async fn intent_retries() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -751,6 +765,7 @@ async fn intent_drop() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -788,6 +803,7 @@ async fn upload_retries() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -840,6 +856,7 @@ async fn normalize_type_id_on_load() { None, "session_id".to_string(), vec![], + None, ) .unwrap(); assert_eq!( @@ -883,3 +900,73 @@ async fn normalize_type_id_on_load() { assert_eq!(intent.payload.type_id, "client_report"); }); } + +#[tokio::test] +async fn enqueue_upload_acknowledges_after_disk_persist() { + let mut setup = Setup::new(2).await; + + let (persisted_tx, persisted_rx) = tokio::sync::oneshot::channel(); + let id = setup + .client + .enqueue_upload( + setup.make_file(b"snapshot"), + "state_snapshot".to_string(), + [].into(), + None, + "session_id".to_string(), + vec![], + Some(persisted_tx), + ) + .unwrap(); + + assert_eq!( + setup.entry_received_rx.recv().await.unwrap(), + id.to_string() + ); + persisted_rx.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn queue_full_with_only_state_snapshots_rejects_new_state_snapshot() { + let mut setup = Setup::new(1).await; + + let (persisted_tx1, persisted_rx1) = tokio::sync::oneshot::channel(); + let id1 = setup + .client + .enqueue_upload( + setup.make_file(b"state-1"), + "state_snapshot".to_string(), + [].into(), + None, + "session_id".to_string(), + vec![], + Some(persisted_tx1), + ) + .unwrap(); + assert_eq!( + setup.entry_received_rx.recv().await.unwrap(), + id1.to_string() + ); + persisted_rx1.await.unwrap().unwrap(); + + let (persisted_tx2, persisted_rx2) = tokio::sync::oneshot::channel(); + let _id2 = setup + .client + .enqueue_upload( + setup.make_file(b"state-2"), + "state_snapshot".to_string(), + [].into(), + None, + "session_id".to_string(), + vec![], + Some(persisted_tx2), + ) + .unwrap(); + + assert!(persisted_rx2.await.unwrap().is_err()); + assert!( + timeout(100.std_milliseconds(), setup.entry_received_rx.recv()) + .await + .is_err() + ); +} diff --git a/bd-crash-handler/src/lib.rs b/bd-crash-handler/src/lib.rs index e1d62591b..a337df3b1 100644 --- a/bd-crash-handler/src/lib.rs +++ b/bd-crash-handler/src/lib.rs @@ -488,6 +488,7 @@ impl Monitor { timestamp, session_id.clone(), reporting_feature_flags.clone(), + None, ) else { log::warn!( "Failed to enqueue issue report for upload: {}", diff --git a/bd-crash-handler/src/monitor_test.rs b/bd-crash-handler/src/monitor_test.rs index d75668c4b..c09ddc068 100644 --- a/bd-crash-handler/src/monitor_test.rs +++ b/bd-crash-handler/src/monitor_test.rs @@ -374,7 +374,7 @@ impl Setup { make_mut(&mut self.upload_client) .expect_enqueue_upload() .withf( - move |mut file, ftype_id, fstate, ftimestamp, fsession_id, feature_flags| { + move |mut file, ftype_id, fstate, ftimestamp, fsession_id, feature_flags, _persisted_tx| { let mut output = vec![]; file.read_to_end(&mut output).unwrap(); let content_match = output == content; @@ -405,7 +405,7 @@ impl Setup { && flags_match }, ) - .returning(move |_, _, _, _, _, _| Ok(uuid)); + .returning(move |_, _, _, _, _, _, _| Ok(uuid)); } } @@ -786,12 +786,12 @@ async fn file_watcher_processes_multiple_reports() { .expect_enqueue_upload() .times(1) .in_sequence(&mut seq) - .returning(move |_, _, _, _, _, _| Ok(uuid1)); + .returning(move |_, _, _, _, _, _, _| Ok(uuid1)); make_mut(&mut setup.upload_client) .expect_enqueue_upload() .times(1) .in_sequence(&mut seq) - .returning(move |_, _, _, _, _, _| Ok(uuid2)); + .returning(move |_, _, _, _, _, _, _| Ok(uuid2)); // Create two crash reports let data1 = CrashReportBuilder::new("Crash1").reason("error1").build(); diff --git a/bd-logger/src/builder.rs b/bd-logger/src/builder.rs index 2a23e3d95..89bcf101a 100644 --- a/bd-logger/src/builder.rs +++ b/bd-logger/src/builder.rs @@ -328,23 +328,33 @@ impl LoggerBuilder { ); let artifact_client: Arc = Arc::new(artifact_client); - // Create state upload handle for uploading state snapshots alongside logs - let snapshot_creation_interval_ms = - *bd_runtime::runtime::state::SnapshotCreationIntervalMs::register(&runtime_loader) + // Create state upload handle for uploading state snapshots alongside logs. + // Gated by the `state.upload_enabled` runtime flag, which defaults to false as a + // safe rollout mechanism. + let state_upload_enabled = + *bd_runtime::runtime::state::StateUploadEnabled::register(&runtime_loader) .into_inner() .borrow(); - let (state_upload_handle_inner, state_upload_worker) = StateUploadHandle::new( - Some(state_directory.clone()), - self.params.store.clone(), - Some(retention_registry.clone()), - Some(Arc::new(state_store.clone())), - snapshot_creation_interval_ms, - time_provider.clone(), - artifact_client.clone(), - &scope, - ) - .await; - let state_upload_handle = Arc::new(state_upload_handle_inner); + let (state_upload_handle, state_upload_worker) = if state_upload_enabled { + let snapshot_creation_interval_ms = + *bd_runtime::runtime::state::SnapshotCreationIntervalMs::register(&runtime_loader) + .into_inner() + .borrow(); + let (handle, worker) = StateUploadHandle::new( + Some(state_directory.clone()), + self.params.store.clone(), + Some(retention_registry.clone()), + Some(Arc::new(state_store.clone())), + snapshot_creation_interval_ms, + time_provider.clone(), + artifact_client.clone(), + &scope, + ) + .await; + (Some(Arc::new(handle)), Some(worker)) + } else { + (None, None) + }; let crash_monitor = Monitor::new( &self.params.sdk_directory, @@ -385,7 +395,7 @@ impl LoggerBuilder { trigger_upload_rx, &scope, log.clone(), - Some(state_upload_handle), + state_upload_handle, ); let updater = Arc::new(client_config::Config::new( @@ -452,7 +462,9 @@ impl LoggerBuilder { Ok(()) }, async move { - state_upload_worker.run().await; + if let Some(worker) = state_upload_worker { + worker.run().await; + } Ok(()) } ) diff --git a/bd-logger/src/consumer.rs b/bd-logger/src/consumer.rs index f12ebc82d..ba7d6ba0d 100644 --- a/bd-logger/src/consumer.rs +++ b/bd-logger/src/consumer.rs @@ -312,7 +312,8 @@ impl BufferUploadManager { let consumer = buffer.clone().register_consumer()?; let batch_builder = BatchBuilder::new(self.feature_flags.clone()); - let state_upload_handle = self.state_upload_handle.clone(); + // Stream uploads are excluded from state snapshot uploads for now. + let state_upload_handle = None; tokio::task::spawn(async move { StreamedBufferUpload { consumer, diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index 0e91f2c6e..c69e3e309 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -176,10 +176,13 @@ impl StateUploadHandle { /// asynchronously. If the channel is full, the request is silently dropped — the worker will /// still cover the needed state range via already-queued requests. pub fn notify_upload_needed(&self, batch_oldest_micros: u64, batch_newest_micros: u64) { - let _ = self.upload_tx.try_send(StateUploadRequest { + let result = self.upload_tx.try_send(StateUploadRequest { batch_oldest_micros, batch_newest_micros, }); + if let Err(e) = result { + log::warn!("dropping state upload request due to full channel: {e}"); + } } } @@ -321,6 +324,7 @@ impl StateUploadWorker { OffsetDateTime::from_unix_timestamp_nanos(i128::from(snapshot_ref.timestamp_micros) * 1000) .ok(); + let (persisted_tx, persisted_rx) = tokio::sync::oneshot::channel(); match self.artifact_client.enqueue_upload( file, "state_snapshot".to_string(), @@ -328,13 +332,26 @@ impl StateUploadWorker { timestamp, "state_snapshot".to_string(), vec![], + Some(persisted_tx), ) { - Ok(_uuid) => { - log::debug!( - "state snapshot upload enqueued for timestamp {}", - snapshot_ref.timestamp_micros - ); - self.on_state_uploaded(snapshot_ref.timestamp_micros); + Ok(_uuid) => match persisted_rx.await { + Ok(Ok(())) => { + log::debug!( + "state snapshot persisted to artifact queue for timestamp {}", + snapshot_ref.timestamp_micros + ); + self.on_state_uploaded(snapshot_ref.timestamp_micros); + }, + Ok(Err(e)) => { + log::warn!("failed to persist state snapshot upload entry: {e}"); + self.stats.upload_failures.inc(); + return; + }, + Err(e) => { + log::warn!("state snapshot persistence ack channel dropped: {e}"); + self.stats.upload_failures.inc(); + return; + }, }, Err(e) => { log::warn!("failed to enqueue state snapshot upload: {e}"); diff --git a/bd-logger/src/test/state_upload_integration.rs b/bd-logger/src/test/state_upload_integration.rs index f2379be7b..df18e3aee 100644 --- a/bd-logger/src/test/state_upload_integration.rs +++ b/bd-logger/src/test/state_upload_integration.rs @@ -50,6 +50,10 @@ fn continuous_buffer_creates_and_uploads_state_snapshot() { bd_runtime::runtime::state::MaxSnapshotCount::path(), ValueKind::Int(10), ), + ( + bd_runtime::runtime::state::StateUploadEnabled::path(), + ValueKind::Bool(true), + ), ], ..Default::default() }); @@ -135,6 +139,10 @@ fn trigger_buffer_flush_creates_snapshot() { bd_runtime::runtime::state::MaxSnapshotCount::path(), ValueKind::Int(10), ), + ( + bd_runtime::runtime::state::StateUploadEnabled::path(), + ValueKind::Bool(true), + ), ], ..Default::default() }); @@ -231,6 +239,10 @@ fn trigger_buffer_with_multiple_flushes_uploads_state_once() { bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), ValueKind::Int(0), ), + ( + bd_runtime::runtime::state::StateUploadEnabled::path(), + ValueKind::Bool(true), + ), ], ..Default::default() }); @@ -318,7 +330,7 @@ fn trigger_buffer_with_multiple_flushes_uploads_state_once() { } #[test] -fn state_upload_handle_prevents_duplicate_uploads() { +fn state_correlator_prevents_duplicate_uploads() { let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); let mut setup = Setup::new_with_cached_runtime(SetupOptions { @@ -337,6 +349,10 @@ fn state_upload_handle_prevents_duplicate_uploads() { bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), ValueKind::Int(0), ), + ( + bd_runtime::runtime::state::StateUploadEnabled::path(), + ValueKind::Bool(true), + ), ], ..Default::default() }); @@ -417,6 +433,10 @@ fn new_state_changes_trigger_new_snapshot() { bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), ValueKind::Int(0), ), + ( + bd_runtime::runtime::state::StateUploadEnabled::path(), + ValueKind::Bool(true), + ), ], ..Default::default() }); @@ -467,7 +487,7 @@ fn continuous_streaming_uploads_state_with_first_batch() { let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); let mut setup = Setup::new_with_cached_runtime(SetupOptions { - sdk_directory: sdk_directory.clone(), + sdk_directory, disk_storage: true, extra_runtime_values: vec![ ( @@ -486,6 +506,10 @@ fn continuous_streaming_uploads_state_with_first_batch() { bd_runtime::runtime::state::MaxSnapshotCount::path(), ValueKind::Int(10), ), + ( + bd_runtime::runtime::state::StateUploadEnabled::path(), + ValueKind::Bool(true), + ), ], ..Default::default() }); @@ -527,19 +551,6 @@ fn continuous_streaming_uploads_state_with_first_batch() { "state snapshot should have content" ); found_artifact = true; - - let snapshots_dir = sdk_directory.path().join("state/snapshots"); - if snapshots_dir.exists() { - let snapshot_files: Vec<_> = std::fs::read_dir(&snapshots_dir) - .unwrap() - .filter_map(Result::ok) - .filter(|e| e.path().extension().is_some_and(|ext| ext == "zz")) - .collect(); - assert!( - !snapshot_files.is_empty(), - "snapshot .zz files should exist for continuous streaming" - ); - } break; } std::thread::sleep(std::time::Duration::from_millis(100)); @@ -571,6 +582,10 @@ fn continuous_streaming_multiple_batches_single_state_upload() { bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), ValueKind::Int(0), ), + ( + bd_runtime::runtime::state::StateUploadEnabled::path(), + ValueKind::Bool(true), + ), ], ..Default::default() }); diff --git a/bd-runtime/src/runtime.rs b/bd-runtime/src/runtime.rs index 3a1810295..d50c75aca 100644 --- a/bd-runtime/src/runtime.rs +++ b/bd-runtime/src/runtime.rs @@ -966,6 +966,10 @@ pub mod state { 5000 ); + // Controls whether state snapshot uploads are enabled. When disabled, state snapshots are + // not uploaded alongside log uploads. Defaults to false as a safe rollout mechanism. + bool_feature_flag!(StateUploadEnabled, "state.upload_enabled", false); + // Controls the maximum number of snapshots to retain for persistent state. // Snapshots will generally be cleaned up based on the retention window dictated by the active // retention handles, but this flag places an upper limit on the number of snapshots retained. The From 0ee2d8e8995c6268a416dbed332e53e65c451c05 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 22:44:21 -0800 Subject: [PATCH 11/32] better durability guarantees --- bd-artifact-upload/src/uploader.rs | 11 ++- bd-logger/src/state_upload.rs | 126 ++++++++++++++++++++++++----- bd-logger/src/state_upload_test.rs | 24 ++++++ 3 files changed, 136 insertions(+), 25 deletions(-) diff --git a/bd-artifact-upload/src/uploader.rs b/bd-artifact-upload/src/uploader.rs index 0fa90c2ac..759483e99 100644 --- a/bd-artifact-upload/src/uploader.rs +++ b/bd-artifact-upload/src/uploader.rs @@ -49,18 +49,19 @@ pub static REPORT_DIRECTORY: LazyLock = LazyLock::new(|| "report_upload /// The index file used for tracking all of the individual files. pub static REPORT_INDEX_FILE: LazyLock = LazyLock::new(|| "report_index.pb".into()); -const STATE_SNAPSHOT_TYPE_ID: &str = "state_snapshot"; #[derive(Default, Clone, Copy)] pub enum ArtifactType { #[default] Report, + StateSnapshot, } impl ArtifactType { fn to_type_id(self) -> &'static str { match self { Self::Report => "client_report", + Self::StateSnapshot => "state_snapshot", } } } @@ -626,11 +627,9 @@ impl Uploader { // one) to make space for the newer one. // TODO(snowp): Consider also having a bound on the size of the files persisted to disk. if self.index.len() == usize::try_from(*self.max_entries.read()).unwrap_or_default() { - if let Some(index_to_drop) = self - .index - .iter() - .position(|entry| entry.type_id.as_deref() != Some(STATE_SNAPSHOT_TYPE_ID)) - { + if let Some(index_to_drop) = self.index.iter().position(|entry| { + entry.type_id.as_deref() != Some(ArtifactType::StateSnapshot.to_type_id()) + }) { log::debug!("upload queue is full, dropping oldest non-state upload"); self.stats.dropped.inc(); if index_to_drop == 0 { diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index c69e3e309..cdcf469ba 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -44,10 +44,12 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use time::OffsetDateTime; use tokio::sync::mpsc; +use tokio::time::{Duration, sleep}; /// Capacity of the upload request channel. Requests beyond this are silently dropped — the worker /// will process the queued requests which already cover the needed state range. const UPLOAD_CHANNEL_CAPACITY: usize = 8; +const BACKPRESSURE_RETRY_INTERVAL: Duration = Duration::from_secs(1); /// Key for persisting the state upload index via bd-key-value. static STATE_UPLOAD_KEY: bd_key_value::Key = @@ -64,16 +66,38 @@ pub struct SnapshotRef { } /// A request from a buffer uploader to upload a state snapshot if needed. +#[derive(Clone, Copy)] struct StateUploadRequest { batch_oldest_micros: u64, batch_newest_micros: u64, } +#[derive(Clone, Copy)] +struct PendingRange { + oldest_micros: u64, + newest_micros: u64, +} + +impl PendingRange { + const fn from_request(request: &StateUploadRequest) -> Self { + Self { + oldest_micros: request.batch_oldest_micros, + newest_micros: request.batch_newest_micros, + } + } + + fn merge(&mut self, other: Self) { + self.oldest_micros = self.oldest_micros.min(other.oldest_micros); + self.newest_micros = self.newest_micros.max(other.newest_micros); + } +} + /// Statistics for state upload operations. struct Stats { snapshots_uploaded: Counter, snapshots_skipped: Counter, upload_failures: Counter, + backpressure_pauses: Counter, } impl Stats { @@ -82,6 +106,7 @@ impl Stats { snapshots_uploaded: scope.counter("snapshots_uploaded"), snapshots_skipped: scope.counter("snapshots_skipped"), upload_failures: scope.counter("upload_failures"), + backpressure_pauses: scope.counter("backpressure_pauses"), } } } @@ -164,6 +189,7 @@ impl StateUploadHandle { time_provider, artifact_client, upload_rx, + pending_range: None, stats, }; @@ -227,10 +253,18 @@ pub struct StateUploadWorker { /// Receiver for upload requests from handle instances. upload_rx: mpsc::Receiver, + pending_range: Option, stats: Stats, } +enum ProcessResult { + Progress, + Backpressure, + Skipped, + Error, +} + impl StateUploadWorker { /// Returns the path to the state store directory, if configured. @@ -242,22 +276,65 @@ impl StateUploadWorker { /// Runs the worker event loop, processing upload requests until the channel is closed. pub async fn run(mut self) { log::debug!("state upload worker started"); - while let Some(request) = self.upload_rx.recv().await { - // Drain any additional pending requests to coalesce: keep the widest timestamp range - // across all queued requests so we do the minimum number of snapshots. - let mut oldest = request.batch_oldest_micros; - let mut newest = request.batch_newest_micros; - - while let Ok(extra) = self.upload_rx.try_recv() { - oldest = oldest.min(extra.batch_oldest_micros); - newest = newest.max(extra.batch_newest_micros); + loop { + tokio::select! { + Some(request) = self.upload_rx.recv() => { + self.ingest_request(request); + self.drain_pending_requests(); + self.process_pending().await; + } + () = sleep(BACKPRESSURE_RETRY_INTERVAL), if self.pending_range.is_some() => { + self.process_pending().await; + } + else => break, } + } + } + + fn ingest_request(&mut self, request: StateUploadRequest) { + let incoming = PendingRange::from_request(&request); + if let Some(existing) = &mut self.pending_range { + existing.merge(incoming); + } else { + self.pending_range = Some(incoming); + } + } - self.process_upload(oldest, newest).await; + fn drain_pending_requests(&mut self) { + while let Ok(request) = self.upload_rx.try_recv() { + self.ingest_request(request); } } - async fn process_upload(&self, batch_oldest_micros: u64, batch_newest_micros: u64) { + async fn process_pending(&mut self) { + let Some(pending) = self.pending_range else { + return; + }; + match self + .process_upload(pending.oldest_micros, pending.newest_micros) + .await + { + ProcessResult::Backpressure => { + self.stats.backpressure_pauses.inc(); + }, + _ => { + if self.pending_satisfied(pending.oldest_micros) { + self.pending_range = None; + } + }, + } + } + + fn pending_satisfied(&self, pending_oldest_micros: u64) -> bool { + let uploaded_through = self.state_uploaded_through_micros.load(Ordering::Relaxed); + uploaded_through >= pending_oldest_micros + } + + async fn process_upload( + &self, + batch_oldest_micros: u64, + batch_newest_micros: u64, + ) -> ProcessResult { let uploaded_through = self.state_uploaded_through_micros.load(Ordering::Relaxed); let last_change = self .state_store @@ -270,19 +347,19 @@ impl StateUploadWorker { "state upload: last_change=0, skipping (uploaded_through={uploaded_through}, \ batch_oldest={batch_oldest_micros})" ); - return; + return ProcessResult::Skipped; } // If we've already uploaded state that covers this batch, no upload needed. if uploaded_through >= batch_oldest_micros { self.stats.snapshots_skipped.inc(); - return; + return ProcessResult::Skipped; } // If there are no pending state changes since our last upload, no upload needed. if last_change <= uploaded_through { self.stats.snapshots_skipped.inc(); - return; + return ProcessResult::Skipped; } // Find all snapshot files in (uploaded_through, batch_newest_micros] that need uploading. @@ -293,7 +370,7 @@ impl StateUploadWorker { if let Some(snapshot) = self.get_or_create_snapshot(batch_oldest_micros).await { snapshots.push(snapshot); } else { - return; + return ProcessResult::Skipped; } } @@ -315,7 +392,7 @@ impl StateUploadWorker { snapshot_ref.path.display() ); self.stats.upload_failures.inc(); - return; + return ProcessResult::Error; }, }; @@ -345,21 +422,32 @@ impl StateUploadWorker { Ok(Err(e)) => { log::warn!("failed to persist state snapshot upload entry: {e}"); self.stats.upload_failures.inc(); - return; + if Self::is_backpressure_error(&e.to_string()) { + return ProcessResult::Backpressure; + } + return ProcessResult::Error; }, Err(e) => { log::warn!("state snapshot persistence ack channel dropped: {e}"); self.stats.upload_failures.inc(); - return; + return ProcessResult::Error; }, }, Err(e) => { log::warn!("failed to enqueue state snapshot upload: {e}"); self.stats.upload_failures.inc(); - return; + if Self::is_backpressure_error(&e.to_string()) { + return ProcessResult::Backpressure; + } + return ProcessResult::Error; }, } } + ProcessResult::Progress + } + + fn is_backpressure_error(error: &str) -> bool { + error.contains("queue full") } /// Called after a state snapshot has been successfully uploaded. diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index 30f71bf0b..ef266e344 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -358,3 +358,27 @@ async fn prefers_existing_snapshot_over_on_demand_creation() { ); assert_eq!(snapshots[1].timestamp_micros, newer_snapshot_ts); } + +#[test] +fn pending_range_merge_widens_bounds() { + let mut pending = PendingRange { + oldest_micros: 100, + newest_micros: 200, + }; + pending.merge(PendingRange { + oldest_micros: 50, + newest_micros: 250, + }); + assert_eq!(pending.oldest_micros, 50); + assert_eq!(pending.newest_micros, 250); +} + +#[test] +fn backpressure_error_detection_matches_queue_full() { + assert!(StateUploadWorker::is_backpressure_error( + "upload queue full and all pending uploads are state snapshots" + )); + assert!(!StateUploadWorker::is_backpressure_error( + "failed to open snapshot file" + )); +} From 38340f3ce3fbe7a41b5a35e7e7221e8a8992f931 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Wed, 25 Feb 2026 23:42:13 -0800 Subject: [PATCH 12/32] improve test coverage --- bd-logger/src/state_upload.rs | 194 +++++---- bd-logger/src/state_upload_test.rs | 395 +++++++++++++++++- .../src/test/state_upload_integration.rs | 31 +- 3 files changed, 521 insertions(+), 99 deletions(-) diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index cdcf469ba..15d18bc77 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -258,13 +258,21 @@ pub struct StateUploadWorker { stats: Stats, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProcessResult { Progress, Backpressure, + DeferredCooldown, Skipped, Error, } +enum UploadPreflight { + Skipped, + DeferredCooldown, + Ready(Vec), +} + impl StateUploadWorker { /// Returns the path to the state store directory, if configured. @@ -314,10 +322,14 @@ impl StateUploadWorker { .process_upload(pending.oldest_micros, pending.newest_micros) .await { - ProcessResult::Backpressure => { + ProcessResult::Backpressure | ProcessResult::DeferredCooldown => { self.stats.backpressure_pauses.inc(); }, _ => { + // A "Skipped" result can still mean we're done with this pending range: + // process_upload first recomputes current coverage from persisted state/upload watermark. + // If another earlier upload already advanced coverage past `pending.oldest_micros`, there + // is nothing left to do for this range and we clear it here. if self.pending_satisfied(pending.oldest_micros) { self.pending_range = None; } @@ -325,11 +337,23 @@ impl StateUploadWorker { } } + /// Pending work is satisfied once uploaded coverage reaches the oldest timestamp that required + /// state; newer pending windows are merged before this check. fn pending_satisfied(&self, pending_oldest_micros: u64) -> bool { let uploaded_through = self.state_uploaded_through_micros.load(Ordering::Relaxed); uploaded_through >= pending_oldest_micros } + // State upload flow: + // 1) Build an upload plan in `plan_upload_attempt` by checking coverage/last-change state, + // finding in-range snapshots, and deciding whether on-demand snapshot creation is needed. + // 2) Handle preflight outcomes: + // - `Skipped`: no work required for current coverage. + // - `DeferredCooldown`: uncovered changes exist but snapshot creation is rate-limited. + // - `Ready`: concrete snapshots should be uploaded now. + // 3) For each ready snapshot, enqueue and wait for persistence ack. + // 4) Advance `state_uploaded_through_micros` only after a successful persistence ack via + // `on_state_uploaded`, so deferred/failed attempts never move the watermark. async fn process_upload( &self, batch_oldest_micros: u64, @@ -341,38 +365,19 @@ impl StateUploadWorker { .as_ref() .map_or(0, |s| s.last_change_micros()); - // If we've never seen any state changes, no upload needed. - if last_change == 0 { - log::debug!( - "state upload: last_change=0, skipping (uploaded_through={uploaded_through}, \ - batch_oldest={batch_oldest_micros})" - ); - return ProcessResult::Skipped; - } - - // If we've already uploaded state that covers this batch, no upload needed. - if uploaded_through >= batch_oldest_micros { - self.stats.snapshots_skipped.inc(); - return ProcessResult::Skipped; - } - - // If there are no pending state changes since our last upload, no upload needed. - if last_change <= uploaded_through { - self.stats.snapshots_skipped.inc(); - return ProcessResult::Skipped; - } - - // Find all snapshot files in (uploaded_through, batch_newest_micros] that need uploading. - let mut snapshots = self.find_snapshots_in_range(uploaded_through, batch_newest_micros); - - // If no existing snapshots cover the range, create one on-demand. - if snapshots.is_empty() { - if let Some(snapshot) = self.get_or_create_snapshot(batch_oldest_micros).await { - snapshots.push(snapshot); - } else { - return ProcessResult::Skipped; - } - } + let snapshots = match self + .plan_upload_attempt( + batch_oldest_micros, + batch_newest_micros, + uploaded_through, + last_change, + ) + .await + { + UploadPreflight::Skipped => return ProcessResult::Skipped, + UploadPreflight::DeferredCooldown => return ProcessResult::DeferredCooldown, + UploadPreflight::Ready(snapshots) => snapshots, + }; // Upload each snapshot in order, advancing the watermark after each confirmed upload. for snapshot_ref in snapshots { @@ -396,10 +401,10 @@ impl StateUploadWorker { }, }; - // Convert timestamp from microseconds to OffsetDateTime. - let timestamp = - OffsetDateTime::from_unix_timestamp_nanos(i128::from(snapshot_ref.timestamp_micros) * 1000) - .ok(); + let timestamp = OffsetDateTime::from_unix_timestamp_micros( + snapshot_ref.timestamp_micros.try_into().unwrap_or_default(), + ) + .ok(); let (persisted_tx, persisted_rx) = tokio::sync::oneshot::channel(); match self.artifact_client.enqueue_upload( @@ -446,6 +451,68 @@ impl StateUploadWorker { ProcessResult::Progress } + async fn plan_upload_attempt( + &self, + batch_oldest_micros: u64, + batch_newest_micros: u64, + uploaded_through: u64, + last_change: u64, + ) -> UploadPreflight { + if last_change == 0 { + log::debug!( + "state upload: last_change=0, skipping (uploaded_through={uploaded_through}, \ + batch_oldest={batch_oldest_micros})" + ); + return UploadPreflight::Skipped; + } + + if uploaded_through >= batch_oldest_micros { + self.stats.snapshots_skipped.inc(); + return UploadPreflight::Skipped; + } + + if last_change <= uploaded_through { + self.stats.snapshots_skipped.inc(); + return UploadPreflight::Skipped; + } + + let mut snapshots = self.find_snapshots_in_range(uploaded_through, batch_newest_micros); + let effective_coverage = snapshots + .last() + .map_or(uploaded_through, |snapshot| snapshot.timestamp_micros); + + if last_change > effective_coverage { + let now_micros = self + .time_provider + .now() + .unix_timestamp_micros() + .cast_unsigned(); + if self.snapshot_creation_on_cooldown(now_micros) { + self.stats.snapshots_skipped.inc(); + log::debug!( + "deferring snapshot creation due to cooldown (last={}, now={now_micros}, interval={})", + self.last_snapshot_creation_micros.load(Ordering::Relaxed), + self.snapshot_creation_interval_micros + ); + return UploadPreflight::DeferredCooldown; + } + + if let Some(snapshot) = self + .create_snapshot_if_needed(effective_coverage.saturating_add(1)) + .await + && snapshot.timestamp_micros > effective_coverage + { + snapshots.push(snapshot); + } + } + + if snapshots.is_empty() { + UploadPreflight::Skipped + } else { + UploadPreflight::Ready(snapshots) + } + } + fn is_backpressure_error(error: &str) -> bool { error.contains("queue full") } @@ -507,37 +574,14 @@ impl StateUploadWorker { found } - /// Returns the most recently created snapshot with timestamp ≤ `up_to_micros`, if any. - fn find_most_recent_snapshot(&self, up_to_micros: u64) -> Option { - let state_path = self.state_store_path.as_ref()?; - let snapshots_dir = state_path.join("snapshots"); - let entries = std::fs::read_dir(&snapshots_dir).ok()?; - entries - .flatten() - .filter_map(|entry| { - let path = entry.path(); - let filename = path.file_name().and_then(|f| f.to_str())?.to_owned(); - let parsed = SnapshotFilename::parse(&filename)?; - if parsed.timestamp_micros <= up_to_micros { - Some(SnapshotRef { - timestamp_micros: parsed.timestamp_micros, - path, - }) - } else { - None - } - }) - .max_by_key(|s| s.timestamp_micros) - } - - /// Finds an existing snapshot or creates a new one for the given timestamp. + /// Creates a new snapshot for uncovered state changes, if needed. /// /// Implements cooldown logic to prevent excessive snapshot creation during high-volume log - /// streaming. If a snapshot was created recently (within `snapshot_creation_interval_micros`), - /// returns `None` to skip this upload cycle. - pub(crate) async fn get_or_create_snapshot( + /// streaming. If a snapshot was created recently (within + /// `snapshot_creation_interval_micros`), returns `None` to defer creation for a later retry. + pub(crate) async fn create_snapshot_if_needed( &self, - batch_oldest_micros: u64, + min_uncovered_micros: u64, ) -> Option { let state_store = self.state_store.as_ref()?; @@ -545,22 +589,18 @@ impl StateUploadWorker { let now = self.time_provider.now(); now.unix_timestamp_micros().cast_unsigned() }; - let last_creation = self.last_snapshot_creation_micros.load(Ordering::Relaxed); - if last_creation > 0 - && now_micros.saturating_sub(last_creation) < self.snapshot_creation_interval_micros - { + if self.snapshot_creation_on_cooldown(now_micros) { log::debug!( - "skipping snapshot creation due to cooldown (last={last_creation}, now={now_micros}, \ - interval={})", + "skipping snapshot creation due to cooldown (last={}, now={now_micros}, interval={})", + self.last_snapshot_creation_micros.load(Ordering::Relaxed), self.snapshot_creation_interval_micros ); self.stats.snapshots_skipped.inc(); - // Return the most recent existing snapshot instead of creating a new one. - return self.find_most_recent_snapshot(now_micros); + return None; } log::debug!( - "no existing snapshot covers log batch (oldest={batch_oldest_micros}), creating new snapshot" + "creating snapshot for uncovered state changes (min_uncovered_micros={min_uncovered_micros})" ); let Some(snapshot_path) = state_store.rotate_journal().await else { @@ -585,4 +625,10 @@ impl StateUploadWorker { path: snapshot_path, }) } + + fn snapshot_creation_on_cooldown(&self, now_micros: u64) -> bool { + let last_creation = self.last_snapshot_creation_micros.load(Ordering::Relaxed); + last_creation > 0 + && now_micros.saturating_sub(last_creation) < self.snapshot_creation_interval_micros + } } diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index ef266e344..95f8b6718 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -11,7 +11,9 @@ use super::*; use bd_runtime::runtime::{ConfigLoader, FeatureFlag as _}; use bd_test_helpers::session::in_memory_store; use bd_time::{SystemTimeProvider, TestTimeProvider}; +use std::sync::atomic::AtomicUsize; use time::OffsetDateTime; +use uuid::Uuid; /// Creates a persistent `bd_state::Store` backed by the given directory with snapshotting enabled. /// Inserts a dummy entry so that `rotate_journal` produces a non-empty snapshot file. @@ -131,10 +133,9 @@ async fn cooldown_prevents_rapid_snapshot_creation() { ) .await; - let batch_ts = - OffsetDateTime::now_utc().unix_timestamp().cast_unsigned() * 1_000_000 + u64::MAX / 2; + let batch_ts = 0; - let snapshot1 = worker.get_or_create_snapshot(batch_ts).await; + let snapshot1 = worker.create_snapshot_if_needed(batch_ts).await; assert!(snapshot1.is_some(), "first snapshot should be created"); // Count snapshot files created. @@ -142,10 +143,10 @@ async fn cooldown_prevents_rapid_snapshot_creation() { let file_count_after_first = count_files(); assert!(file_count_after_first >= 1, "snapshot file should exist"); - let snapshot2 = worker.get_or_create_snapshot(batch_ts + 1000).await; + let snapshot2 = worker.create_snapshot_if_needed(batch_ts).await; assert!( - snapshot2.is_some(), - "second call should return existing snapshot" + snapshot2.is_none(), + "second call should defer due to cooldown" ); assert_eq!( count_files(), @@ -177,9 +178,9 @@ async fn cooldown_allows_snapshot_after_interval() { ) .await; - let batch_ts = time_provider.now().unix_timestamp().cast_unsigned() * 1_000_000 + u64::MAX / 2; + let batch_ts = time_provider.now().unix_timestamp_micros().cast_unsigned(); - let snapshot1 = worker.get_or_create_snapshot(batch_ts).await; + let snapshot1 = worker.create_snapshot_if_needed(batch_ts).await; assert!(snapshot1.is_some()); let count_files = || std::fs::read_dir(&snapshots_dir).unwrap().count(); @@ -195,7 +196,7 @@ async fn cooldown_allows_snapshot_after_interval() { } let future_batch_ts = batch_ts + 10_000_000; - let snapshot2 = worker.get_or_create_snapshot(future_batch_ts).await; + let snapshot2 = worker.create_snapshot_if_needed(future_batch_ts).await; assert!(snapshot2.is_some()); assert_eq!( count_files(), @@ -226,8 +227,9 @@ async fn zero_cooldown_allows_immediate_snapshot_creation() { ) .await; - let base_ts = - OffsetDateTime::now_utc().unix_timestamp().cast_unsigned() * 1_000_000 + u64::MAX / 2; + let base_ts = OffsetDateTime::now_utc() + .unix_timestamp_micros() + .cast_unsigned(); let count_files = || std::fs::read_dir(&snapshots_dir).unwrap().count(); let mut total_snapshots = 0; @@ -242,7 +244,7 @@ async fn zero_cooldown_allows_immediate_snapshot_creation() { } let snapshot = worker - .get_or_create_snapshot(base_ts + i * 10_000_000) + .create_snapshot_if_needed(base_ts + i * 10_000_000) .await; assert!(snapshot.is_some()); total_snapshots += count_files(); @@ -307,10 +309,11 @@ async fn creates_on_demand_snapshot_when_none_exists() { ) .await; - let batch_ts = - OffsetDateTime::now_utc().unix_timestamp().cast_unsigned() * 1_000_000 + u64::MAX / 2; + let batch_ts = OffsetDateTime::now_utc() + .unix_timestamp_micros() + .cast_unsigned(); - let snapshot = worker.get_or_create_snapshot(batch_ts).await; + let snapshot = worker.create_snapshot_if_needed(batch_ts).await; assert!(snapshot.is_some()); assert_eq!( @@ -382,3 +385,365 @@ fn backpressure_error_detection_matches_queue_full() { "failed to open snapshot file" )); } + +#[tokio::test] +async fn cooldown_defer_does_not_advance_watermark() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + + let (state_store, retention_registry) = make_state_store(&state_dir).await; + let (_handle, mut worker) = StateUploadHandle::new( + Some(state_dir), + store, + Some(retention_registry), + Some(state_store), + 1000, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + // Set recent snapshot creation time to force a cooldown defer path. + let _created = worker.create_snapshot_if_needed(0).await.unwrap(); + for entry in std::fs::read_dir(&snapshots_dir).unwrap() { + let entry = entry.unwrap(); + std::fs::remove_file(entry.path()).unwrap(); + } + + worker.ingest_request(StateUploadRequest { + batch_oldest_micros: 1, + batch_newest_micros: OffsetDateTime::now_utc() + .unix_timestamp_micros() + .cast_unsigned(), + }); + worker.process_pending().await; + + assert_eq!( + worker.state_uploaded_through_micros.load(Ordering::Relaxed), + 0, + "cooldown defer must not advance uploaded watermark" + ); + assert!( + worker.pending_range.is_some(), + "cooldown defer should keep pending range for retry" + ); +} + +fn write_snapshot_file(snapshots_dir: &std::path::Path, timestamp_micros: u64) { + std::fs::create_dir_all(snapshots_dir).unwrap(); + let snapshot = snapshots_dir.join(format!("state.jrn.g0.t{timestamp_micros}.zz")); + std::fs::write(snapshot, b"snapshot").unwrap(); +} + +#[tokio::test] +async fn enqueue_backpressure_keeps_pending_range() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let (state_store, retention_registry) = make_state_store(&state_dir).await; + let snapshot_ts = 2_000_000_000_000_000; + write_snapshot_file(&snapshots_dir, snapshot_ts); + + let mut mock_client = bd_artifact_upload::MockClient::new(); + mock_client + .expect_enqueue_upload() + .times(1) + .returning(|_, _, _, _, _, _, _| Err(anyhow::anyhow!("queue full"))); + + let (_handle, mut worker) = StateUploadHandle::new( + Some(state_dir), + store, + Some(retention_registry), + Some(state_store), + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(mock_client), + &stats, + ) + .await; + + worker.ingest_request(StateUploadRequest { + batch_oldest_micros: 1, + batch_newest_micros: snapshot_ts, + }); + worker.process_pending().await; + + assert_eq!( + worker.state_uploaded_through_micros.load(Ordering::Relaxed), + 0 + ); + assert!(worker.pending_range.is_some()); +} + +#[tokio::test] +async fn persisted_ack_error_does_not_advance_watermark() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let (state_store, retention_registry) = make_state_store(&state_dir).await; + let snapshot_ts = 2_000_000_000_000_000; + write_snapshot_file(&snapshots_dir, snapshot_ts); + + let mut mock_client = bd_artifact_upload::MockClient::new(); + mock_client + .expect_enqueue_upload() + .times(1) + .returning(|_, _, _, _, _, _, persisted_tx| { + if let Some(tx) = persisted_tx { + let _ = tx.send(Err(anyhow::anyhow!("persistence failed"))); + } + Ok(Uuid::new_v4()) + }); + + let (_handle, worker) = StateUploadHandle::new( + Some(state_dir), + store, + Some(retention_registry), + Some(state_store), + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(mock_client), + &stats, + ) + .await; + + let result = worker.process_upload(1, snapshot_ts).await; + assert_eq!(result, ProcessResult::Error); + assert_eq!( + worker.state_uploaded_through_micros.load(Ordering::Relaxed), + 0 + ); +} + +#[tokio::test] +async fn persisted_ack_channel_drop_does_not_advance_watermark() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let (state_store, retention_registry) = make_state_store(&state_dir).await; + let snapshot_ts = 2_000_000_000_000_000; + write_snapshot_file(&snapshots_dir, snapshot_ts); + + let mut mock_client = bd_artifact_upload::MockClient::new(); + mock_client + .expect_enqueue_upload() + .times(1) + .returning(|_, _, _, _, _, _, _| Ok(Uuid::new_v4())); + + let (_handle, worker) = StateUploadHandle::new( + Some(state_dir), + store, + Some(retention_registry), + Some(state_store), + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(mock_client), + &stats, + ) + .await; + + let result = worker.process_upload(1, snapshot_ts).await; + assert_eq!(result, ProcessResult::Error); + assert_eq!( + worker.state_uploaded_through_micros.load(Ordering::Relaxed), + 0 + ); +} + +#[tokio::test] +async fn successful_enqueue_ack_advances_watermark_and_clears_pending() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let (state_store, retention_registry) = make_state_store(&state_dir).await; + let snapshot_ts = 2_000_000_000_000_000; + write_snapshot_file(&snapshots_dir, snapshot_ts); + + let mut mock_client = bd_artifact_upload::MockClient::new(); + mock_client + .expect_enqueue_upload() + .times(1) + .returning(|_, _, _, _, _, _, persisted_tx| { + if let Some(tx) = persisted_tx { + let _ = tx.send(Ok(())); + } + Ok(Uuid::new_v4()) + }); + + let (_handle, mut worker) = StateUploadHandle::new( + Some(state_dir), + store, + Some(retention_registry), + Some(state_store), + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(mock_client), + &stats, + ) + .await; + + worker.ingest_request(StateUploadRequest { + batch_oldest_micros: 1, + batch_newest_micros: snapshot_ts, + }); + worker.process_pending().await; + + assert_eq!( + worker.state_uploaded_through_micros.load(Ordering::Relaxed), + snapshot_ts + ); + assert!(worker.pending_range.is_none()); +} + +#[tokio::test] +async fn multiple_snapshots_partial_backpressure_keeps_pending_with_partial_progress() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let (state_store, retention_registry) = make_state_store(&state_dir).await; + let first_snapshot_ts = 1_900_000_000_000_000; + let second_snapshot_ts = 2_000_000_000_000_000; + write_snapshot_file(&snapshots_dir, first_snapshot_ts); + write_snapshot_file(&snapshots_dir, second_snapshot_ts); + + let call_count = AtomicUsize::new(0); + let mut mock_client = bd_artifact_upload::MockClient::new(); + mock_client + .expect_enqueue_upload() + .times(2) + .returning(move |_, _, _, _, _, _, persisted_tx| { + if call_count.fetch_add(1, Ordering::Relaxed) == 0 { + if let Some(tx) = persisted_tx { + let _ = tx.send(Ok(())); + } + Ok(Uuid::new_v4()) + } else { + Err(anyhow::anyhow!("queue full")) + } + }); + + let (_handle, mut worker) = StateUploadHandle::new( + Some(state_dir), + store, + Some(retention_registry), + Some(state_store), + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(mock_client), + &stats, + ) + .await; + + worker.ingest_request(StateUploadRequest { + batch_oldest_micros: 1, + batch_newest_micros: second_snapshot_ts, + }); + worker.process_pending().await; + + assert_eq!( + worker.state_uploaded_through_micros.load(Ordering::Relaxed), + first_snapshot_ts + ); + assert!(worker.pending_range.is_some()); +} + +#[tokio::test] +async fn plan_upload_attempt_skips_last_change_zero_already_covered_and_no_new_changes() { + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let (_handle, worker) = StateUploadHandle::new( + None, + store, + None, + None, + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + let result = worker.plan_upload_attempt(10, 20, 0, 0).await; + assert!(matches!(result, UploadPreflight::Skipped)); + + let result = worker.plan_upload_attempt(10, 20, 10, 15).await; + assert!(matches!(result, UploadPreflight::Skipped)); + + let result = worker.plan_upload_attempt(10, 20, 9, 9).await; + assert!(matches!(result, UploadPreflight::Skipped)); +} + +#[tokio::test] +async fn plan_upload_attempt_returns_ready_for_in_range_snapshots() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + write_snapshot_file(&snapshots_dir, 100); + write_snapshot_file(&snapshots_dir, 200); + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let (_handle, worker) = StateUploadHandle::new( + Some(state_dir), + store, + None, + None, + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + match worker.plan_upload_attempt(1, 200, 0, 200).await { + UploadPreflight::Ready(snapshots) => { + assert_eq!(snapshots.len(), 2); + assert_eq!(snapshots[0].timestamp_micros, 100); + assert_eq!(snapshots[1].timestamp_micros, 200); + }, + _ => panic!("expected ready preflight"), + } +} + +#[tokio::test] +async fn plan_upload_attempt_defers_when_effective_coverage_is_behind_and_on_cooldown() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + write_snapshot_file(&snapshots_dir, 100); + + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let time_provider = Arc::new(TestTimeProvider::new(OffsetDateTime::now_utc())); + let (_handle, worker) = StateUploadHandle::new( + Some(state_dir), + store, + None, + None, + 1_000, + time_provider.clone(), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + let now = time_provider.now().unix_timestamp_micros().cast_unsigned(); + worker + .last_snapshot_creation_micros + .store(now, Ordering::Relaxed); + + let result = worker.plan_upload_attempt(1, 200, 0, 300).await; + assert!(matches!(result, UploadPreflight::DeferredCooldown)); +} diff --git a/bd-logger/src/test/state_upload_integration.rs b/bd-logger/src/test/state_upload_integration.rs index df18e3aee..29e1b5ac4 100644 --- a/bd-logger/src/test/state_upload_integration.rs +++ b/bd-logger/src/test/state_upload_integration.rs @@ -10,6 +10,7 @@ use super::setup::{Setup, SetupOptions}; use crate::log_level; use bd_log_matcher::builder::message_equals; +use bd_proto::protos::bdtail::bdtail_config::{BdTailConfigurations, BdTailStream}; use bd_proto::protos::client::api::configuration_update::StateOfTheWorld; use bd_proto::protos::config::v1::config::{BufferConfigList, buffer_config}; use bd_proto::protos::logging::payload::LogType; @@ -483,7 +484,7 @@ fn new_state_changes_trigger_new_snapshot() { } #[test] -fn continuous_streaming_uploads_state_with_first_batch() { +fn stream_only_buffer_does_not_upload_state_snapshot() { let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); let mut setup = Setup::new_with_cached_runtime(SetupOptions { @@ -514,7 +515,21 @@ fn continuous_streaming_uploads_state_with_first_batch() { ..Default::default() }); - setup.configure_stream_all_logs(); + setup.send_configuration_update(configuration_update( + "", + StateOfTheWorld { + bdtail_configuration: Some(BdTailConfigurations { + active_streams: vec![BdTailStream { + stream_id: "all".into(), + matcher: None.into(), + ..Default::default() + }], + ..Default::default() + }) + .into(), + ..Default::default() + }, + )); setup .logger_handle @@ -538,18 +553,14 @@ fn continuous_streaming_uploads_state_with_first_batch() { ); let log_upload = setup.server.blocking_next_log_upload(); - assert!(log_upload.is_some(), "expected log upload"); + assert!(log_upload.is_some(), "expected streamed log upload"); let timeout = std::time::Duration::from_secs(2); let start = std::time::Instant::now(); let mut found_artifact = false; while start.elapsed() < timeout { - if let Some(artifact) = setup.server.blocking_next_artifact_upload() { - assert!( - !artifact.contents.is_empty(), - "state snapshot should have content" - ); + if setup.server.blocking_next_artifact_upload().is_some() { found_artifact = true; break; } @@ -557,8 +568,8 @@ fn continuous_streaming_uploads_state_with_first_batch() { } assert!( - found_artifact, - "expected artifact upload for state snapshot with continuous streaming" + !found_artifact, + "stream-only buffers should not trigger state snapshot artifact upload" ); } From 7e4606f681c9ca243a82c1b237339a5169f519eb Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 07:48:33 -0800 Subject: [PATCH 13/32] use enum for error propagation --- bd-artifact-upload/src/lib.rs | 2 +- bd-artifact-upload/src/uploader.rs | 39 ++++++---- bd-artifact-upload/src/uploader_test.rs | 10 ++- bd-logger/src/state_upload.rs | 10 +-- bd-logger/src/state_upload_test.rs | 16 +--- .../src/test/state_upload_integration.rs | 73 +++++++++++++++++++ 6 files changed, 113 insertions(+), 37 deletions(-) diff --git a/bd-artifact-upload/src/lib.rs b/bd-artifact-upload/src/lib.rs index f5a8bf58b..ae3bcf9fd 100644 --- a/bd-artifact-upload/src/lib.rs +++ b/bd-artifact-upload/src/lib.rs @@ -16,7 +16,7 @@ mod uploader; -pub use uploader::{Client, MockClient, SnappedFeatureFlag, Uploader}; +pub use uploader::{Client, EnqueueError, MockClient, SnappedFeatureFlag, Uploader}; #[cfg(test)] #[ctor::ctor] diff --git a/bd-artifact-upload/src/uploader.rs b/bd-artifact-upload/src/uploader.rs index 759483e99..38719508c 100644 --- a/bd-artifact-upload/src/uploader.rs +++ b/bd-artifact-upload/src/uploader.rs @@ -118,7 +118,7 @@ struct NewUpload { timestamp: Option, session_id: String, feature_flags: Vec, - persisted_tx: Option>>, + persisted_tx: Option>>, } // Used for bounded_buffer logs @@ -178,6 +178,16 @@ impl Stats { } } +#[derive(Debug, thiserror::Error)] +pub enum EnqueueError { + #[error("upload queue full")] + QueueFull, + #[error("upload channel closed")] + Closed, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + #[automock] pub trait Client: Send + Sync { fn enqueue_upload( @@ -188,8 +198,8 @@ pub trait Client: Send + Sync { timestamp: Option, session_id: String, feature_flags: Vec, - persisted_tx: Option>>, - ) -> anyhow::Result; + persisted_tx: Option>>, + ) -> std::result::Result; } pub struct UploadClient { @@ -207,8 +217,8 @@ impl Client for UploadClient { timestamp: Option, session_id: String, feature_flags: Vec, - persisted_tx: Option>>, - ) -> anyhow::Result { + persisted_tx: Option>>, + ) -> std::result::Result { let uuid = uuid::Uuid::new_v4(); let result = self @@ -226,7 +236,10 @@ impl Client for UploadClient { .inspect_err(|e| log::warn!("failed to enqueue artifact upload: {e:?}")); self.counter_stats.record(&result); - result?; + result.map_err(|e| match e { + bd_bounded_buffer::TrySendError::FullSizeOverflow => EnqueueError::QueueFull, + bd_bounded_buffer::TrySendError::Closed => EnqueueError::Closed, + })?; Ok(uuid) } @@ -621,7 +634,7 @@ impl Uploader { session_id: String, timestamp: Option, feature_flags: Vec, - mut persisted_tx: Option>>, + mut persisted_tx: Option>>, ) { // If we've reached our limit of entries, stop the entry currently being uploaded (the oldest // one) to make space for the newer one. @@ -645,9 +658,7 @@ impl Uploader { } else { self.stats.dropped.inc(); if let Some(tx) = persisted_tx.take() { - let _ = tx.send(Err(anyhow::anyhow!( - "upload queue full and all pending uploads are state snapshots" - ))); + let _ = tx.send(Err(EnqueueError::QueueFull)); } return; } @@ -664,9 +675,9 @@ impl Uploader { Err(e) => { log::warn!("failed to create file for artifact: {uuid} on disk: {e}"); if let Some(tx) = persisted_tx.take() { - let _ = tx.send(Err(anyhow::anyhow!( + let _ = tx.send(Err(EnqueueError::Other(anyhow::anyhow!( "failed to create file for artifact {uuid}: {e}" - ))); + )))); } #[cfg(test)] @@ -681,9 +692,9 @@ impl Uploader { { log::warn!("failed to write artifact to disk: {uuid} to disk: {e}"); if let Some(tx) = persisted_tx.take() { - let _ = tx.send(Err(anyhow::anyhow!( + let _ = tx.send(Err(EnqueueError::Other(anyhow::anyhow!( "failed to write artifact to disk {uuid}: {e}" - ))); + )))); } #[cfg(test)] diff --git a/bd-artifact-upload/src/uploader_test.rs b/bd-artifact-upload/src/uploader_test.rs index 4e7aa7ff3..87eed4be5 100644 --- a/bd-artifact-upload/src/uploader_test.rs +++ b/bd-artifact-upload/src/uploader_test.rs @@ -6,7 +6,13 @@ // https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt use super::UploadClient; -use crate::uploader::{Client, REPORT_DIRECTORY, REPORT_INDEX_FILE, SnappedFeatureFlag}; +use crate::uploader::{ + Client, + EnqueueError, + REPORT_DIRECTORY, + REPORT_INDEX_FILE, + SnappedFeatureFlag, +}; use assert_matches::assert_matches; use bd_api::DataUpload; use bd_api::upload::{IntentResponse, UploadResponse}; @@ -963,7 +969,7 @@ async fn queue_full_with_only_state_snapshots_rejects_new_state_snapshot() { ) .unwrap(); - assert!(persisted_rx2.await.unwrap().is_err()); + assert_matches!(persisted_rx2.await.unwrap(), Err(EnqueueError::QueueFull)); assert!( timeout(100.std_milliseconds(), setup.entry_received_rx.recv()) .await diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index 15d18bc77..d825d2052 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -32,7 +32,7 @@ #[path = "./state_upload_test.rs"] mod tests; -use bd_artifact_upload::Client as ArtifactClient; +use bd_artifact_upload::{Client as ArtifactClient, EnqueueError}; use bd_client_stats_store::{Counter, Scope}; use bd_log_primitives::LogFields; use bd_resilient_kv::SnapshotFilename; @@ -427,7 +427,7 @@ impl StateUploadWorker { Ok(Err(e)) => { log::warn!("failed to persist state snapshot upload entry: {e}"); self.stats.upload_failures.inc(); - if Self::is_backpressure_error(&e.to_string()) { + if matches!(e, EnqueueError::QueueFull) { return ProcessResult::Backpressure; } return ProcessResult::Error; @@ -441,7 +441,7 @@ impl StateUploadWorker { Err(e) => { log::warn!("failed to enqueue state snapshot upload: {e}"); self.stats.upload_failures.inc(); - if Self::is_backpressure_error(&e.to_string()) { + if matches!(e, EnqueueError::QueueFull) { return ProcessResult::Backpressure; } return ProcessResult::Error; @@ -513,10 +513,6 @@ impl StateUploadWorker { } } - fn is_backpressure_error(error: &str) -> bool { - error.contains("queue full") - } - /// Called after a state snapshot has been successfully uploaded. fn on_state_uploaded(&self, snapshot_timestamp_micros: u64) { self diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index 95f8b6718..90cfff13a 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -376,16 +376,6 @@ fn pending_range_merge_widens_bounds() { assert_eq!(pending.newest_micros, 250); } -#[test] -fn backpressure_error_detection_matches_queue_full() { - assert!(StateUploadWorker::is_backpressure_error( - "upload queue full and all pending uploads are state snapshots" - )); - assert!(!StateUploadWorker::is_backpressure_error( - "failed to open snapshot file" - )); -} - #[tokio::test] async fn cooldown_defer_does_not_advance_watermark() { let temp_dir = tempfile::tempdir().unwrap(); @@ -454,7 +444,7 @@ async fn enqueue_backpressure_keeps_pending_range() { mock_client .expect_enqueue_upload() .times(1) - .returning(|_, _, _, _, _, _, _| Err(anyhow::anyhow!("queue full"))); + .returning(|_, _, _, _, _, _, _| Err(bd_artifact_upload::EnqueueError::QueueFull)); let (_handle, mut worker) = StateUploadHandle::new( Some(state_dir), @@ -498,7 +488,7 @@ async fn persisted_ack_error_does_not_advance_watermark() { .times(1) .returning(|_, _, _, _, _, _, persisted_tx| { if let Some(tx) = persisted_tx { - let _ = tx.send(Err(anyhow::anyhow!("persistence failed"))); + let _ = tx.send(Err(bd_artifact_upload::EnqueueError::Closed)); } Ok(Uuid::new_v4()) }); @@ -632,7 +622,7 @@ async fn multiple_snapshots_partial_backpressure_keeps_pending_with_partial_prog } Ok(Uuid::new_v4()) } else { - Err(anyhow::anyhow!("queue full")) + Err(bd_artifact_upload::EnqueueError::QueueFull) } }); diff --git a/bd-logger/src/test/state_upload_integration.rs b/bd-logger/src/test/state_upload_integration.rs index 29e1b5ac4..92d212a2d 100644 --- a/bd-logger/src/test/state_upload_integration.rs +++ b/bd-logger/src/test/state_upload_integration.rs @@ -573,6 +573,79 @@ fn stream_only_buffer_does_not_upload_state_snapshot() { ); } +#[test] +fn continuous_buffer_uploads_state_with_first_batch() { + let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); + + let mut setup = Setup::new_with_cached_runtime(SetupOptions { + sdk_directory, + disk_storage: true, + extra_runtime_values: vec![ + ( + bd_runtime::runtime::state::UsePersistentStorage::path(), + ValueKind::Bool(true), + ), + ( + bd_runtime::runtime::log_upload::BatchSizeFlag::path(), + ValueKind::Int(2), + ), + ( + bd_runtime::runtime::state::SnapshotCreationIntervalMs::path(), + ValueKind::Int(0), + ), + ( + bd_runtime::runtime::state::MaxSnapshotCount::path(), + ValueKind::Int(10), + ), + ( + bd_runtime::runtime::state::StateUploadEnabled::path(), + ValueKind::Bool(true), + ), + ], + ..Default::default() + }); + + setup.configure_stream_all_logs(); + setup + .logger_handle + .set_feature_flag_exposure("continuous_flag".to_string(), Some("active".to_string())); + + setup.log( + log_level::INFO, + LogType::NORMAL, + "continuous log 1".into(), + [].into(), + [].into(), + None, + ); + setup.log( + log_level::INFO, + LogType::NORMAL, + "continuous log 2".into(), + [].into(), + [].into(), + None, + ); + + let log_upload = setup.server.blocking_next_log_upload(); + assert!(log_upload.is_some(), "expected continuous log upload"); + + let timeout = std::time::Duration::from_secs(2); + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if let Some(artifact) = setup.server.blocking_next_artifact_upload() { + assert!( + !artifact.contents.is_empty(), + "continuous buffers should trigger state snapshot artifact upload" + ); + return; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + panic!("expected artifact upload for state snapshot with continuous buffers"); +} + #[test] fn continuous_streaming_multiple_batches_single_state_upload() { let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); From 66b7107a02c80d5234c102283e7a4d256fa90b56 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 09:37:56 -0800 Subject: [PATCH 14/32] avoid losing ranges if handle ch is full --- bd-logger/src/state_upload.rs | 185 ++++---- bd-logger/src/state_upload_test.rs | 676 ++++++++++------------------- 2 files changed, 322 insertions(+), 539 deletions(-) diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index d825d2052..cc3540407 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -21,8 +21,8 @@ //! Upload coordination is split into two parts: //! //! - [`StateUploadHandle`] — a cheap, cloneable handle held by each buffer uploader. Callers -//! fire-and-forget upload requests via [`StateUploadHandle::notify_upload_needed`], which sends -//! to a bounded channel without blocking. +//! fire-and-forget upload requests via [`StateUploadHandle::notify_upload_needed`], which +//! coalesces ranges in shared state and emits best-effort wake signals without blocking. //! //! - [`StateUploadWorker`] — a single background task that owns all snapshot creation and upload //! logic. Because only one task processes requests, deduplication and cooldown enforcement happen @@ -46,9 +46,8 @@ use time::OffsetDateTime; use tokio::sync::mpsc; use tokio::time::{Duration, sleep}; -/// Capacity of the upload request channel. Requests beyond this are silently dropped — the worker -/// will process the queued requests which already cover the needed state range. -const UPLOAD_CHANNEL_CAPACITY: usize = 8; +/// Capacity of the worker wake channel used to nudge processing of coalesced pending ranges. +const UPLOAD_CHANNEL_CAPACITY: usize = 1; const BACKPRESSURE_RETRY_INTERVAL: Duration = Duration::from_secs(1); /// Key for persisting the state upload index via bd-key-value. @@ -65,13 +64,6 @@ pub struct SnapshotRef { pub path: PathBuf, } -/// A request from a buffer uploader to upload a state snapshot if needed. -#[derive(Clone, Copy)] -struct StateUploadRequest { - batch_oldest_micros: u64, - batch_newest_micros: u64, -} - #[derive(Clone, Copy)] struct PendingRange { oldest_micros: u64, @@ -79,20 +71,19 @@ struct PendingRange { } impl PendingRange { - const fn from_request(request: &StateUploadRequest) -> Self { - Self { - oldest_micros: request.batch_oldest_micros, - newest_micros: request.batch_newest_micros, - } - } - fn merge(&mut self, other: Self) { self.oldest_micros = self.oldest_micros.min(other.oldest_micros); self.newest_micros = self.newest_micros.max(other.newest_micros); } } -/// Statistics for state upload operations. +#[derive(Default)] +struct PendingAccumulator { + range: Option, + version: u64, + wake_queued: bool, +} + struct Stats { snapshots_uploaded: Counter, snapshots_skipped: Counter, @@ -113,15 +104,17 @@ impl Stats { /// Coordinates state snapshot uploads before log uploads. /// -/// This is a lightweight, cloneable sender handle. Buffer uploaders call +/// This is a lightweight, cloneable coalescing handle. Buffer uploaders call /// [`notify_upload_needed`][Self::notify_upload_needed] in a fire-and-forget manner — /// the call is non-blocking and never waits for the snapshot to be created or uploaded. /// /// All actual snapshot creation and upload logic is handled by the companion /// [`StateUploadWorker`], which runs as a single background task. pub struct StateUploadHandle { - /// Channel for sending upload requests to the background worker. - upload_tx: mpsc::Sender, + /// Best-effort wake channel for nudging the background worker. + wake_tx: mpsc::Sender<()>, + /// Shared pending-range accumulator. + pending_accumulator: Arc>, } impl StateUploadHandle { @@ -130,16 +123,6 @@ impl StateUploadHandle { /// The returned [`StateUploadWorker`] must be spawned (e.g. via `tokio::spawn` or included in a /// `try_join!`) for snapshot uploads to be processed. The handle can be cloned and /// shared across multiple buffer uploaders. - /// - /// # Arguments - /// * `state_store_path` - Path to the state store directory containing snapshot files - /// * `store` - Key-value store for persisting upload index - /// * `retention_registry` - Registry for managing snapshot retention to prevent cleanup - /// * `state_store` - Optional state store for triggering on-demand snapshots - /// * `snapshot_creation_interval_ms` - Minimum interval between snapshot creations (ms) - /// * `time_provider` - Time provider for getting current time - /// * `artifact_client` - Client for uploading snapshot artifacts - /// * `stats_scope` - Stats scope for metrics pub async fn new( state_store_path: Option, store: Arc, @@ -172,11 +155,15 @@ impl StateUploadHandle { handle.update_retention_micros(uploaded_through); } - let (upload_tx, upload_rx) = mpsc::channel(UPLOAD_CHANNEL_CAPACITY); + let (wake_tx, wake_rx) = mpsc::channel(UPLOAD_CHANNEL_CAPACITY); + let pending_accumulator = Arc::new(parking_lot::Mutex::new(PendingAccumulator::default())); let state_uploaded_through_micros = Arc::new(AtomicU64::new(uploaded_through)); - let handle = Self { upload_tx }; + let handle = Self { + wake_tx, + pending_accumulator: pending_accumulator.clone(), + }; let worker = StateUploadWorker { state_uploaded_through_micros, @@ -188,7 +175,9 @@ impl StateUploadHandle { state_store, time_provider, artifact_client, - upload_rx, + wake_rx, + pending_accumulator, + pending_version_seen: 0, pending_range: None, stats, }; @@ -198,16 +187,34 @@ impl StateUploadHandle { /// Notifies the background worker that a state snapshot upload may be needed for a log batch. /// - /// This is non-blocking: the request is queued in a bounded channel and the worker processes it - /// asynchronously. If the channel is full, the request is silently dropped — the worker will - /// still cover the needed state range via already-queued requests. + /// This is non-blocking. The range is first merged into a shared accumulator, then the worker is + /// nudged via a best-effort wake channel. pub fn notify_upload_needed(&self, batch_oldest_micros: u64, batch_newest_micros: u64) { - let result = self.upload_tx.try_send(StateUploadRequest { - batch_oldest_micros, - batch_newest_micros, - }); - if let Err(e) = result { - log::warn!("dropping state upload request due to full channel: {e}"); + let should_wake = { + let mut pending = self.pending_accumulator.lock(); + let incoming = PendingRange { + oldest_micros: batch_oldest_micros, + newest_micros: batch_newest_micros, + }; + if let Some(existing) = &mut pending.range { + existing.merge(incoming); + } else { + pending.range = Some(incoming); + } + pending.version = pending.version.wrapping_add(1); + if pending.wake_queued { + false + } else { + pending.wake_queued = true; + true + } + }; + + if should_wake { + // If this fails there is already a pending wake in the channel so we don't have to worry + // about nudging the worker later - it will process the updated pending range when it wakes + // up. + let _ = self.wake_tx.try_send(()); } } } @@ -227,32 +234,22 @@ impl StateUploadHandle { pub struct StateUploadWorker { /// Shared with the handle — updated after successful uploads. state_uploaded_through_micros: Arc, - /// Timestamp of the last snapshot creation (microseconds since epoch). last_snapshot_creation_micros: AtomicU64, - /// Minimum interval between snapshot creations (microseconds). snapshot_creation_interval_micros: u64, - /// Path to the state store directory (for finding snapshot files). state_store_path: Option, - - /// Key-value store for persisting upload index across restarts. store: Arc, - - /// Retention handle for preventing snapshot cleanup. retention_handle: Option, - - /// State store for triggering on-demand snapshot creation before uploads. state_store: Option>, - time_provider: Arc, - - /// Artifact client for uploading snapshots. artifact_client: Arc, - /// Receiver for upload requests from handle instances. - upload_rx: mpsc::Receiver, + /// Used to coordinate updates to the pending range and best-effort wake signals from the handle. + wake_rx: mpsc::Receiver<()>, + pending_accumulator: Arc>, + pending_version_seen: u64, pending_range: Option, stats: Stats, @@ -286,12 +283,16 @@ impl StateUploadWorker { log::debug!("state upload worker started"); loop { tokio::select! { - Some(request) = self.upload_rx.recv() => { - self.ingest_request(request); - self.drain_pending_requests(); + Some(()) = self.wake_rx.recv() => { + self.drain_pending_accumulator(); self.process_pending().await; + while self.pending_version_changed() { + self.drain_pending_accumulator(); + self.process_pending().await; + } } () = sleep(BACKPRESSURE_RETRY_INTERVAL), if self.pending_range.is_some() => { + self.drain_pending_accumulator(); self.process_pending().await; } else => break, @@ -299,49 +300,47 @@ impl StateUploadWorker { } } - fn ingest_request(&mut self, request: StateUploadRequest) { - let incoming = PendingRange::from_request(&request); - if let Some(existing) = &mut self.pending_range { - existing.merge(incoming); - } else { - self.pending_range = Some(incoming); + fn drain_pending_accumulator(&mut self) { + let mut pending = self.pending_accumulator.lock(); + if let Some(incoming) = pending.range.take() { + if let Some(existing) = &mut self.pending_range { + existing.merge(incoming); + } else { + self.pending_range = Some(incoming); + } } + self.pending_version_seen = pending.version; + pending.wake_queued = false; } - fn drain_pending_requests(&mut self) { - while let Ok(request) = self.upload_rx.try_recv() { - self.ingest_request(request); - } + fn pending_version_changed(&self) -> bool { + let pending = self.pending_accumulator.lock(); + pending.version != self.pending_version_seen } async fn process_pending(&mut self) { let Some(pending) = self.pending_range else { return; }; - match self + let result = self .process_upload(pending.oldest_micros, pending.newest_micros) - .await - { - ProcessResult::Backpressure | ProcessResult::DeferredCooldown => { - self.stats.backpressure_pauses.inc(); - }, - _ => { - // A "Skipped" result can still mean we're done with this pending range: - // process_upload first recomputes current coverage from persisted state/upload watermark. - // If another earlier upload already advanced coverage past `pending.oldest_micros`, there - // is nothing left to do for this range and we clear it here. - if self.pending_satisfied(pending.oldest_micros) { - self.pending_range = None; - } - }, + .await; + if matches!( + result, + ProcessResult::Backpressure | ProcessResult::DeferredCooldown + ) { + self.stats.backpressure_pauses.inc(); } - } - /// Pending work is satisfied once uploaded coverage reaches the oldest timestamp that required - /// state; newer pending windows are merged before this check. - fn pending_satisfied(&self, pending_oldest_micros: u64) -> bool { let uploaded_through = self.state_uploaded_through_micros.load(Ordering::Relaxed); - uploaded_through >= pending_oldest_micros + if uploaded_through >= pending.newest_micros { + self.pending_range = None; + } else if uploaded_through >= pending.oldest_micros { + self.pending_range = Some(PendingRange { + oldest_micros: uploaded_through.saturating_add(1), + newest_micros: pending.newest_micros, + }); + } } // State upload flow: @@ -354,6 +353,8 @@ impl StateUploadWorker { // 3) For each ready snapshot, enqueue and wait for persistence ack. // 4) Advance `state_uploaded_through_micros` only after a successful persistence ack via // `on_state_uploaded`, so deferred/failed attempts never move the watermark. + // 5) `process_pending` narrows `pending_range.oldest_micros` from the persisted watermark after + // each attempt and only clears pending once coverage reaches `pending.newest_micros`. async fn process_upload( &self, batch_oldest_micros: u64, diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index 90cfff13a..f387d8a0f 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -11,7 +11,6 @@ use super::*; use bd_runtime::runtime::{ConfigLoader, FeatureFlag as _}; use bd_test_helpers::session::in_memory_store; use bd_time::{SystemTimeProvider, TestTimeProvider}; -use std::sync::atomic::AtomicUsize; use time::OffsetDateTime; use uuid::Uuid; @@ -19,9 +18,12 @@ use uuid::Uuid; /// Inserts a dummy entry so that `rotate_journal` produces a non-empty snapshot file. async fn make_state_store( dir: &std::path::Path, -) -> (Arc, Arc) { +) -> ( + Arc, + Arc, + Arc, +) { let runtime_loader = ConfigLoader::new(dir); - // Enable snapshotting — the default (0) disables it. runtime_loader .update_snapshot(bd_proto::protos::client::api::RuntimeUpdate { version_nonce: "test".to_string(), @@ -42,10 +44,13 @@ async fn make_state_store( .await .unwrap(); let stats = bd_client_stats_store::Collector::default().scope("test"); + let time_provider = Arc::new(TestTimeProvider::new( + OffsetDateTime::from_unix_timestamp(1).unwrap(), + )); let result = bd_state::Store::persistent( dir, bd_state::PersistentStoreConfig::default(), - Arc::new(SystemTimeProvider {}), + time_provider.clone(), &runtime_loader, &stats, ) @@ -61,297 +66,159 @@ async fn make_state_store( ) .await .unwrap(); - (Arc::new(result.store), result.retention_registry) -} - -#[tokio::test] -async fn no_state_changes() { - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - - // Verify construction succeeds and notify_upload_needed is non-blocking. - let (handle, _worker) = StateUploadHandle::new( - None, - store, - None, - None, - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(bd_artifact_upload::MockClient::new()), - &stats, + ( + Arc::new(result.store), + result.retention_registry, + time_provider, ) - .await; - - // With no state store, there are no state changes — channel send should succeed without blocking. - handle.notify_upload_needed(0, 1_000_000); } -#[tokio::test] -async fn uploaded_coverage_prevents_reupload() { - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - - let (_handle, worker) = StateUploadHandle::new( - None, - store, - None, - None, - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(bd_artifact_upload::MockClient::new()), - &stats, - ) - .await; - - worker.on_state_uploaded(1_704_067_300_000_000); - - // After uploading through a timestamp, find_snapshots_in_range should return nothing for - // ranges already covered. - let snapshots = worker.find_snapshots_in_range(1_704_067_200_000_000, 1_704_067_300_000_000); - assert!(snapshots.is_empty(), "no snapshots in covered range"); +async fn insert_state_change(state_store: &bd_state::Store, key: &str) { + state_store + .insert( + bd_state::Scope::GlobalState, + key.to_string(), + bd_state::string_value("test_value"), + ) + .await + .unwrap(); } -#[tokio::test] -async fn cooldown_prevents_rapid_snapshot_creation() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - - let (state_store, retention_registry) = make_state_store(&state_dir).await; - - let (_handle, worker) = StateUploadHandle::new( - Some(state_dir), - store, - Some(retention_registry), - Some(state_store), - 1000, - Arc::new(SystemTimeProvider {}), - Arc::new(bd_artifact_upload::MockClient::new()), - &stats, - ) - .await; - - let batch_ts = 0; - - let snapshot1 = worker.create_snapshot_if_needed(batch_ts).await; - assert!(snapshot1.is_some(), "first snapshot should be created"); - - // Count snapshot files created. - let count_files = || std::fs::read_dir(&snapshots_dir).unwrap().count(); - let file_count_after_first = count_files(); - assert!(file_count_after_first >= 1, "snapshot file should exist"); - - let snapshot2 = worker.create_snapshot_if_needed(batch_ts).await; - assert!( - snapshot2.is_none(), - "second call should defer due to cooldown" - ); - assert_eq!( - count_files(), - file_count_after_first, - "should not create new snapshot due to cooldown" - ); +struct Setup { + _temp_dir: tempfile::TempDir, + state_dir: std::path::PathBuf, + snapshots_dir: std::path::PathBuf, + state_store: Arc, + retention_registry: Arc, + time_provider: Arc, } -#[tokio::test] -async fn cooldown_allows_snapshot_after_interval() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - - let (state_store, retention_registry) = make_state_store(&state_dir).await; - let time_provider = Arc::new(TestTimeProvider::new(OffsetDateTime::now_utc())); - - let (_handle, worker) = StateUploadHandle::new( - Some(state_dir), - store, - Some(retention_registry), - Some(state_store.clone()), - 1, - time_provider.clone(), - Arc::new(bd_artifact_upload::MockClient::new()), - &stats, - ) - .await; - - let batch_ts = time_provider.now().unix_timestamp_micros().cast_unsigned(); - - let snapshot1 = worker.create_snapshot_if_needed(batch_ts).await; - assert!(snapshot1.is_some()); - - let count_files = || std::fs::read_dir(&snapshots_dir).unwrap().count(); - let file_count_after_first = count_files(); - - // Advance time past cooldown. - time_provider.advance(time::Duration::milliseconds(2)); - - // Clear the first snapshot so the second call must create a new one. - for entry in std::fs::read_dir(&snapshots_dir).unwrap() { - let entry = entry.unwrap(); - std::fs::remove_file(entry.path()).unwrap(); +impl Setup { + async fn new() -> Self { + let temp_dir = tempfile::tempdir().unwrap(); + let state_dir = temp_dir.path().join("state"); + let snapshots_dir = state_dir.join("snapshots"); + let (state_store, retention_registry, time_provider) = make_state_store(&state_dir).await; + Self { + _temp_dir: temp_dir, + state_dir, + snapshots_dir, + state_store, + retention_registry, + time_provider, + } } - let future_batch_ts = batch_ts + 10_000_000; - let snapshot2 = worker.create_snapshot_if_needed(future_batch_ts).await; - assert!(snapshot2.is_some()); - assert_eq!( - count_files(), - file_count_after_first, - "should create new snapshot after cooldown expires" - ); -} - -#[tokio::test] -async fn zero_cooldown_allows_immediate_snapshot_creation() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - - let (state_store, retention_registry) = make_state_store(&state_dir).await; + async fn worker_with_client( + &self, + cooldown_micros: u32, + client: Arc, + ) -> StateUploadWorker { + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let (_handle, worker) = StateUploadHandle::new( + Some(self.state_dir.clone()), + store, + Some(self.retention_registry.clone()), + Some(self.state_store.clone()), + cooldown_micros, + self.time_provider.clone(), + client, + &stats, + ) + .await; + worker + } - let (_handle, worker) = StateUploadHandle::new( - Some(state_dir), - store, - Some(retention_registry), - Some(state_store.clone()), - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(bd_artifact_upload::MockClient::new()), - &stats, - ) - .await; + fn now_micros(&self) -> u64 { + self + .time_provider + .now() + .unix_timestamp_micros() + .cast_unsigned() + } - let base_ts = OffsetDateTime::now_utc() - .unix_timestamp_micros() - .cast_unsigned(); + async fn create_snapshot_after_state_change(&self, key: &str) -> u64 { + self.time_provider.advance(time::Duration::seconds(1)); + insert_state_change(&self.state_store, key).await; + self.create_rotated_snapshot().await + } - let count_files = || std::fs::read_dir(&snapshots_dir).unwrap().count(); - let mut total_snapshots = 0; + async fn create_rotated_snapshot(&self) -> u64 { + let path = self.state_store.rotate_journal().await.unwrap(); + let filename = path.file_name().unwrap().to_str().unwrap(); + bd_resilient_kv::SnapshotFilename::parse(filename) + .unwrap() + .timestamp_micros + } - for i in 0 .. 3 { - // Clear snapshots so each iteration forces a new creation. - if let Ok(entries) = std::fs::read_dir(&snapshots_dir) { - for entry in entries { - let entry = entry.unwrap(); + fn clear_snapshot_files(&self) { + if let Ok(entries) = std::fs::read_dir(&self.snapshots_dir) { + for entry in entries.flatten() { std::fs::remove_file(entry.path()).unwrap(); } } - - let snapshot = worker - .create_snapshot_if_needed(base_ts + i * 10_000_000) - .await; - assert!(snapshot.is_some()); - total_snapshots += count_files(); } - - assert!( - total_snapshots >= 3, - "all snapshots should be created with zero cooldown" - ); } #[tokio::test] -async fn uses_existing_snapshot_from_normal_rotation() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - std::fs::create_dir_all(&snapshots_dir).unwrap(); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - - let existing_timestamp = 1_700_000_000_000_000u64; - let existing_snapshot = snapshots_dir.join(format!("state.jrn.g0.t{existing_timestamp}.zz")); - std::fs::write(&existing_snapshot, b"pre-existing snapshot from rotation").unwrap(); +async fn uploaded_coverage_prevents_reupload() { + let setup = Setup::new().await; + let snapshot_ts = setup.create_rotated_snapshot().await; - let (_handle, worker) = StateUploadHandle::new( - Some(state_dir), - store, - None, - None, - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(bd_artifact_upload::MockClient::new()), - &stats, - ) - .await; + let mut mock_client = bd_artifact_upload::MockClient::new(); + mock_client.expect_enqueue_upload().times(0); + let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; - // find_snapshots_in_range should find the snapshot when it falls in range. - let snapshots = worker.find_snapshots_in_range(0, existing_timestamp); - assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].timestamp_micros, existing_timestamp); + worker.on_state_uploaded(snapshot_ts); + worker.ingest_request(StateUploadRequest { + batch_oldest_micros: 1, + batch_newest_micros: snapshot_ts, + }); + worker.process_pending().await; + assert!(worker.pending_range.is_none()); } #[tokio::test] -async fn creates_on_demand_snapshot_when_none_exists() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); +async fn cooldown_allows_snapshot_after_interval() { + let setup = Setup::new().await; + let worker = setup + .worker_with_client(1, Arc::new(bd_artifact_upload::MockClient::new())) + .await; - let (state_store, retention_registry) = make_state_store(&state_dir).await; + let batch_ts = setup.now_micros(); - let (_handle, worker) = StateUploadHandle::new( - Some(state_dir), - store, - Some(retention_registry), - Some(state_store), - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(bd_artifact_upload::MockClient::new()), - &stats, - ) - .await; + let snapshot1 = worker.create_snapshot_if_needed(batch_ts).await; + assert!(snapshot1.is_some()); + + let file_count_after_first = count_snapshot_files(&setup.snapshots_dir); - let batch_ts = OffsetDateTime::now_utc() - .unix_timestamp_micros() - .cast_unsigned(); + // Advance time past cooldown. + setup.time_provider.advance(time::Duration::milliseconds(2)); - let snapshot = worker.create_snapshot_if_needed(batch_ts).await; + insert_state_change(&setup.state_store, "test_key_2").await; - assert!(snapshot.is_some()); + let future_batch_ts = batch_ts + 100; + let snapshot2 = worker.create_snapshot_if_needed(future_batch_ts).await; + assert!(snapshot2.is_some()); assert_eq!( - std::fs::read_dir(&snapshots_dir).unwrap().count(), - 1, - "should create new snapshot on-demand when none exists" + count_snapshot_files(&setup.snapshots_dir), + file_count_after_first + 1, + "should create new snapshot after cooldown expires" ); } #[tokio::test] -async fn prefers_existing_snapshot_over_on_demand_creation() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - std::fs::create_dir_all(&snapshots_dir).unwrap(); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - - let old_snapshot_ts = 1_700_000_000_000_000u64; - let old_snapshot = snapshots_dir.join(format!("state.jrn.g0.t{old_snapshot_ts}.zz")); - std::fs::write(&old_snapshot, b"old snapshot").unwrap(); - - let newer_snapshot_ts = 1_700_001_000_000_000u64; - let newer_snapshot = snapshots_dir.join(format!("state.jrn.g1.t{newer_snapshot_ts}.zz")); - std::fs::write(&newer_snapshot, b"newer snapshot").unwrap(); - - let (_handle, worker) = StateUploadHandle::new( - Some(state_dir), - store, - None, - None, - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(bd_artifact_upload::MockClient::new()), - &stats, - ) - .await; - +async fn find_snapshots_in_range_returns_ordered_snapshots() { + let setup = Setup::new().await; + let worker = setup + .worker_with_client(0, Arc::new(bd_artifact_upload::MockClient::new())) + .await; + + let old_snapshot_ts = setup.create_rotated_snapshot().await; + setup.time_provider.advance(time::Duration::milliseconds(1)); + insert_state_change(&setup.state_store, "test_key_2").await; + let newer_snapshot_ts = setup.create_rotated_snapshot().await; // find_snapshots_in_range returns both snapshots in the range, sorted oldest first. let snapshots = worker.find_snapshots_in_range(0, newer_snapshot_ts + 1_000_000); assert_eq!(snapshots.len(), 2); @@ -377,38 +244,48 @@ fn pending_range_merge_widens_bounds() { } #[tokio::test] -async fn cooldown_defer_does_not_advance_watermark() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); +async fn notify_upload_needed_keeps_range_when_wake_channel_is_full() { let store = in_memory_store(); let stats = bd_client_stats_store::Collector::default().scope("test"); - - let (state_store, retention_registry) = make_state_store(&state_dir).await; - let (_handle, mut worker) = StateUploadHandle::new( - Some(state_dir), + let (handle, _worker) = StateUploadHandle::new( + None, store, - Some(retention_registry), - Some(state_store), - 1000, + None, + None, + 0, Arc::new(SystemTimeProvider {}), Arc::new(bd_artifact_upload::MockClient::new()), &stats, ) .await; - // Set recent snapshot creation time to force a cooldown defer path. - let _created = worker.create_snapshot_if_needed(0).await.unwrap(); - for entry in std::fs::read_dir(&snapshots_dir).unwrap() { - let entry = entry.unwrap(); - std::fs::remove_file(entry.path()).unwrap(); + for _ in 0 .. UPLOAD_CHANNEL_CAPACITY { + handle.wake_tx.try_send(()).unwrap(); } + handle.notify_upload_needed(100, 200); + let pending = handle.pending_accumulator.lock().unwrap(); + let range = pending.range.unwrap(); + assert_eq!(range.oldest_micros, 100); + assert_eq!(range.newest_micros, 200); +} + +#[tokio::test] +async fn cooldown_defer_does_not_advance_watermark() { + let setup = Setup::new().await; + let mut worker = setup + .worker_with_client(1000, Arc::new(bd_artifact_upload::MockClient::new())) + .await; + + // Set recent snapshot creation time to force a cooldown defer path. + let _created = worker.create_snapshot_if_needed(100).await.unwrap(); + setup.clear_snapshot_files(); + setup.time_provider.advance(time::Duration::milliseconds(1)); + insert_state_change(&setup.state_store, "test_key_2").await; + worker.ingest_request(StateUploadRequest { batch_oldest_micros: 1, - batch_newest_micros: OffsetDateTime::now_utc() - .unix_timestamp_micros() - .cast_unsigned(), + batch_newest_micros: 2_000_000, }); worker.process_pending().await; @@ -423,40 +300,22 @@ async fn cooldown_defer_does_not_advance_watermark() { ); } -fn write_snapshot_file(snapshots_dir: &std::path::Path, timestamp_micros: u64) { - std::fs::create_dir_all(snapshots_dir).unwrap(); - let snapshot = snapshots_dir.join(format!("state.jrn.g0.t{timestamp_micros}.zz")); - std::fs::write(snapshot, b"snapshot").unwrap(); +fn count_snapshot_files(snapshots_dir: &std::path::Path) -> usize { + std::fs::read_dir(snapshots_dir).unwrap().count() } + #[tokio::test] async fn enqueue_backpressure_keeps_pending_range() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - let (state_store, retention_registry) = make_state_store(&state_dir).await; - let snapshot_ts = 2_000_000_000_000_000; - write_snapshot_file(&snapshots_dir, snapshot_ts); + let setup = Setup::new().await; + let snapshot_ts = setup.create_rotated_snapshot().await; let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client .expect_enqueue_upload() .times(1) .returning(|_, _, _, _, _, _, _| Err(bd_artifact_upload::EnqueueError::QueueFull)); - - let (_handle, mut worker) = StateUploadHandle::new( - Some(state_dir), - store, - Some(retention_registry), - Some(state_store), - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(mock_client), - &stats, - ) - .await; + let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; worker.ingest_request(StateUploadRequest { batch_oldest_micros: 1, @@ -468,19 +327,17 @@ async fn enqueue_backpressure_keeps_pending_range() { worker.state_uploaded_through_micros.load(Ordering::Relaxed), 0 ); - assert!(worker.pending_range.is_some()); + assert_eq!(worker.pending_range.map(|r| r.oldest_micros), Some(1)); + assert_eq!( + worker.pending_range.map(|r| r.newest_micros), + Some(snapshot_ts) + ); } #[tokio::test] async fn persisted_ack_error_does_not_advance_watermark() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - let (state_store, retention_registry) = make_state_store(&state_dir).await; - let snapshot_ts = 2_000_000_000_000_000; - write_snapshot_file(&snapshots_dir, snapshot_ts); + let setup = Setup::new().await; + let snapshot_ts = setup.create_rotated_snapshot().await; let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client @@ -493,17 +350,7 @@ async fn persisted_ack_error_does_not_advance_watermark() { Ok(Uuid::new_v4()) }); - let (_handle, worker) = StateUploadHandle::new( - Some(state_dir), - store, - Some(retention_registry), - Some(state_store), - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(mock_client), - &stats, - ) - .await; + let worker = setup.worker_with_client(0, Arc::new(mock_client)).await; let result = worker.process_upload(1, snapshot_ts).await; assert_eq!(result, ProcessResult::Error); @@ -515,14 +362,8 @@ async fn persisted_ack_error_does_not_advance_watermark() { #[tokio::test] async fn persisted_ack_channel_drop_does_not_advance_watermark() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - let (state_store, retention_registry) = make_state_store(&state_dir).await; - let snapshot_ts = 2_000_000_000_000_000; - write_snapshot_file(&snapshots_dir, snapshot_ts); + let setup = Setup::new().await; + let snapshot_ts = setup.create_rotated_snapshot().await; let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client @@ -530,17 +371,7 @@ async fn persisted_ack_channel_drop_does_not_advance_watermark() { .times(1) .returning(|_, _, _, _, _, _, _| Ok(Uuid::new_v4())); - let (_handle, worker) = StateUploadHandle::new( - Some(state_dir), - store, - Some(retention_registry), - Some(state_store), - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(mock_client), - &stats, - ) - .await; + let worker = setup.worker_with_client(0, Arc::new(mock_client)).await; let result = worker.process_upload(1, snapshot_ts).await; assert_eq!(result, ProcessResult::Error); @@ -552,14 +383,8 @@ async fn persisted_ack_channel_drop_does_not_advance_watermark() { #[tokio::test] async fn successful_enqueue_ack_advances_watermark_and_clears_pending() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - let (state_store, retention_registry) = make_state_store(&state_dir).await; - let snapshot_ts = 2_000_000_000_000_000; - write_snapshot_file(&snapshots_dir, snapshot_ts); + let setup = Setup::new().await; + let snapshot_ts = setup.create_rotated_snapshot().await; let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client @@ -572,17 +397,7 @@ async fn successful_enqueue_ack_advances_watermark_and_clears_pending() { Ok(Uuid::new_v4()) }); - let (_handle, mut worker) = StateUploadHandle::new( - Some(state_dir), - store, - Some(retention_registry), - Some(state_store), - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(mock_client), - &stats, - ) - .await; + let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; worker.ingest_request(StateUploadRequest { batch_oldest_micros: 1, @@ -597,60 +412,6 @@ async fn successful_enqueue_ack_advances_watermark_and_clears_pending() { assert!(worker.pending_range.is_none()); } -#[tokio::test] -async fn multiple_snapshots_partial_backpressure_keeps_pending_with_partial_progress() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - let (state_store, retention_registry) = make_state_store(&state_dir).await; - let first_snapshot_ts = 1_900_000_000_000_000; - let second_snapshot_ts = 2_000_000_000_000_000; - write_snapshot_file(&snapshots_dir, first_snapshot_ts); - write_snapshot_file(&snapshots_dir, second_snapshot_ts); - - let call_count = AtomicUsize::new(0); - let mut mock_client = bd_artifact_upload::MockClient::new(); - mock_client - .expect_enqueue_upload() - .times(2) - .returning(move |_, _, _, _, _, _, persisted_tx| { - if call_count.fetch_add(1, Ordering::Relaxed) == 0 { - if let Some(tx) = persisted_tx { - let _ = tx.send(Ok(())); - } - Ok(Uuid::new_v4()) - } else { - Err(bd_artifact_upload::EnqueueError::QueueFull) - } - }); - - let (_handle, mut worker) = StateUploadHandle::new( - Some(state_dir), - store, - Some(retention_registry), - Some(state_store), - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(mock_client), - &stats, - ) - .await; - - worker.ingest_request(StateUploadRequest { - batch_oldest_micros: 1, - batch_newest_micros: second_snapshot_ts, - }); - worker.process_pending().await; - - assert_eq!( - worker.state_uploaded_through_micros.load(Ordering::Relaxed), - first_snapshot_ts - ); - assert!(worker.pending_range.is_some()); -} - #[tokio::test] async fn plan_upload_attempt_skips_last_change_zero_already_covered_and_no_new_changes() { let store = in_memory_store(); @@ -678,16 +439,11 @@ async fn plan_upload_attempt_skips_last_change_zero_already_covered_and_no_new_c } #[tokio::test] -async fn plan_upload_attempt_returns_ready_for_in_range_snapshots() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - write_snapshot_file(&snapshots_dir, 100); - write_snapshot_file(&snapshots_dir, 200); +async fn skipped_with_incomplete_coverage_keeps_pending() { let store = in_memory_store(); let stats = bd_client_stats_store::Collector::default().scope("test"); - let (_handle, worker) = StateUploadHandle::new( - Some(state_dir), + let (_handle, mut worker) = StateUploadHandle::new( + None, store, None, None, @@ -698,42 +454,68 @@ async fn plan_upload_attempt_returns_ready_for_in_range_snapshots() { ) .await; - match worker.plan_upload_attempt(1, 200, 0, 200).await { - UploadPreflight::Ready(snapshots) => { - assert_eq!(snapshots.len(), 2); - assert_eq!(snapshots[0].timestamp_micros, 100); - assert_eq!(snapshots[1].timestamp_micros, 200); - }, - _ => panic!("expected ready preflight"), - } + worker.ingest_request(StateUploadRequest { + batch_oldest_micros: 0, + batch_newest_micros: 100, + }); + worker.process_pending().await; + + assert_eq!( + worker.state_uploaded_through_micros.load(Ordering::Relaxed), + 0 + ); + assert!(worker.pending_range.is_some()); } #[tokio::test] -async fn plan_upload_attempt_defers_when_effective_coverage_is_behind_and_on_cooldown() { - let temp_dir = tempfile::tempdir().unwrap(); - let state_dir = temp_dir.path().join("state"); - let snapshots_dir = state_dir.join("snapshots"); - write_snapshot_file(&snapshots_dir, 100); - +async fn skipped_with_partial_coverage_narrows_pending_range() { let store = in_memory_store(); let stats = bd_client_stats_store::Collector::default().scope("test"); - let time_provider = Arc::new(TestTimeProvider::new(OffsetDateTime::now_utc())); - let (_handle, worker) = StateUploadHandle::new( - Some(state_dir), + let (_handle, mut worker) = StateUploadHandle::new( + None, store, None, None, - 1_000, - time_provider.clone(), + 0, + Arc::new(SystemTimeProvider {}), Arc::new(bd_artifact_upload::MockClient::new()), &stats, ) .await; - let now = time_provider.now().unix_timestamp_micros().cast_unsigned(); + worker - .last_snapshot_creation_micros - .store(now, Ordering::Relaxed); + .state_uploaded_through_micros + .store(50, Ordering::Relaxed); + worker.ingest_request(StateUploadRequest { + batch_oldest_micros: 1, + batch_newest_micros: 100, + }); + worker.process_pending().await; - let result = worker.plan_upload_attempt(1, 200, 0, 300).await; - assert!(matches!(result, UploadPreflight::DeferredCooldown)); + assert_eq!(worker.pending_range.map(|r| r.oldest_micros), Some(51)); + assert_eq!(worker.pending_range.map(|r| r.newest_micros), Some(100)); +} + +#[tokio::test] +async fn plan_upload_attempt_returns_ready_for_in_range_snapshots() { + let setup = Setup::new().await; + let _first_snapshot_ts = setup.create_snapshot_after_state_change("test_key_1").await; + let second_snapshot_ts = setup.create_snapshot_after_state_change("test_key_2").await; + let worker = setup + .worker_with_client(0, Arc::new(bd_artifact_upload::MockClient::new())) + .await; + + match worker + .plan_upload_attempt(1, second_snapshot_ts, 0, second_snapshot_ts) + .await + { + UploadPreflight::Ready(snapshots) => { + assert!(!snapshots.is_empty()); + assert_eq!( + snapshots.last().unwrap().timestamp_micros, + second_snapshot_ts + ); + }, + _ => panic!("expected ready preflight"), + } } From f3df1da7ca0dca419d005c76b4e8cf15f14e6db1 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 10:31:06 -0800 Subject: [PATCH 15/32] update agents --- bd-logger/AGENTS.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/bd-logger/AGENTS.md b/bd-logger/AGENTS.md index bb7bcc817..5805ac0c0 100644 --- a/bd-logger/AGENTS.md +++ b/bd-logger/AGENTS.md @@ -19,17 +19,16 @@ uploaded so the server has them available when it processes those logs. State upload coordination is split into two types: -- **`StateUploadHandle`** — a cheap, `Arc`-cloneable sender. Each buffer uploader holds one. When +- **`StateUploadHandle`** — a cheap, `Arc`-cloneable coalescing handle. Each buffer uploader holds one. When a batch is about to be flushed, the uploader calls `handle.notify_upload_needed(batch_oldest_micros, batch_newest_micros)` in a fire-and-forget - manner. The call queues a request into a bounded channel and returns immediately — it never - blocks the log upload path. + manner. The call merges the range into shared pending state protected by a mutex, then + best-effort nudges the worker via a bounded wake channel; it never blocks the log upload path. - **`StateUploadWorker`** — a single background task that owns all snapshot creation and upload - logic. Because exactly one task processes requests, deduplication and cooldown enforcement - require no locks or atomics between callers. The worker drains the channel on each wakeup, - coalescing all queued requests into the widest possible timestamp range before deciding whether - to act. + logic. Because exactly one task processes requests, deduplication and cooldown enforcement are + centralized. On each wakeup (or retry tick), the worker drains/coalesces shared pending state, + then processes the widest pending range before deciding whether to act. The handle and worker are created together via `StateUploadHandle::new`, which also restores previously-persisted upload coverage from `bd-key-value`. @@ -54,7 +53,7 @@ When the worker receives a batch's timestamp range `[oldest, newest]`, it evalua Creating a snapshot on every batch flush during high-volume streaming is wasteful. The worker tracks `last_snapshot_creation_micros` and will not create a new snapshot if one was created within `snapshot_creation_interval_micros` (a runtime-configurable value). During cooldown, the -worker falls back to the most recent existing snapshot instead of creating a new one. +worker defers on-demand creation and keeps pending work for retry. ### Coverage Persistence and Retention @@ -82,12 +81,13 @@ The three flush paths that interact with state uploads are: All three follow the same pattern: read `timestamp_range()`, call `notify_upload_needed` if a range is available, then call `take()` to produce the log batch. -### Channel Backpressure +### Wake Channel Backpressure -The upload request channel has capacity `UPLOAD_CHANNEL_CAPACITY` (8). If the channel is full, -`notify_upload_needed` silently drops the request. This is intentional: the already-queued -requests span a timestamp range that covers the dropped batch's range. The worker coalesces all -pending requests into the widest range before acting, so no coverage is lost. +The wake channel has capacity `UPLOAD_CHANNEL_CAPACITY` (8). If wake signaling is saturated, +`notify_upload_needed` still records the requested range in shared pending state and returns. A +missed wake does not lose coverage; the worker will observe pending state on the next wake/timer +cycle, and version tracking forces immediate reprocessing when producers update pending state while +the worker is active. ### Key Invariants @@ -96,5 +96,5 @@ pending requests into the widest range before acting, so no coverage is lost. - Snapshot uploads are considered confirmed once they are successfully enqueued to the `bd-artifact-upload` queue (which persists them to disk and retries the network upload). If the enqueue fails, the watermark stays put and the next batch will retry. -- All snapshot creation and deduplication logic runs in the single worker task. There is no - concurrent access to the upload state. +- Snapshot creation and upload progress logic run in the single worker task. Producer-side range + coalescing is concurrent but synchronized via a mutex-backed accumulator. From bcbaa44cbba611f46fec98abf95cac8a7f405be1 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 10:34:17 -0800 Subject: [PATCH 16/32] fix tests --- bd-logger/src/state_upload_test.rs | 38 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index f387d8a0f..eb636e6ff 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -171,9 +171,9 @@ async fn uploaded_coverage_prevents_reupload() { let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; worker.on_state_uploaded(snapshot_ts); - worker.ingest_request(StateUploadRequest { - batch_oldest_micros: 1, - batch_newest_micros: snapshot_ts, + worker.pending_range = Some(PendingRange { + oldest_micros: 1, + newest_micros: snapshot_ts, }); worker.process_pending().await; assert!(worker.pending_range.is_none()); @@ -264,7 +264,7 @@ async fn notify_upload_needed_keeps_range_when_wake_channel_is_full() { } handle.notify_upload_needed(100, 200); - let pending = handle.pending_accumulator.lock().unwrap(); + let pending = handle.pending_accumulator.lock(); let range = pending.range.unwrap(); assert_eq!(range.oldest_micros, 100); assert_eq!(range.newest_micros, 200); @@ -283,9 +283,9 @@ async fn cooldown_defer_does_not_advance_watermark() { setup.time_provider.advance(time::Duration::milliseconds(1)); insert_state_change(&setup.state_store, "test_key_2").await; - worker.ingest_request(StateUploadRequest { - batch_oldest_micros: 1, - batch_newest_micros: 2_000_000, + worker.pending_range = Some(PendingRange { + oldest_micros: 1, + newest_micros: 2_000_000, }); worker.process_pending().await; @@ -317,9 +317,9 @@ async fn enqueue_backpressure_keeps_pending_range() { .returning(|_, _, _, _, _, _, _| Err(bd_artifact_upload::EnqueueError::QueueFull)); let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; - worker.ingest_request(StateUploadRequest { - batch_oldest_micros: 1, - batch_newest_micros: snapshot_ts, + worker.pending_range = Some(PendingRange { + oldest_micros: 1, + newest_micros: snapshot_ts, }); worker.process_pending().await; @@ -399,9 +399,9 @@ async fn successful_enqueue_ack_advances_watermark_and_clears_pending() { let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; - worker.ingest_request(StateUploadRequest { - batch_oldest_micros: 1, - batch_newest_micros: snapshot_ts, + worker.pending_range = Some(PendingRange { + oldest_micros: 1, + newest_micros: snapshot_ts, }); worker.process_pending().await; @@ -454,9 +454,9 @@ async fn skipped_with_incomplete_coverage_keeps_pending() { ) .await; - worker.ingest_request(StateUploadRequest { - batch_oldest_micros: 0, - batch_newest_micros: 100, + worker.pending_range = Some(PendingRange { + oldest_micros: 0, + newest_micros: 100, }); worker.process_pending().await; @@ -486,9 +486,9 @@ async fn skipped_with_partial_coverage_narrows_pending_range() { worker .state_uploaded_through_micros .store(50, Ordering::Relaxed); - worker.ingest_request(StateUploadRequest { - batch_oldest_micros: 1, - batch_newest_micros: 100, + worker.pending_range = Some(PendingRange { + oldest_micros: 1, + newest_micros: 100, }); worker.process_pending().await; From 46c7a1d44f6b35194d9b197530f681b982feebb0 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 10:36:29 -0800 Subject: [PATCH 17/32] todo --- bd-artifact-upload/src/uploader.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bd-artifact-upload/src/uploader.rs b/bd-artifact-upload/src/uploader.rs index 38719508c..d6ea02cc8 100644 --- a/bd-artifact-upload/src/uploader.rs +++ b/bd-artifact-upload/src/uploader.rs @@ -639,6 +639,10 @@ impl Uploader { // If we've reached our limit of entries, stop the entry currently being uploaded (the oldest // one) to make space for the newer one. // TODO(snowp): Consider also having a bound on the size of the files persisted to disk. + // TODO(snowp): We should consider redoing how backpressure works for crash reports as well as + // there are cases in which we drop reports. For now limit the backpressure mechanism to + // StateSnapshots as we want stronger guarantees than what is currently provided for regular + // crash reports. if self.index.len() == usize::try_from(*self.max_entries.read()).unwrap_or_default() { if let Some(index_to_drop) = self.index.iter().position(|entry| { entry.type_id.as_deref() != Some(ArtifactType::StateSnapshot.to_type_id()) From 0ee1ba4bee1099e4831a25bb9ff737277a20b8e5 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 12:44:15 -0800 Subject: [PATCH 18/32] persistence etc --- bd-artifact-upload/src/uploader.rs | 169 ++++++++--- bd-artifact-upload/src/uploader_test.rs | 33 +++ bd-client-common/src/file.rs | 10 + bd-client-common/src/file_system.rs | 7 + bd-client-common/src/file_test.rs | 11 + bd-client-common/src/test.rs | 16 + bd-logger/AGENTS.md | 41 ++- bd-logger/src/state_upload.rs | 279 +++++++++--------- bd-logger/src/state_upload_test.rs | 199 +++++++------ .../src/test/state_upload_integration.rs | 31 +- 10 files changed, 485 insertions(+), 311 deletions(-) diff --git a/bd-artifact-upload/src/uploader.rs b/bd-artifact-upload/src/uploader.rs index d6ea02cc8..e4e2fe4e5 100644 --- a/bd-artifact-upload/src/uploader.rs +++ b/bd-artifact-upload/src/uploader.rs @@ -16,6 +16,7 @@ use bd_bounded_buffer::SendCounters; use bd_client_common::error::InvariantError; use bd_client_common::file::{ async_write_checksummed_data, + is_zlib_data, read_checksummed_data, read_compressed_protobuf, write_compressed_protobuf, @@ -112,7 +113,7 @@ impl SnappedFeatureFlag { #[derive(Debug)] struct NewUpload { uuid: Uuid, - file: std::fs::File, + source: UploadSource, type_id: String, state: LogFields, timestamp: Option, @@ -121,13 +122,19 @@ struct NewUpload { persisted_tx: Option>>, } +#[derive(Debug)] +enum UploadSource { + File(std::fs::File), + Path(PathBuf), +} + // Used for bounded_buffer logs impl std::fmt::Display for NewUpload { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "NewUpload {{ uuid: {}, file: {:?} }}", - self.uuid, self.file + "NewUpload {{ uuid: {}, source: {:?} }}", + self.uuid, self.source ) } } @@ -200,6 +207,17 @@ pub trait Client: Send + Sync { feature_flags: Vec, persisted_tx: Option>>, ) -> std::result::Result; + + fn enqueue_upload_from_path( + &self, + source_path: PathBuf, + type_id: String, + state: LogFields, + timestamp: Option, + session_id: String, + feature_flags: Vec, + persisted_tx: Option>>, + ) -> std::result::Result; } pub struct UploadClient { @@ -225,7 +243,42 @@ impl Client for UploadClient { .upload_tx .try_send(NewUpload { uuid, - file, + source: UploadSource::File(file), + type_id, + state, + timestamp, + session_id, + feature_flags, + persisted_tx, + }) + .inspect_err(|e| log::warn!("failed to enqueue artifact upload: {e:?}")); + + self.counter_stats.record(&result); + result.map_err(|e| match e { + bd_bounded_buffer::TrySendError::FullSizeOverflow => EnqueueError::QueueFull, + bd_bounded_buffer::TrySendError::Closed => EnqueueError::Closed, + })?; + + Ok(uuid) + } + + fn enqueue_upload_from_path( + &self, + source_path: PathBuf, + type_id: String, + state: LogFields, + timestamp: Option, + session_id: String, + feature_flags: Vec, + persisted_tx: Option>>, + ) -> std::result::Result { + let uuid = uuid::Uuid::new_v4(); + + let result = self + .upload_tx + .try_send(NewUpload { + uuid, + source: UploadSource::Path(source_path), type_id, state, timestamp, @@ -393,17 +446,22 @@ impl Uploader { return Ok(()); }; - let Ok(contents) = read_checksummed_data(&contents) else { - log::debug!( - "failed to validate CRC checksum for artifact {}, deleting and removing from index", - next.name - ); - - self.file_system.delete_file(&file_path).await?; - self.index.pop_front(); - self.write_index().await; - - return Ok(()); + let contents = if is_zlib_data(&contents) { + contents + } else { + let Ok(contents) = read_checksummed_data(&contents) else { + log::debug!( + "failed to validate CRC checksum for artifact {}, deleting and removing from index", + next.name + ); + + self.file_system.delete_file(&file_path).await?; + self.index.pop_front(); + self.write_index().await; + + return Ok(()); + }; + contents }; log::debug!("starting file upload for {:?}", next.name); self.upload_task_handle = Some(tokio::spawn(Self::upload_artifact( @@ -436,7 +494,7 @@ impl Uploader { } Some(NewUpload { uuid, - file, + source, type_id, state, timestamp, @@ -448,7 +506,7 @@ impl Uploader { self .track_new_upload( uuid, - file, + source, type_id, state, session_id, @@ -628,7 +686,7 @@ impl Uploader { async fn track_new_upload( &mut self, uuid: Uuid, - file: std::fs::File, + source: UploadSource, type_id: String, state: LogFields, session_id: String, @@ -670,30 +728,67 @@ impl Uploader { let uuid = uuid.to_string(); - let target_file = match self - .file_system - .create_file(&REPORT_DIRECTORY.join(&uuid)) - .await - { - Ok(file) => file, - Err(e) => { - log::warn!("failed to create file for artifact: {uuid} on disk: {e}"); - if let Some(tx) = persisted_tx.take() { - let _ = tx.send(Err(EnqueueError::Other(anyhow::anyhow!( - "failed to create file for artifact {uuid}: {e}" - )))); - } + let target_path = REPORT_DIRECTORY.join(&uuid); + let write_result = match source { + UploadSource::File(file) => { + let target_file = match self.file_system.create_file(&target_path).await { + Ok(file) => file, + Err(e) => { + log::warn!("failed to create file for artifact: {uuid} on disk: {e}"); + if let Some(tx) = persisted_tx.take() { + let _ = tx.send(Err(EnqueueError::Other(anyhow::anyhow!( + "failed to create file for artifact {uuid}: {e}" + )))); + } + + #[cfg(test)] + if let Some(hooks) = &self.test_hooks { + hooks.entry_received_tx.send(uuid.clone()).await.unwrap(); + } + return; + }, + }; - #[cfg(test)] - if let Some(hooks) = &self.test_hooks { - hooks.entry_received_tx.send(uuid.clone()).await.unwrap(); + async_write_checksummed_data(tokio::fs::File::from_std(file), target_file).await + }, + UploadSource::Path(source_path) => { + if let Err(e) = self + .file_system + .rename_file(&source_path, &target_path) + .await + { + log::debug!("failed to move artifact source, falling back to copy: {e}"); + match std::fs::File::open(&source_path) { + Ok(source_file) => match self.file_system.create_file(&target_path).await { + Ok(target_file) => { + let result = + async_write_checksummed_data(tokio::fs::File::from_std(source_file), target_file) + .await; + if result.is_ok() + && let Err(e) = self.file_system.delete_file(&source_path).await + { + log::debug!( + "failed to delete moved source file {}: {e}", + source_path.display() + ); + } + result + }, + Err(e) => Err(e), + }, + Err(e) => Err(anyhow::anyhow!( + "failed to open file for artifact {} on disk: {}", + source_path.display(), + e + )), + } + } else { + Ok(()) } - return; }, }; - if let Err(e) = async_write_checksummed_data(tokio::fs::File::from_std(file), target_file).await - { + if let Err(e) = write_result { log::warn!("failed to write artifact to disk: {uuid} to disk: {e}"); if let Some(tx) = persisted_tx.take() { let _ = tx.send(Err(EnqueueError::Other(anyhow::anyhow!( diff --git a/bd-artifact-upload/src/uploader_test.rs b/bd-artifact-upload/src/uploader_test.rs index 87eed4be5..e2383817f 100644 --- a/bd-artifact-upload/src/uploader_test.rs +++ b/bd-artifact-upload/src/uploader_test.rs @@ -932,6 +932,39 @@ async fn enqueue_upload_acknowledges_after_disk_persist() { persisted_rx.await.unwrap().unwrap(); } +#[tokio::test] +async fn enqueue_upload_from_path_acknowledges_after_disk_persist_and_removes_source() { + let mut setup = Setup::new(2).await; + + let source_path = std::path::PathBuf::from("source_snapshot.zz"); + setup + .filesystem + .write_file(&source_path, b"snapshot") + .await + .unwrap(); + + let (persisted_tx, persisted_rx) = tokio::sync::oneshot::channel(); + let id = setup + .client + .enqueue_upload_from_path( + source_path.clone(), + "state_snapshot".to_string(), + [].into(), + None, + "session_id".to_string(), + vec![], + Some(persisted_tx), + ) + .unwrap(); + + assert_eq!( + setup.entry_received_rx.recv().await.unwrap(), + id.to_string() + ); + persisted_rx.await.unwrap().unwrap(); + assert!(!setup.filesystem.exists(&source_path).await.unwrap()); +} + #[tokio::test] async fn queue_full_with_only_state_snapshots_rejects_new_state_snapshot() { let mut setup = Setup::new(1).await; diff --git a/bd-client-common/src/file.rs b/bd-client-common/src/file.rs index 3884beea9..33c944a45 100644 --- a/bd-client-common/src/file.rs +++ b/bd-client-common/src/file.rs @@ -60,6 +60,16 @@ pub fn read_compressed_protobuf( Ok(T::parse_from_tokio_bytes(&decompressed_bytes.into())?) } +#[must_use] +pub fn is_zlib_data(bytes: &[u8]) -> bool { + if bytes.len() < 2 || bytes[0] != 0x78 { + return false; + } + + let cmf_flg = u16::from(bytes[0]) << 8 | u16::from(bytes[1]); + cmf_flg % 31 == 0 +} + /// Writes the data and appends a CRC checksum at the end of the slice. The checksum is a 4-byte /// little-endian CRC32 checksum of the data. #[must_use] diff --git a/bd-client-common/src/file_system.rs b/bd-client-common/src/file_system.rs index 5f94ab880..bac094e07 100644 --- a/bd-client-common/src/file_system.rs +++ b/bd-client-common/src/file_system.rs @@ -29,6 +29,9 @@ pub trait FileSystem: Send + Sync { /// Deletes the file if it exists. async fn delete_file(&self, path: &Path) -> anyhow::Result<()>; + /// Renames/moves a file relative to the SDK root. + async fn rename_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + /// Deletes the directory if it exists. async fn remove_dir(&self, path: &Path) -> anyhow::Result<()>; @@ -98,6 +101,10 @@ impl FileSystem for RealFileSystem { } } + async fn rename_file(&self, from: &Path, to: &Path) -> anyhow::Result<()> { + Ok(tokio::fs::rename(self.directory.join(from), self.directory.join(to)).await?) + } + async fn remove_dir(&self, path: &Path) -> anyhow::Result<()> { match tokio::fs::remove_dir_all(self.directory.join(path)).await { Ok(()) => Ok(()), diff --git a/bd-client-common/src/file_test.rs b/bd-client-common/src/file_test.rs index 0db4b3b86..9c3834400 100644 --- a/bd-client-common/src/file_test.rs +++ b/bd-client-common/src/file_test.rs @@ -38,3 +38,14 @@ fn invalid_checksum() { "crc mismatch" ); } + +#[test] +fn identifies_zlib_data() { + let compressed = super::write_compressed(b"hello").unwrap(); + assert!(super::is_zlib_data(&compressed)); +} + +#[test] +fn identifies_non_zlib_data() { + assert!(!super::is_zlib_data(b"not-zlib")); +} diff --git a/bd-client-common/src/test.rs b/bd-client-common/src/test.rs index c961fe95c..5b50854e9 100644 --- a/bd-client-common/src/test.rs +++ b/bd-client-common/src/test.rs @@ -85,6 +85,22 @@ impl FileSystem for TestFileSystem { Ok(()) } + async fn rename_file(&self, from: &Path, to: &Path) -> anyhow::Result<()> { + let from_path = self.directory.path().join(from); + let to_path = self.directory.path().join(to); + + tokio::fs::rename(&from_path, &to_path).await.map_err(|e| { + anyhow::anyhow!( + "failed to rename file {} to {}: {}", + from_path.display(), + to_path.display(), + e + ) + })?; + + Ok(()) + } + async fn remove_dir(&self, path: &Path) -> anyhow::Result<()> { let dir_path = self.directory.path().join(path); if !dir_path.exists() { diff --git a/bd-logger/AGENTS.md b/bd-logger/AGENTS.md index 5805ac0c0..aa92a6a81 100644 --- a/bd-logger/AGENTS.md +++ b/bd-logger/AGENTS.md @@ -30,22 +30,16 @@ State upload coordination is split into two types: centralized. On each wakeup (or retry tick), the worker drains/coalesces shared pending state, then processes the widest pending range before deciding whether to act. -The handle and worker are created together via `StateUploadHandle::new`, which also restores -previously-persisted upload coverage from `bd-key-value`. +The handle and worker are created together via `StateUploadHandle::new`. ### Upload Decision Logic When the worker receives a batch's timestamp range `[oldest, newest]`, it evaluates in order: 1. **No state changes ever recorded** (`last_change_micros == 0`) → skip. Nothing to upload. -2. **Coverage already sufficient** (`uploaded_through >= batch_oldest`) → skip. The server - already has state that covers this batch. -3. **No new changes since last upload** (`last_change <= uploaded_through`) → skip. State hasn't - changed since we last uploaded. -4. **Existing snapshots cover the gap** → upload those snapshot files. Snapshots are found by - scanning `{state_store_path}/snapshots/` for files whose parsed timestamp falls in - `(uploaded_through, batch_newest_micros]`. -5. **No existing snapshots cover the gap** → create one on-demand via +2. **Snapshot files exist** in `{state_store_path}/snapshots/` → upload every snapshot file found + there (oldest-first). File presence is the source of truth. +3. **No snapshot files exist but state changed** (`last_change_micros > 0`) → create one on-demand via `state_store.rotate_journal()`, subject to a cooldown (see below). ### Snapshot Cooldown @@ -55,16 +49,18 @@ tracks `last_snapshot_creation_micros` and will not create a new snapshot if one within `snapshot_creation_interval_micros` (a runtime-configurable value). During cooldown, the worker defers on-demand creation and keeps pending work for retry. -### Coverage Persistence and Retention +### Snapshot Move Semantics -`uploaded_through_micros` — the watermark of what has been confirmed uploaded — is persisted via -`bd-key-value` under the key `state_upload.uploaded_through.1`. It is loaded on startup and used -immediately, so the worker never re-uploads state snapshots that were confirmed in a previous -process run. +State snapshot uploads are enqueued via `enqueue_upload_from_path`: the snapshot file is moved +(renamed) from `state/snapshots/` into `bd-artifact-upload`'s `report_uploads/` directory. This +means: -The watermark is also fed into the `RetentionHandle` from `bd-resilient-kv`, which prevents the -snapshot retention cleanup from deleting any snapshot that is still needed. This prevents a race -where cleanup removes a snapshot before the logger has had a chance to upload it. +- No re-copy/re-checksum pass is required for snapshot files (they are already zlib compressed). +- Once the enqueue ack succeeds, the file has left `state/snapshots/`, so the worker will not + re-upload it. +- If enqueue fails, the file remains in `state/snapshots/`, so the next retry still sees it. + +Upload selection is file-presence based; there is no separate uploaded watermark state. ### BatchBuilder Timestamp Tracking @@ -83,7 +79,7 @@ range is available, then call `take()` to produce the log batch. ### Wake Channel Backpressure -The wake channel has capacity `UPLOAD_CHANNEL_CAPACITY` (8). If wake signaling is saturated, +The wake channel has capacity `UPLOAD_CHANNEL_CAPACITY` (1). If wake signaling is saturated, `notify_upload_needed` still records the requested range in shared pending state and returns. A missed wake does not lose coverage; the worker will observe pending state on the next wake/timer cycle, and version tracking forces immediate reprocessing when producers update pending state while @@ -91,10 +87,9 @@ the worker is active. ### Key Invariants -- `uploaded_through_micros` is monotonically non-decreasing. It is only advanced via - `fetch_max`, never set to a smaller value. - Snapshot uploads are considered confirmed once they are successfully enqueued to the - `bd-artifact-upload` queue (which persists them to disk and retries the network upload). If the - enqueue fails, the watermark stays put and the next batch will retry. + `bd-artifact-upload` queue (which persists them to disk and retries the network upload). If + enqueue fails, the source file is still present in `state/snapshots/` and the next batch will + retry. - Snapshot creation and upload progress logic run in the single worker task. Producer-side range coalescing is concurrent but synchronized via a mutex-backed accumulator. diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index cc3540407..86defffb5 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -38,7 +38,7 @@ use bd_log_primitives::LogFields; use bd_resilient_kv::SnapshotFilename; use bd_state::{RetentionHandle, RetentionRegistry}; use bd_time::{OffsetDateTimeExt, TimeProvider}; -use std::fs::File; +use protobuf::well_known_types::struct_::{Struct as ProtoStruct, Value as ProtoValue, value}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -49,10 +49,8 @@ use tokio::time::{Duration, sleep}; /// Capacity of the worker wake channel used to nudge processing of coalesced pending ranges. const UPLOAD_CHANNEL_CAPACITY: usize = 1; const BACKPRESSURE_RETRY_INTERVAL: Duration = Duration::from_secs(1); - -/// Key for persisting the state upload index via bd-key-value. -static STATE_UPLOAD_KEY: bd_key_value::Key = - bd_key_value::Key::new("state_upload.uploaded_through.1"); +static PENDING_UPLOAD_RANGE_KEY: bd_key_value::Key = + bd_key_value::Key::new("state_upload.pending_range.1"); /// A reference to a state snapshot that should be uploaded. @@ -135,38 +133,19 @@ impl StateUploadHandle { ) -> (Self, StateUploadWorker) { let stats = Stats::new(&stats_scope.scope("state_upload")); + let (wake_tx, wake_rx) = mpsc::channel(UPLOAD_CHANNEL_CAPACITY); + let pending_accumulator = Arc::new(parking_lot::Mutex::new(PendingAccumulator::default())); let retention_handle = match &retention_registry { Some(registry) => Some(registry.create_handle().await), None => None, }; - let uploaded_through = store - .get_string(&STATE_UPLOAD_KEY) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - - if uploaded_through > 0 { - log::debug!("loaded state upload coverage through {uploaded_through}"); - } - - if let Some(handle) = &retention_handle - && uploaded_through > 0 - { - handle.update_retention_micros(uploaded_through); - } - - let (wake_tx, wake_rx) = mpsc::channel(UPLOAD_CHANNEL_CAPACITY); - let pending_accumulator = Arc::new(parking_lot::Mutex::new(PendingAccumulator::default())); - - let state_uploaded_through_micros = Arc::new(AtomicU64::new(uploaded_through)); - let handle = Self { wake_tx, pending_accumulator: pending_accumulator.clone(), }; let worker = StateUploadWorker { - state_uploaded_through_micros, last_snapshot_creation_micros: AtomicU64::new(0), snapshot_creation_interval_micros: u64::from(snapshot_creation_interval_ms) * 1000, state_store_path, @@ -232,8 +211,6 @@ impl StateUploadHandle { /// /// Obtain via [`StateUploadHandle::new`] and spawn with `tokio::spawn` or `try_join!`. pub struct StateUploadWorker { - /// Shared with the handle — updated after successful uploads. - state_uploaded_through_micros: Arc, /// Timestamp of the last snapshot creation (microseconds since epoch). last_snapshot_creation_micros: AtomicU64, /// Minimum interval between snapshot creations (microseconds). @@ -281,6 +258,12 @@ impl StateUploadWorker { /// Runs the worker event loop, processing upload requests until the channel is closed. pub async fn run(mut self) { log::debug!("state upload worker started"); + self.refresh_retention_handle(); + self.pending_range = self.read_persisted_pending_range(); + if self.pending_range.is_some() { + self.process_pending().await; + } + loop { tokio::select! { Some(()) = self.wake_rx.recv() => { @@ -311,6 +294,7 @@ impl StateUploadWorker { } self.pending_version_seen = pending.version; pending.wake_queued = false; + self.persist_pending_range(); } fn pending_version_changed(&self) -> bool { @@ -332,15 +316,11 @@ impl StateUploadWorker { self.stats.backpressure_pauses.inc(); } - let uploaded_through = self.state_uploaded_through_micros.load(Ordering::Relaxed); - if uploaded_through >= pending.newest_micros { + if matches!(result, ProcessResult::Progress | ProcessResult::Skipped) { self.pending_range = None; - } else if uploaded_through >= pending.oldest_micros { - self.pending_range = Some(PendingRange { - oldest_micros: uploaded_through.saturating_add(1), - newest_micros: pending.newest_micros, - }); } + self.persist_pending_range(); + self.refresh_retention_handle(); } // State upload flow: @@ -351,34 +331,26 @@ impl StateUploadWorker { // - `DeferredCooldown`: uncovered changes exist but snapshot creation is rate-limited. // - `Ready`: concrete snapshots should be uploaded now. // 3) For each ready snapshot, enqueue and wait for persistence ack. - // 4) Advance `state_uploaded_through_micros` only after a successful persistence ack via - // `on_state_uploaded`, so deferred/failed attempts never move the watermark. - // 5) `process_pending` narrows `pending_range.oldest_micros` from the persisted watermark after - // each attempt and only clears pending once coverage reaches `pending.newest_micros`. + // 4) On success, count the snapshot upload and continue; on failure, keep pending work for retry. async fn process_upload( &self, batch_oldest_micros: u64, batch_newest_micros: u64, ) -> ProcessResult { - let uploaded_through = self.state_uploaded_through_micros.load(Ordering::Relaxed); let last_change = self .state_store .as_ref() .map_or(0, |s| s.last_change_micros()); let snapshots = match self - .plan_upload_attempt( - batch_oldest_micros, - batch_newest_micros, - uploaded_through, - last_change, - ) + .plan_upload_attempt(batch_oldest_micros, batch_newest_micros, last_change) .await { UploadPreflight::Skipped => return ProcessResult::Skipped, UploadPreflight::DeferredCooldown => return ProcessResult::DeferredCooldown, UploadPreflight::Ready(snapshots) => snapshots, }; + self.refresh_retention_handle(); // Upload each snapshot in order, advancing the watermark after each confirmed upload. for snapshot_ref in snapshots { @@ -389,27 +361,14 @@ impl StateUploadWorker { batch_newest_micros ); - // Open the snapshot file. - let file = match File::open(&snapshot_ref.path) { - Ok(f) => f, - Err(e) => { - log::warn!( - "failed to open snapshot file {}: {e}", - snapshot_ref.path.display() - ); - self.stats.upload_failures.inc(); - return ProcessResult::Error; - }, - }; - let timestamp = OffsetDateTime::from_unix_timestamp_micros( snapshot_ref.timestamp_micros.try_into().unwrap_or_default(), ) .ok(); let (persisted_tx, persisted_rx) = tokio::sync::oneshot::channel(); - match self.artifact_client.enqueue_upload( - file, + match self.artifact_client.enqueue_upload_from_path( + snapshot_ref.path.clone(), "state_snapshot".to_string(), LogFields::new(), timestamp, @@ -423,7 +382,7 @@ impl StateUploadWorker { "state snapshot persisted to artifact queue for timestamp {}", snapshot_ref.timestamp_micros ); - self.on_state_uploaded(snapshot_ref.timestamp_micros); + self.stats.snapshots_uploaded.inc(); }, Ok(Err(e)) => { log::warn!("failed to persist state snapshot upload entry: {e}"); @@ -456,91 +415,58 @@ impl StateUploadWorker { &self, batch_oldest_micros: u64, batch_newest_micros: u64, - uploaded_through: u64, last_change: u64, ) -> UploadPreflight { if last_change == 0 { - log::debug!( - "state upload: last_change=0, skipping (uploaded_through={uploaded_through}, \ - batch_oldest={batch_oldest_micros})" - ); + log::debug!("state upload: last_change=0, skipping (batch_newest={batch_newest_micros})"); return UploadPreflight::Skipped; } - if uploaded_through >= batch_oldest_micros { - self.stats.snapshots_skipped.inc(); - return UploadPreflight::Skipped; + let snapshots = self.find_snapshots_in_range(batch_oldest_micros, batch_newest_micros); + if !snapshots.is_empty() { + return UploadPreflight::Ready(snapshots); } - if last_change <= uploaded_through { + let now_micros = self + .time_provider + .now() + .unix_timestamp_micros() + .cast_unsigned(); + if self.snapshot_creation_on_cooldown(now_micros) { self.stats.snapshots_skipped.inc(); - return UploadPreflight::Skipped; - } - - let mut snapshots = self.find_snapshots_in_range(uploaded_through, batch_newest_micros); - let effective_coverage = snapshots - .last() - .map_or(uploaded_through, |snapshot| snapshot.timestamp_micros); - - if last_change > effective_coverage { - let now_micros = self - .time_provider - .now() - .unix_timestamp_micros() - .cast_unsigned(); - if self.snapshot_creation_on_cooldown(now_micros) { - self.stats.snapshots_skipped.inc(); - log::debug!( - "deferring snapshot creation due to cooldown (last={}, now={now_micros}, interval={})", - self.last_snapshot_creation_micros.load(Ordering::Relaxed), - self.snapshot_creation_interval_micros - ); - return UploadPreflight::DeferredCooldown; - } - - if let Some(snapshot) = self - .create_snapshot_if_needed(effective_coverage.saturating_add(1)) - .await - && snapshot.timestamp_micros > effective_coverage - { - snapshots.push(snapshot); - } - } - - if snapshots.is_empty() { - UploadPreflight::Skipped - } else { - UploadPreflight::Ready(snapshots) - } - } - - /// Called after a state snapshot has been successfully uploaded. - fn on_state_uploaded(&self, snapshot_timestamp_micros: u64) { - self - .state_uploaded_through_micros - .fetch_max(snapshot_timestamp_micros, Ordering::Relaxed); - self.stats.snapshots_uploaded.inc(); - - // Update retention handle to allow cleanup of snapshots older than what we've uploaded. - if let Some(handle) = &self.retention_handle { - handle.update_retention_micros(snapshot_timestamp_micros); + log::debug!( + "deferring snapshot creation due to cooldown (last={}, now={now_micros}, interval={})", + self.last_snapshot_creation_micros.load(Ordering::Relaxed), + self.snapshot_creation_interval_micros + ); + return UploadPreflight::DeferredCooldown; } - // Persist the updated coverage. self - .store - .set_string(&STATE_UPLOAD_KEY, &snapshot_timestamp_micros.to_string()); + .create_snapshot_if_needed(last_change) + .await + .map_or(UploadPreflight::DeferredCooldown, |snapshot| { + UploadPreflight::Ready(vec![snapshot]) + }) } - /// Finds all snapshot files in the range `(after_micros, up_to_micros]`, sorted oldest first. - /// - /// This ensures we upload every state change that occurred during the batch window, not just the - /// most recent one. - pub(crate) fn find_snapshots_in_range( + fn find_snapshots_in_range( &self, - after_micros: u64, - up_to_micros: u64, + batch_oldest_micros: u64, + batch_newest_micros: u64, ) -> Vec { + self + .find_all_snapshots() + .into_iter() + .filter(|snapshot| { + snapshot.timestamp_micros >= batch_oldest_micros + && snapshot.timestamp_micros <= batch_newest_micros + }) + .collect() + } + + /// Finds all snapshot files, sorted oldest first. + pub(crate) fn find_all_snapshots(&self) -> Vec { let Some(state_path) = self.state_store_path.as_ref() else { return vec![]; }; @@ -556,14 +482,10 @@ impl StateUploadWorker { let path = entry.path(); let filename = path.file_name().and_then(|f| f.to_str())?.to_owned(); let parsed = SnapshotFilename::parse(&filename)?; - if parsed.timestamp_micros > after_micros && parsed.timestamp_micros <= up_to_micros { - Some(SnapshotRef { - timestamp_micros: parsed.timestamp_micros, - path, - }) - } else { - None - } + Some(SnapshotRef { + timestamp_micros: parsed.timestamp_micros, + path, + }) }) .collect(); @@ -581,6 +503,11 @@ impl StateUploadWorker { min_uncovered_micros: u64, ) -> Option { let state_store = self.state_store.as_ref()?; + if let Some(handle) = &self.retention_handle { + // Ensure cleanup during rotation doesn't remove the newly created snapshot before it can be + // enqueued for upload. + handle.update_retention_micros(min_uncovered_micros); + } let now_micros = { let now = self.time_provider.now(); @@ -628,4 +555,78 @@ impl StateUploadWorker { last_creation > 0 && now_micros.saturating_sub(last_creation) < self.snapshot_creation_interval_micros } + + fn persist_pending_range(&self) { + match self.pending_range { + Some(range) => self + .store + .set(&PENDING_UPLOAD_RANGE_KEY, &pending_range_to_proto(range)), + None => self + .store + .set(&PENDING_UPLOAD_RANGE_KEY, &ProtoStruct::default()), + } + } + + fn read_persisted_pending_range(&self) -> Option { + self + .store + .get(&PENDING_UPLOAD_RANGE_KEY) + .and_then(|proto| pending_range_from_proto(&proto)) + } + + fn refresh_retention_handle(&self) { + let Some(handle) = &self.retention_handle else { + return; + }; + let oldest_snapshot = self + .find_all_snapshots() + .into_iter() + .map(|s| s.timestamp_micros) + .min(); + match oldest_snapshot { + Some(oldest) => handle.update_retention_micros(oldest), + None => handle.update_retention_micros(RetentionHandle::RETENTION_NONE), + } + } +} + +fn pending_range_to_proto(range: PendingRange) -> ProtoStruct { + let mut proto = ProtoStruct::new(); + proto.fields.insert( + "oldest_micros".to_string(), + ProtoValue { + kind: Some(value::Kind::StringValue(range.oldest_micros.to_string())), + ..Default::default() + }, + ); + proto.fields.insert( + "newest_micros".to_string(), + ProtoValue { + kind: Some(value::Kind::StringValue(range.newest_micros.to_string())), + ..Default::default() + }, + ); + proto +} + +fn pending_range_from_proto(proto: &ProtoStruct) -> Option { + let oldest = proto + .fields + .get("oldest_micros") + .and_then(proto_string_value_to_u64)?; + let newest = proto + .fields + .get("newest_micros") + .and_then(proto_string_value_to_u64)?; + Some(PendingRange { + oldest_micros: oldest, + newest_micros: newest, + }) +} + +fn proto_string_value_to_u64(value: &ProtoValue) -> Option { + let value::Kind::StringValue(v) = value.kind.as_ref()? else { + return None; + }; + v.parse::().ok() } diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index eb636e6ff..23c9ad36e 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -21,6 +21,7 @@ async fn make_state_store( ) -> ( Arc, Arc, + bd_state::RetentionHandle, Arc, ) { let runtime_loader = ConfigLoader::new(dir); @@ -66,9 +67,12 @@ async fn make_state_store( ) .await .unwrap(); + let retention_handle = result.retention_registry.create_handle().await; + retention_handle.update_retention_micros(0); ( Arc::new(result.store), result.retention_registry, + retention_handle, time_provider, ) } @@ -88,8 +92,10 @@ struct Setup { _temp_dir: tempfile::TempDir, state_dir: std::path::PathBuf, snapshots_dir: std::path::PathBuf, + store: Arc, state_store: Arc, retention_registry: Arc, + _state_retention_handle: bd_state::RetentionHandle, time_provider: Arc, } @@ -98,13 +104,17 @@ impl Setup { let temp_dir = tempfile::tempdir().unwrap(); let state_dir = temp_dir.path().join("state"); let snapshots_dir = state_dir.join("snapshots"); - let (state_store, retention_registry, time_provider) = make_state_store(&state_dir).await; + let store = in_memory_store(); + let (state_store, retention_registry, state_retention_handle, time_provider) = + make_state_store(&state_dir).await; Self { _temp_dir: temp_dir, state_dir, snapshots_dir, + store, state_store, retention_registry, + _state_retention_handle: state_retention_handle, time_provider, } } @@ -114,11 +124,10 @@ impl Setup { cooldown_micros: u32, client: Arc, ) -> StateUploadWorker { - let store = in_memory_store(); let stats = bd_client_stats_store::Collector::default().scope("test"); let (_handle, worker) = StateUploadHandle::new( Some(self.state_dir.clone()), - store, + self.store.clone(), Some(self.retention_registry.clone()), Some(self.state_store.clone()), cooldown_micros, @@ -162,18 +171,26 @@ impl Setup { } #[tokio::test] -async fn uploaded_coverage_prevents_reupload() { - let setup = Setup::new().await; - let snapshot_ts = setup.create_rotated_snapshot().await; - +async fn no_snapshot_files_skips_upload() { + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); let mut mock_client = bd_artifact_upload::MockClient::new(); - mock_client.expect_enqueue_upload().times(0); - let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; + mock_client.expect_enqueue_upload_from_path().times(0); + let (_handle, mut worker) = StateUploadHandle::new( + None, + store, + None, + None, + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(mock_client), + &stats, + ) + .await; - worker.on_state_uploaded(snapshot_ts); worker.pending_range = Some(PendingRange { oldest_micros: 1, - newest_micros: snapshot_ts, + newest_micros: 100, }); worker.process_pending().await; assert!(worker.pending_range.is_none()); @@ -209,7 +226,7 @@ async fn cooldown_allows_snapshot_after_interval() { } #[tokio::test] -async fn find_snapshots_in_range_returns_ordered_snapshots() { +async fn find_all_snapshots_returns_ordered_snapshots() { let setup = Setup::new().await; let worker = setup .worker_with_client(0, Arc::new(bd_artifact_upload::MockClient::new())) @@ -219,8 +236,8 @@ async fn find_snapshots_in_range_returns_ordered_snapshots() { setup.time_provider.advance(time::Duration::milliseconds(1)); insert_state_change(&setup.state_store, "test_key_2").await; let newer_snapshot_ts = setup.create_rotated_snapshot().await; - // find_snapshots_in_range returns both snapshots in the range, sorted oldest first. - let snapshots = worker.find_snapshots_in_range(0, newer_snapshot_ts + 1_000_000); + // find_all_snapshots returns snapshots sorted oldest first. + let snapshots = worker.find_all_snapshots(); assert_eq!(snapshots.len(), 2); assert_eq!( snapshots[0].timestamp_micros, old_snapshot_ts, @@ -271,7 +288,7 @@ async fn notify_upload_needed_keeps_range_when_wake_channel_is_full() { } #[tokio::test] -async fn cooldown_defer_does_not_advance_watermark() { +async fn cooldown_defer_keeps_pending_for_retry() { let setup = Setup::new().await; let mut worker = setup .worker_with_client(1000, Arc::new(bd_artifact_upload::MockClient::new())) @@ -289,11 +306,6 @@ async fn cooldown_defer_does_not_advance_watermark() { }); worker.process_pending().await; - assert_eq!( - worker.state_uploaded_through_micros.load(Ordering::Relaxed), - 0, - "cooldown defer must not advance uploaded watermark" - ); assert!( worker.pending_range.is_some(), "cooldown defer should keep pending range for retry" @@ -301,7 +313,7 @@ async fn cooldown_defer_does_not_advance_watermark() { } fn count_snapshot_files(snapshots_dir: &std::path::Path) -> usize { - std::fs::read_dir(snapshots_dir).unwrap().count() + std::fs::read_dir(snapshots_dir).map_or(0, Iterator::count) } @@ -312,7 +324,7 @@ async fn enqueue_backpressure_keeps_pending_range() { let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client - .expect_enqueue_upload() + .expect_enqueue_upload_from_path() .times(1) .returning(|_, _, _, _, _, _, _| Err(bd_artifact_upload::EnqueueError::QueueFull)); let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; @@ -323,10 +335,6 @@ async fn enqueue_backpressure_keeps_pending_range() { }); worker.process_pending().await; - assert_eq!( - worker.state_uploaded_through_micros.load(Ordering::Relaxed), - 0 - ); assert_eq!(worker.pending_range.map(|r| r.oldest_micros), Some(1)); assert_eq!( worker.pending_range.map(|r| r.newest_micros), @@ -341,7 +349,7 @@ async fn persisted_ack_error_does_not_advance_watermark() { let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client - .expect_enqueue_upload() + .expect_enqueue_upload_from_path() .times(1) .returning(|_, _, _, _, _, _, persisted_tx| { if let Some(tx) = persisted_tx { @@ -354,10 +362,6 @@ async fn persisted_ack_error_does_not_advance_watermark() { let result = worker.process_upload(1, snapshot_ts).await; assert_eq!(result, ProcessResult::Error); - assert_eq!( - worker.state_uploaded_through_micros.load(Ordering::Relaxed), - 0 - ); } #[tokio::test] @@ -367,7 +371,7 @@ async fn persisted_ack_channel_drop_does_not_advance_watermark() { let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client - .expect_enqueue_upload() + .expect_enqueue_upload_from_path() .times(1) .returning(|_, _, _, _, _, _, _| Ok(Uuid::new_v4())); @@ -375,20 +379,16 @@ async fn persisted_ack_channel_drop_does_not_advance_watermark() { let result = worker.process_upload(1, snapshot_ts).await; assert_eq!(result, ProcessResult::Error); - assert_eq!( - worker.state_uploaded_through_micros.load(Ordering::Relaxed), - 0 - ); } #[tokio::test] -async fn successful_enqueue_ack_advances_watermark_and_clears_pending() { +async fn successful_enqueue_ack_clears_pending() { let setup = Setup::new().await; let snapshot_ts = setup.create_rotated_snapshot().await; let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client - .expect_enqueue_upload() + .expect_enqueue_upload_from_path() .times(1) .returning(|_, _, _, _, _, _, persisted_tx| { if let Some(tx) = persisted_tx { @@ -405,10 +405,6 @@ async fn successful_enqueue_ack_advances_watermark_and_clears_pending() { }); worker.process_pending().await; - assert_eq!( - worker.state_uploaded_through_micros.load(Ordering::Relaxed), - snapshot_ts - ); assert!(worker.pending_range.is_none()); } @@ -428,18 +424,18 @@ async fn plan_upload_attempt_skips_last_change_zero_already_covered_and_no_new_c ) .await; - let result = worker.plan_upload_attempt(10, 20, 0, 0).await; + let result = worker.plan_upload_attempt(0, 20, 0).await; assert!(matches!(result, UploadPreflight::Skipped)); - let result = worker.plan_upload_attempt(10, 20, 10, 15).await; - assert!(matches!(result, UploadPreflight::Skipped)); + let result = worker.plan_upload_attempt(0, 20, 15).await; + assert!(matches!(result, UploadPreflight::DeferredCooldown)); - let result = worker.plan_upload_attempt(10, 20, 9, 9).await; - assert!(matches!(result, UploadPreflight::Skipped)); + let result = worker.plan_upload_attempt(0, 20, 9).await; + assert!(matches!(result, UploadPreflight::DeferredCooldown)); } #[tokio::test] -async fn skipped_with_incomplete_coverage_keeps_pending() { +async fn skipped_with_no_state_changes_clears_pending() { let store = in_memory_store(); let stats = bd_client_stats_store::Collector::default().scope("test"); let (_handle, mut worker) = StateUploadHandle::new( @@ -460,40 +456,7 @@ async fn skipped_with_incomplete_coverage_keeps_pending() { }); worker.process_pending().await; - assert_eq!( - worker.state_uploaded_through_micros.load(Ordering::Relaxed), - 0 - ); - assert!(worker.pending_range.is_some()); -} - -#[tokio::test] -async fn skipped_with_partial_coverage_narrows_pending_range() { - let store = in_memory_store(); - let stats = bd_client_stats_store::Collector::default().scope("test"); - let (_handle, mut worker) = StateUploadHandle::new( - None, - store, - None, - None, - 0, - Arc::new(SystemTimeProvider {}), - Arc::new(bd_artifact_upload::MockClient::new()), - &stats, - ) - .await; - - worker - .state_uploaded_through_micros - .store(50, Ordering::Relaxed); - worker.pending_range = Some(PendingRange { - oldest_micros: 1, - newest_micros: 100, - }); - worker.process_pending().await; - - assert_eq!(worker.pending_range.map(|r| r.oldest_micros), Some(51)); - assert_eq!(worker.pending_range.map(|r| r.newest_micros), Some(100)); + assert!(worker.pending_range.is_none()); } #[tokio::test] @@ -506,7 +469,7 @@ async fn plan_upload_attempt_returns_ready_for_in_range_snapshots() { .await; match worker - .plan_upload_attempt(1, second_snapshot_ts, 0, second_snapshot_ts) + .plan_upload_attempt(second_snapshot_ts, second_snapshot_ts, second_snapshot_ts) .await { UploadPreflight::Ready(snapshots) => { @@ -519,3 +482,73 @@ async fn plan_upload_attempt_returns_ready_for_in_range_snapshots() { _ => panic!("expected ready preflight"), } } + +#[tokio::test] +async fn plan_upload_attempt_filters_snapshots_to_pending_range() { + let setup = Setup::new().await; + let first_snapshot_ts = setup.create_snapshot_after_state_change("test_key_1").await; + let second_snapshot_ts = setup.create_snapshot_after_state_change("test_key_2").await; + let worker = setup + .worker_with_client(0, Arc::new(bd_artifact_upload::MockClient::new())) + .await; + + match worker + .plan_upload_attempt(second_snapshot_ts, second_snapshot_ts, second_snapshot_ts) + .await + { + UploadPreflight::Ready(snapshots) => { + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].timestamp_micros, second_snapshot_ts); + assert_ne!(snapshots[0].timestamp_micros, first_snapshot_ts); + }, + _ => panic!("expected ready preflight"), + } +} + +#[tokio::test] +async fn run_processes_persisted_pending_range_on_startup() { + let setup = Setup::new().await; + let snapshot_ts = setup.create_snapshot_after_state_change("startup").await; + setup.store.set( + &PENDING_UPLOAD_RANGE_KEY, + &pending_range_to_proto(PendingRange { + oldest_micros: snapshot_ts, + newest_micros: snapshot_ts, + }), + ); + + let mut mock_client = bd_artifact_upload::MockClient::new(); + mock_client + .expect_enqueue_upload_from_path() + .times(1) + .returning(|_, _, _, _, _, _, persisted_tx| { + if let Some(tx) = persisted_tx { + let _ = tx.send(Ok(())); + } + Ok(Uuid::new_v4()) + }); + + let stats = bd_client_stats_store::Collector::default().scope("test"); + let (handle, worker) = StateUploadHandle::new( + Some(setup.state_dir.clone()), + setup.store.clone(), + Some(setup.retention_registry.clone()), + Some(setup.state_store.clone()), + 0, + setup.time_provider.clone(), + Arc::new(mock_client), + &stats, + ) + .await; + + drop(handle); + worker.run().await; + + assert!( + setup + .store + .get(&PENDING_UPLOAD_RANGE_KEY) + .and_then(|proto| pending_range_from_proto(&proto)) + .is_none() + ); +} diff --git a/bd-logger/src/test/state_upload_integration.rs b/bd-logger/src/test/state_upload_integration.rs index 92d212a2d..dd180f576 100644 --- a/bd-logger/src/test/state_upload_integration.rs +++ b/bd-logger/src/test/state_upload_integration.rs @@ -29,10 +29,8 @@ use tempfile::TempDir; #[test] fn continuous_buffer_creates_and_uploads_state_snapshot() { - let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); - let mut setup = Setup::new_with_cached_runtime(SetupOptions { - sdk_directory: sdk_directory.clone(), + sdk_directory: Arc::new(TempDir::with_prefix("sdk").unwrap()), disk_storage: true, extra_runtime_values: vec![ ( @@ -103,17 +101,6 @@ fn continuous_buffer_creates_and_uploads_state_snapshot() { "state snapshot should have content" ); - let snapshots_dir = sdk_directory.path().join("state/snapshots"); - if snapshots_dir.exists() { - let entries: Vec<_> = std::fs::read_dir(&snapshots_dir) - .unwrap() - .filter_map(Result::ok) - .collect(); - assert!( - !entries.is_empty(), - "snapshot files should exist in state/snapshots/" - ); - } return; } std::thread::sleep(std::time::Duration::from_millis(100)); @@ -122,10 +109,8 @@ fn continuous_buffer_creates_and_uploads_state_snapshot() { #[test] fn trigger_buffer_flush_creates_snapshot() { - let sdk_directory = Arc::new(TempDir::with_prefix("sdk").unwrap()); - let mut setup = Setup::new_with_cached_runtime(SetupOptions { - sdk_directory: sdk_directory.clone(), + sdk_directory: Arc::new(TempDir::with_prefix("sdk").unwrap()), disk_storage: true, extra_runtime_values: vec![ ( @@ -204,18 +189,6 @@ fn trigger_buffer_flush_creates_snapshot() { "state snapshot should have content" ); - let snapshots_dir = sdk_directory.path().join("state/snapshots"); - if snapshots_dir.exists() { - let snapshot_files: Vec<_> = std::fs::read_dir(&snapshots_dir) - .unwrap() - .filter_map(Result::ok) - .filter(|e| e.path().extension().is_some_and(|ext| ext == "zz")) - .collect(); - assert!( - !snapshot_files.is_empty(), - "snapshot .zz files should exist after trigger flush" - ); - } return; } std::thread::sleep(std::time::Duration::from_millis(100)); From 59e734627728d6d6876fb2f01564dfdf53be0d66 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 12:44:33 -0800 Subject: [PATCH 19/32] update continuous buffer snapshot retentin --- bd-buffer/src/ring_buffer.rs | 21 +++++++++------ bd-buffer/src/ring_buffer_test.rs | 19 ++++++++++++-- bd-logger/src/consumer.rs | 7 +++++ .../src/versioned_kv_journal/retention.rs | 8 +++--- .../versioned_kv_journal/retention_test.rs | 26 +++++++++++++++++++ 5 files changed, 68 insertions(+), 13 deletions(-) diff --git a/bd-buffer/src/ring_buffer.rs b/bd-buffer/src/ring_buffer.rs index aab7e2758..f0112e4de 100644 --- a/bd-buffer/src/ring_buffer.rs +++ b/bd-buffer/src/ring_buffer.rs @@ -284,11 +284,7 @@ impl Manager { let retention_registry = self.retention_registry.clone(); let allow_overwrite_for_cb = allow_overwrite; - let retention_handle = if allow_overwrite { - Some(retention_registry.create_handle().await) - } else { - None - }; + let retention_handle = Some(retention_registry.create_handle().await); let retention_handle_for_cb = retention_handle.clone(); // Only install the eviction callback if we are allowed to overwrite, as it's only @@ -342,7 +338,9 @@ impl Manager { .and_then(|ts| u64::try_from(ts.unix_timestamp_micros()).ok()) }) { Ok(Some(Some(micros))) => handle.update_retention_micros(micros), - Ok(Some(None) | None) => {}, + Ok(Some(None) | None) => { + handle.update_retention_micros(RetentionHandle::RETENTION_NONE); + }, Err(error) => { log::debug!("failed to peek oldest record for retention init: {error}"); }, @@ -536,6 +534,7 @@ impl Manager { // Adapter for the new cursor impl. To be removed. pub struct CursorConsumer { cursor_consumer: Box, + retention_handle: Option, // TODO(mattklein123): This is not actually required in the new code but some tests seem to // depend on this. Clean this up during the old code purge. @@ -557,6 +556,11 @@ impl CursorConsumer { .advance_read_pointer() .map_err(|e| anyhow!("cursor consumer buffer read error occurred: {e}")) } + + #[must_use] + pub fn retention_handle(&self) -> Option { + self.retention_handle.clone() + } } // @@ -599,7 +603,7 @@ pub struct RingBuffer { // The underlying buffer. buffer: Arc, - _retention_handle: Option, + retention_handle: Option, } impl Debug for RingBuffer { @@ -738,7 +742,7 @@ impl RingBuffer { filename: non_volatile_filename, delete_on_drop: AtomicBool::new(false), buffer, - _retention_handle: retention_handle, + retention_handle, }), deleted, ) @@ -760,6 +764,7 @@ impl RingBuffer { pub fn create_continous_consumer(self: &Arc) -> anyhow::Result { Ok(CursorConsumer { cursor_consumer: self.buffer.clone().register_cursor_consumer()?, + retention_handle: self.retention_handle.clone(), _buffer: self.clone(), }) } diff --git a/bd-buffer/src/ring_buffer_test.rs b/bd-buffer/src/ring_buffer_test.rs index 1dedacbcd..6b69b3d52 100644 --- a/bd-buffer/src/ring_buffer_test.rs +++ b/bd-buffer/src/ring_buffer_test.rs @@ -385,8 +385,6 @@ async fn retention_handle_is_released_on_buffer_removal() { .await .unwrap(); - assert!(retention_registry.min_retention_timestamp().await.is_some()); - let removed_config = BufferConfigList::default(); ring_buffer_manager .update_from_config(&removed_config, false) @@ -396,6 +394,23 @@ async fn retention_handle_is_released_on_buffer_removal() { assert!(retention_registry.min_retention_timestamp().await.is_none()); } +#[tokio::test] +async fn empty_continuous_buffer_uses_retention_none() { + let directory = tmp_dir(); + let retention_registry = Arc::new(bd_resilient_kv::RetentionRegistry::new( + bd_runtime::runtime::IntWatch::new_for_testing(0), + )); + let ring_buffer_manager = setup_manager(directory.path(), retention_registry.clone()); + + let config = single_buffer_with_size("continuous", 1_000, 100, buffer_config::Type::CONTINUOUS); + ring_buffer_manager + .update_from_config(&config, false) + .await + .unwrap(); + + assert_eq!(retention_registry.min_retention_timestamp().await, None); +} + #[tokio::test] async fn trigger_buffer_retention_initialized_from_oldest_record() { let directory = tmp_dir(); diff --git a/bd-logger/src/consumer.rs b/bd-logger/src/consumer.rs index ba7d6ba0d..6e9b4bc74 100644 --- a/bd-logger/src/consumer.rs +++ b/bd-logger/src/consumer.rs @@ -19,6 +19,7 @@ use bd_client_common::maybe_await; use bd_client_stats_store::{Counter, Scope}; use bd_error_reporter::reporter::handle_unexpected_error_with_details; use bd_log_primitives::EncodableLog; +use bd_resilient_kv::RetentionHandle; use bd_runtime::runtime::{ConfigLoader, DurationWatch, IntWatch, Watch}; use bd_shutdown::{ComponentShutdown, ComponentShutdownTrigger}; use bd_time::OffsetDateTimeExt; @@ -475,6 +476,7 @@ struct ContinuousBufferUploader { // State upload handle for uploading state snapshots before logs. state_upload_handle: Option>, + retention_handle: Option, } impl ContinuousBufferUploader { @@ -486,6 +488,7 @@ impl ContinuousBufferUploader { buffer_id: String, state_upload_handle: Option>, ) -> Self { + let retention_handle = consumer.retention_handle(); Self { consumer, log_upload_service, @@ -495,6 +498,7 @@ impl ContinuousBufferUploader { feature_flags, buffer_id, state_upload_handle, + retention_handle, } } // Attempts to upload all logs in the provided buffer. For every polling interval we @@ -582,6 +586,9 @@ impl ContinuousBufferUploader { for _ in 0 .. logs_len { self.consumer.advance_read_cursor()?; } + if let (Some(handle), Some((oldest, _newest))) = (&self.retention_handle, timestamp_range) { + handle.update_retention_micros(oldest); + } Ok(()) } diff --git a/bd-resilient-kv/src/versioned_kv_journal/retention.rs b/bd-resilient-kv/src/versioned_kv_journal/retention.rs index fc8635066..fd609fa63 100644 --- a/bd-resilient-kv/src/versioned_kv_journal/retention.rs +++ b/bd-resilient-kv/src/versioned_kv_journal/retention.rs @@ -33,6 +33,7 @@ pub struct RetentionHandle { impl RetentionHandle { pub const RETENTION_PENDING: u64 = u64::MAX; + pub const RETENTION_NONE: u64 = u64::MAX - 1; /// Updates the retention requirement to retain data from the given timestamp (in microseconds). /// @@ -114,15 +115,16 @@ impl RetentionRegistry { } let mut min_retention: Option = None; - let mut has_handles = false; let mut has_pending = false; for handle in handles.iter().filter_map(std::sync::Weak::upgrade) { - has_handles = true; let retention = handle.load(Ordering::Relaxed); if retention == RetentionHandle::RETENTION_PENDING { has_pending = true; continue; } + if retention == RetentionHandle::RETENTION_NONE { + continue; + } min_retention = Some(min_retention.map_or(retention, |min| min.min(retention))); } @@ -134,6 +136,6 @@ impl RetentionRegistry { return Some(min_retention); } - has_handles.then_some(0) + None } } diff --git a/bd-resilient-kv/src/versioned_kv_journal/retention_test.rs b/bd-resilient-kv/src/versioned_kv_journal/retention_test.rs index 7b4704f8f..ccb0d9d4d 100644 --- a/bd-resilient-kv/src/versioned_kv_journal/retention_test.rs +++ b/bd-resilient-kv/src/versioned_kv_journal/retention_test.rs @@ -128,6 +128,32 @@ async fn registry_pending_handle_retains_all() { ); } +#[tokio::test] +async fn registry_none_handle_does_not_require_retention() { + let registry = Arc::new(RetentionRegistry::new( + bd_runtime::runtime::IntWatch::new_for_testing(0), + )); + let handle = registry.create_handle().await; + handle.update_retention_micros(RetentionHandle::RETENTION_NONE); + + let min_retention = registry.min_retention_timestamp().await; + assert_eq!(min_retention, None); +} + +#[tokio::test] +async fn registry_none_handle_is_ignored_when_others_require_retention() { + let registry = Arc::new(RetentionRegistry::new( + bd_runtime::runtime::IntWatch::new_for_testing(0), + )); + let none_handle = registry.create_handle().await; + none_handle.update_retention_micros(RetentionHandle::RETENTION_NONE); + let active_handle = registry.create_handle().await; + active_handle.update_retention_micros(1_234_567); + + let min_retention = registry.min_retention_timestamp().await; + assert_eq!(min_retention, Some(1_234_567)); +} + #[tokio::test] async fn handle_releases_on_drop() { let registry = Arc::new(RetentionRegistry::new( From 0182771166064b5cc2a186b110fc1720be8aec6c Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 22:09:01 -0800 Subject: [PATCH 20/32] dedupe enqueue interface --- bd-artifact-upload/src/lib.rs | 2 +- bd-artifact-upload/src/uploader.rs | 55 +++---------------------- bd-artifact-upload/src/uploader_test.rs | 45 ++++++++++---------- bd-crash-handler/src/lib.rs | 4 +- bd-crash-handler/src/monitor_test.rs | 6 ++- bd-logger/src/state_upload.rs | 6 +-- bd-logger/src/state_upload_test.rs | 12 +++--- 7 files changed, 45 insertions(+), 85 deletions(-) diff --git a/bd-artifact-upload/src/lib.rs b/bd-artifact-upload/src/lib.rs index ae3bcf9fd..ea569e8ef 100644 --- a/bd-artifact-upload/src/lib.rs +++ b/bd-artifact-upload/src/lib.rs @@ -16,7 +16,7 @@ mod uploader; -pub use uploader::{Client, EnqueueError, MockClient, SnappedFeatureFlag, Uploader}; +pub use uploader::{Client, EnqueueError, MockClient, SnappedFeatureFlag, UploadSource, Uploader}; #[cfg(test)] #[ctor::ctor] diff --git a/bd-artifact-upload/src/uploader.rs b/bd-artifact-upload/src/uploader.rs index e4e2fe4e5..26f86908c 100644 --- a/bd-artifact-upload/src/uploader.rs +++ b/bd-artifact-upload/src/uploader.rs @@ -123,7 +123,7 @@ struct NewUpload { } #[derive(Debug)] -enum UploadSource { +pub enum UploadSource { File(std::fs::File), Path(PathBuf), } @@ -199,18 +199,7 @@ pub enum EnqueueError { pub trait Client: Send + Sync { fn enqueue_upload( &self, - file: std::fs::File, - type_id: String, - state: LogFields, - timestamp: Option, - session_id: String, - feature_flags: Vec, - persisted_tx: Option>>, - ) -> std::result::Result; - - fn enqueue_upload_from_path( - &self, - source_path: PathBuf, + source: UploadSource, type_id: String, state: LogFields, timestamp: Option, @@ -229,42 +218,7 @@ impl Client for UploadClient { /// Dispatches a payload to be uploaded, returning the associated artifact UUID. fn enqueue_upload( &self, - file: std::fs::File, - type_id: String, - state: LogFields, - timestamp: Option, - session_id: String, - feature_flags: Vec, - persisted_tx: Option>>, - ) -> std::result::Result { - let uuid = uuid::Uuid::new_v4(); - - let result = self - .upload_tx - .try_send(NewUpload { - uuid, - source: UploadSource::File(file), - type_id, - state, - timestamp, - session_id, - feature_flags, - persisted_tx, - }) - .inspect_err(|e| log::warn!("failed to enqueue artifact upload: {e:?}")); - - self.counter_stats.record(&result); - result.map_err(|e| match e { - bd_bounded_buffer::TrySendError::FullSizeOverflow => EnqueueError::QueueFull, - bd_bounded_buffer::TrySendError::Closed => EnqueueError::Closed, - })?; - - Ok(uuid) - } - - fn enqueue_upload_from_path( - &self, - source_path: PathBuf, + source: UploadSource, type_id: String, state: LogFields, timestamp: Option, @@ -278,7 +232,7 @@ impl Client for UploadClient { .upload_tx .try_send(NewUpload { uuid, - source: UploadSource::Path(source_path), + source, type_id, state, timestamp, @@ -447,6 +401,7 @@ impl Uploader { }; let contents = if is_zlib_data(&contents) { + // TODO(snowp): Should we consider validating the file here? contents } else { let Ok(contents) = read_checksummed_data(&contents) else { diff --git a/bd-artifact-upload/src/uploader_test.rs b/bd-artifact-upload/src/uploader_test.rs index e2383817f..add6e9d8e 100644 --- a/bd-artifact-upload/src/uploader_test.rs +++ b/bd-artifact-upload/src/uploader_test.rs @@ -12,6 +12,7 @@ use crate::uploader::{ REPORT_DIRECTORY, REPORT_INDEX_FILE, SnappedFeatureFlag, + UploadSource, }; use assert_matches::assert_matches; use bd_api::DataUpload; @@ -166,7 +167,7 @@ async fn basic_flow() { let id = setup .client .enqueue_upload( - setup.make_file(b"abc"), + UploadSource::File(setup.make_file(b"abc")), "client_report".to_string(), [("foo".into(), "bar".into())].into(), Some(timestamp), @@ -226,7 +227,7 @@ async fn feature_flags() { let id = setup .client .enqueue_upload( - setup.make_file(b"abc"), + UploadSource::File(setup.make_file(b"abc")), "client_report".to_string(), [("foo".into(), "bar".into())].into(), Some(timestamp), @@ -303,7 +304,7 @@ async fn pending_upload_limit() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"1"), + UploadSource::File(setup.make_file(b"1")), "client_report".to_string(), [].into(), None, @@ -320,7 +321,7 @@ async fn pending_upload_limit() { let id2 = setup .client .enqueue_upload( - setup.make_file(b"2"), + UploadSource::File(setup.make_file(b"2")), "client_report".to_string(), [].into(), None, @@ -336,7 +337,7 @@ async fn pending_upload_limit() { let id3 = setup .client .enqueue_upload( - setup.make_file(b"3"), + UploadSource::File(setup.make_file(b"3")), "client_report".to_string(), [].into(), None, @@ -405,7 +406,7 @@ async fn inconsistent_state_missing_file() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"1"), + UploadSource::File(setup.make_file(b"1")), "client_report".to_string(), [].into(), None, @@ -421,7 +422,7 @@ async fn inconsistent_state_missing_file() { let id2 = setup .client .enqueue_upload( - setup.make_file(b"2"), + UploadSource::File(setup.make_file(b"2")), "client_report".to_string(), [].into(), None, @@ -465,7 +466,7 @@ async fn inconsistent_state_extra_file() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"1"), + UploadSource::File(setup.make_file(b"1")), "client_report".to_string(), [].into(), None, @@ -537,7 +538,7 @@ async fn disk_persistence() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"1"), + UploadSource::File(setup.make_file(b"1")), "client_report".to_string(), [].into(), None, @@ -585,7 +586,7 @@ async fn inconsistent_state_missing_index() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"1"), + UploadSource::File(setup.make_file(b"1")), "client_report".to_string(), [].into(), None, @@ -610,7 +611,7 @@ async fn inconsistent_state_missing_index() { let id2 = setup .client .enqueue_upload( - setup.make_file(b"2"), + UploadSource::File(setup.make_file(b"2")), "client_report".to_string(), [].into(), None, @@ -657,7 +658,7 @@ async fn new_entry_disk_full() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"1"), + UploadSource::File(setup.make_file(b"1")), "client_report".to_string(), [].into(), None, @@ -687,7 +688,7 @@ async fn new_entry_disk_full_after_received() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"1"), + UploadSource::File(setup.make_file(b"1")), "client_report".to_string(), [].into(), None, @@ -729,7 +730,7 @@ async fn intent_retries() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"1"), + UploadSource::File(setup.make_file(b"1")), "client_report".to_string(), [].into(), None, @@ -765,7 +766,7 @@ async fn intent_drop() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"1"), + UploadSource::File(setup.make_file(b"1")), "client_report".to_string(), [].into(), None, @@ -803,7 +804,7 @@ async fn upload_retries() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"1"), + UploadSource::File(setup.make_file(b"1")), "client_report".to_string(), [].into(), None, @@ -856,7 +857,7 @@ async fn normalize_type_id_on_load() { let id = setup .client .enqueue_upload( - setup.make_file(b"abc"), + UploadSource::File(setup.make_file(b"abc")), "client_report".to_string(), [].into(), None, @@ -915,7 +916,7 @@ async fn enqueue_upload_acknowledges_after_disk_persist() { let id = setup .client .enqueue_upload( - setup.make_file(b"snapshot"), + UploadSource::File(setup.make_file(b"snapshot")), "state_snapshot".to_string(), [].into(), None, @@ -946,8 +947,8 @@ async fn enqueue_upload_from_path_acknowledges_after_disk_persist_and_removes_so let (persisted_tx, persisted_rx) = tokio::sync::oneshot::channel(); let id = setup .client - .enqueue_upload_from_path( - source_path.clone(), + .enqueue_upload( + UploadSource::Path(source_path.clone()), "state_snapshot".to_string(), [].into(), None, @@ -973,7 +974,7 @@ async fn queue_full_with_only_state_snapshots_rejects_new_state_snapshot() { let id1 = setup .client .enqueue_upload( - setup.make_file(b"state-1"), + UploadSource::File(setup.make_file(b"state-1")), "state_snapshot".to_string(), [].into(), None, @@ -992,7 +993,7 @@ async fn queue_full_with_only_state_snapshots_rejects_new_state_snapshot() { let _id2 = setup .client .enqueue_upload( - setup.make_file(b"state-2"), + UploadSource::File(setup.make_file(b"state-2")), "state_snapshot".to_string(), [].into(), None, diff --git a/bd-crash-handler/src/lib.rs b/bd-crash-handler/src/lib.rs index a337df3b1..5f89331da 100644 --- a/bd-crash-handler/src/lib.rs +++ b/bd-crash-handler/src/lib.rs @@ -22,7 +22,7 @@ pub mod config_writer; mod file_watcher; pub mod global_state; -use bd_artifact_upload::SnappedFeatureFlag; +use bd_artifact_upload::{SnappedFeatureFlag, UploadSource}; use bd_client_common::debug_check_lifecycle_less_than; use bd_client_common::init_lifecycle::{InitLifecycle, InitLifecycleState}; use bd_error_reporter::reporter::handle_unexpected; @@ -482,7 +482,7 @@ impl Monitor { log::debug!("uploading report out of band"); let Ok(artifact_id) = self.artifact_client.enqueue_upload( - file, + UploadSource::File(file), "client_report".to_string(), state_fields.clone(), timestamp, diff --git a/bd-crash-handler/src/monitor_test.rs b/bd-crash-handler/src/monitor_test.rs index c09ddc068..8956d4970 100644 --- a/bd-crash-handler/src/monitor_test.rs +++ b/bd-crash-handler/src/monitor_test.rs @@ -6,6 +6,7 @@ // https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt use crate::{Monitor, global_state}; +use bd_artifact_upload::UploadSource; use bd_client_common::init_lifecycle::InitLifecycleState; use bd_log_primitives::{AnnotatedLogFields, LogFields}; use bd_proto::flatbuffers::report::bitdrift_public::fbs::issue_reporting::v_1::{ @@ -374,7 +375,10 @@ impl Setup { make_mut(&mut self.upload_client) .expect_enqueue_upload() .withf( - move |mut file, ftype_id, fstate, ftimestamp, fsession_id, feature_flags, _persisted_tx| { + move |source, ftype_id, fstate, ftimestamp, fsession_id, feature_flags, _persisted_tx| { + let UploadSource::File(mut file) = source else { + return false; + }; let mut output = vec![]; file.read_to_end(&mut output).unwrap(); let content_match = output == content; diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index 86defffb5..d7164604c 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -32,7 +32,7 @@ #[path = "./state_upload_test.rs"] mod tests; -use bd_artifact_upload::{Client as ArtifactClient, EnqueueError}; +use bd_artifact_upload::{Client as ArtifactClient, EnqueueError, UploadSource}; use bd_client_stats_store::{Counter, Scope}; use bd_log_primitives::LogFields; use bd_resilient_kv::SnapshotFilename; @@ -367,8 +367,8 @@ impl StateUploadWorker { .ok(); let (persisted_tx, persisted_rx) = tokio::sync::oneshot::channel(); - match self.artifact_client.enqueue_upload_from_path( - snapshot_ref.path.clone(), + match self.artifact_client.enqueue_upload( + UploadSource::Path(snapshot_ref.path.clone()), "state_snapshot".to_string(), LogFields::new(), timestamp, diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index 23c9ad36e..7eb047530 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -175,7 +175,7 @@ async fn no_snapshot_files_skips_upload() { let store = in_memory_store(); let stats = bd_client_stats_store::Collector::default().scope("test"); let mut mock_client = bd_artifact_upload::MockClient::new(); - mock_client.expect_enqueue_upload_from_path().times(0); + mock_client.expect_enqueue_upload().times(0); let (_handle, mut worker) = StateUploadHandle::new( None, store, @@ -324,7 +324,7 @@ async fn enqueue_backpressure_keeps_pending_range() { let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client - .expect_enqueue_upload_from_path() + .expect_enqueue_upload() .times(1) .returning(|_, _, _, _, _, _, _| Err(bd_artifact_upload::EnqueueError::QueueFull)); let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; @@ -349,7 +349,7 @@ async fn persisted_ack_error_does_not_advance_watermark() { let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client - .expect_enqueue_upload_from_path() + .expect_enqueue_upload() .times(1) .returning(|_, _, _, _, _, _, persisted_tx| { if let Some(tx) = persisted_tx { @@ -371,7 +371,7 @@ async fn persisted_ack_channel_drop_does_not_advance_watermark() { let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client - .expect_enqueue_upload_from_path() + .expect_enqueue_upload() .times(1) .returning(|_, _, _, _, _, _, _| Ok(Uuid::new_v4())); @@ -388,7 +388,7 @@ async fn successful_enqueue_ack_clears_pending() { let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client - .expect_enqueue_upload_from_path() + .expect_enqueue_upload() .times(1) .returning(|_, _, _, _, _, _, persisted_tx| { if let Some(tx) = persisted_tx { @@ -519,7 +519,7 @@ async fn run_processes_persisted_pending_range_on_startup() { let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client - .expect_enqueue_upload_from_path() + .expect_enqueue_upload() .times(1) .returning(|_, _, _, _, _, _, persisted_tx| { if let Some(tx) = persisted_tx { From ead0152419377fae8fa1c7f9575f00b02bb71e40 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 22:14:56 -0800 Subject: [PATCH 21/32] cleanup --- bd-crash-handler/src/monitor_test.rs | 3 ++- bd-logger/src/consumer.rs | 12 +----------- bd-logger/src/state_upload.rs | 2 +- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/bd-crash-handler/src/monitor_test.rs b/bd-crash-handler/src/monitor_test.rs index 8956d4970..ee3d5b05b 100644 --- a/bd-crash-handler/src/monitor_test.rs +++ b/bd-crash-handler/src/monitor_test.rs @@ -376,9 +376,10 @@ impl Setup { .expect_enqueue_upload() .withf( move |source, ftype_id, fstate, ftimestamp, fsession_id, feature_flags, _persisted_tx| { - let UploadSource::File(mut file) = source else { + let UploadSource::File(file) = source else { return false; }; + let mut file = file.try_clone().unwrap(); let mut output = vec![]; file.read_to_end(&mut output).unwrap(); let content_match = output == content; diff --git a/bd-logger/src/consumer.rs b/bd-logger/src/consumer.rs index ce3d64286..b9f63bb81 100644 --- a/bd-logger/src/consumer.rs +++ b/bd-logger/src/consumer.rs @@ -313,15 +313,12 @@ impl BufferUploadManager { let consumer = buffer.clone().register_consumer()?; let batch_builder = BatchBuilder::new(self.feature_flags.clone()); - // Stream uploads are excluded from state snapshot uploads for now. - let state_upload_handle = None; tokio::task::spawn(async move { StreamedBufferUpload { consumer, log_upload_service, batch_builder, shutdown, - state_upload_handle, } .start() .await @@ -555,7 +552,6 @@ impl ContinuousBufferUploader { let logs_len = logs.len(); log::debug!("flushing {logs_len} logs"); - // Upload state snapshot if needed before uploading logs if let (Some(handle), Some((oldest, newest))) = (&self.state_upload_handle, timestamp_range) { handle.notify_upload_needed(oldest, newest); } @@ -619,9 +615,6 @@ struct StreamedBufferUpload { batch_builder: BatchBuilder, shutdown: ComponentShutdown, - - // State upload handle for uploading state snapshots before logs. - state_upload_handle: Option>, } impl StreamedBufferUpload { @@ -679,10 +672,7 @@ impl StreamedBufferUpload { } } - let timestamp_range = self.batch_builder.timestamp_range(); - if let (Some(handle), Some((oldest, newest))) = (&self.state_upload_handle, timestamp_range) { - handle.notify_upload_needed(oldest, newest); - } + // TODO(snowp): Handle streaming state updates. let upload_future = async { self diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index d7164604c..947f6269a 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -164,7 +164,7 @@ impl StateUploadHandle { (handle, worker) } - /// Notifies the background worker that a state snapshot upload may be needed for a log batch. + /// Notifies the uploader that a state snapshot upload may be needed for a log batch. /// /// This is non-blocking. The range is first merged into a shared accumulator, then the worker is /// nudged via a best-effort wake channel. From 1a9222909cfcd6d6d1b869087fa4d24ee97c40d7 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 22:15:22 -0800 Subject: [PATCH 22/32] cleanup --- bd-logger/src/logger.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/bd-logger/src/logger.rs b/bd-logger/src/logger.rs index 85079552f..fdc64abce 100644 --- a/bd-logger/src/logger.rs +++ b/bd-logger/src/logger.rs @@ -320,7 +320,6 @@ impl LoggerHandle { ); } - #[must_use] pub fn should_log_app_update( &self, From fb979b087a939d0f4d52868484010fc7fd178c92 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 22:36:27 -0800 Subject: [PATCH 23/32] comments --- bd-logger/AGENTS.md | 17 ++++++++++++----- bd-logger/src/state_upload.rs | 10 +++++++--- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/bd-logger/AGENTS.md b/bd-logger/AGENTS.md index aa92a6a81..cb6be0a5d 100644 --- a/bd-logger/AGENTS.md +++ b/bd-logger/AGENTS.md @@ -37,10 +37,10 @@ The handle and worker are created together via `StateUploadHandle::new`. When the worker receives a batch's timestamp range `[oldest, newest]`, it evaluates in order: 1. **No state changes ever recorded** (`last_change_micros == 0`) → skip. Nothing to upload. -2. **Snapshot files exist** in `{state_store_path}/snapshots/` → upload every snapshot file found - there (oldest-first). File presence is the source of truth. +2. **Snapshot files exist** in `{state_store_path}/snapshots/` → upload snapshots whose filename + timestamp is within the current pending log range `[oldest, newest]` (oldest-first). 3. **No snapshot files exist but state changed** (`last_change_micros > 0`) → create one on-demand via - `state_store.rotate_journal()`, subject to a cooldown (see below). + `state_store.rotate_journal()`, subject to a cooldown (see below). ### Snapshot Cooldown @@ -51,7 +51,8 @@ worker defers on-demand creation and keeps pending work for retry. ### Snapshot Move Semantics -State snapshot uploads are enqueued via `enqueue_upload_from_path`: the snapshot file is moved +State snapshot uploads are enqueued via `enqueue_upload(UploadSource::Path(...))`: the snapshot +file is moved (renamed) from `state/snapshots/` into `bd-artifact-upload`'s `report_uploads/` directory. This means: @@ -60,7 +61,13 @@ means: re-upload it. - If enqueue fails, the file remains in `state/snapshots/`, so the next retry still sees it. -Upload selection is file-presence based; there is no separate uploaded watermark state. +Upload selection is range-based over file presence; there is no separate uploaded watermark state. + +### Pending Range Durability + +The worker persists pending coverage to key-value storage (`state_upload.pending_range.1`) whenever +it drains/merges producer requests, and clears it after successful processing. On startup, it reads +this key and immediately processes recovered pending work before entering the normal wake loop. ### BatchBuilder Timestamp Tracking diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index 947f6269a..6e1cb04f6 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -12,9 +12,10 @@ //! most recent state snapshot uploaded before time T. //! //! The [`StateUploadHandle`] ensures that: -//! - State snapshots are uploaded before logs that depend on them +//! - State snapshots are marked for upload as logs that depend on them //! - Duplicate snapshot uploads are avoided across multiple buffers //! - Snapshot coverage is tracked across process restarts via persistence +//! - Snapshot selection is scoped to the pending log timestamp range, avoiding unnecessary uploads //! //! ## Architecture //! @@ -27,6 +28,10 @@ //! - [`StateUploadWorker`] — a single background task that owns all snapshot creation and upload //! logic. Because only one task processes requests, deduplication and cooldown enforcement happen //! naturally without any locking between callers. +//! +//! The worker persists pending upload range coverage in key-value storage and recovers it on +//! startup. Snapshot artifacts are enqueued with `enqueue_upload(UploadSource::Path(...))`, which +//! preserves move semantics from `state/snapshots/` into artifact upload storage. #[cfg(test)] #[path = "./state_upload_test.rs"] @@ -47,7 +52,6 @@ use tokio::sync::mpsc; use tokio::time::{Duration, sleep}; /// Capacity of the worker wake channel used to nudge processing of coalesced pending ranges. -const UPLOAD_CHANNEL_CAPACITY: usize = 1; const BACKPRESSURE_RETRY_INTERVAL: Duration = Duration::from_secs(1); static PENDING_UPLOAD_RANGE_KEY: bd_key_value::Key = bd_key_value::Key::new("state_upload.pending_range.1"); @@ -133,7 +137,7 @@ impl StateUploadHandle { ) -> (Self, StateUploadWorker) { let stats = Stats::new(&stats_scope.scope("state_upload")); - let (wake_tx, wake_rx) = mpsc::channel(UPLOAD_CHANNEL_CAPACITY); + let (wake_tx, wake_rx) = mpsc::channel(1); let pending_accumulator = Arc::new(parking_lot::Mutex::new(PendingAccumulator::default())); let retention_handle = match &retention_registry { Some(registry) => Some(registry.create_handle().await), From f7aa7da1a6afab9cf316de0099f7bb7a1d0c118f Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 22:49:18 -0800 Subject: [PATCH 24/32] clean up tests some more --- bd-logger/src/consumer_test.rs | 3 - bd-logger/src/state_upload.rs | 53 +++------ bd-logger/src/state_upload_test.rs | 22 ++-- bd-proto/src/protos/client/key_value.rs | 147 +++++++++++++++++++++++- 4 files changed, 169 insertions(+), 56 deletions(-) diff --git a/bd-logger/src/consumer_test.rs b/bd-logger/src/consumer_test.rs index a95eff097..91b188bc8 100644 --- a/bd-logger/src/consumer_test.rs +++ b/bd-logger/src/consumer_test.rs @@ -996,7 +996,6 @@ async fn log_streaming() { log_upload_service: upload_service, shutdown: shutdown_trigger.make_shutdown(), batch_builder: BatchBuilder::new(make_flags(&runtime_loader)), - state_upload_handle: None, } .start() .await @@ -1053,7 +1052,6 @@ async fn streaming_batch_size_flag() { log_upload_service: upload_service, batch_builder: BatchBuilder::new(make_flags(&runtime_loader)), shutdown: shutdown_trigger.make_shutdown(), - state_upload_handle: None, } .start() .await @@ -1111,7 +1109,6 @@ async fn log_streaming_shutdown() { log_upload_service: upload_service, shutdown, batch_builder: BatchBuilder::new(make_flags(&runtime_loader)), - state_upload_handle: None, } .start() .await diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index 6e1cb04f6..423129cf6 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -40,10 +40,10 @@ mod tests; use bd_artifact_upload::{Client as ArtifactClient, EnqueueError, UploadSource}; use bd_client_stats_store::{Counter, Scope}; use bd_log_primitives::LogFields; +use bd_proto::protos::client::key_value::StateSnapshotRange; use bd_resilient_kv::SnapshotFilename; use bd_state::{RetentionHandle, RetentionRegistry}; use bd_time::{OffsetDateTimeExt, TimeProvider}; -use protobuf::well_known_types::struct_::{Struct as ProtoStruct, Value as ProtoValue, value}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -51,9 +51,8 @@ use time::OffsetDateTime; use tokio::sync::mpsc; use tokio::time::{Duration, sleep}; -/// Capacity of the worker wake channel used to nudge processing of coalesced pending ranges. -const BACKPRESSURE_RETRY_INTERVAL: Duration = Duration::from_secs(1); -static PENDING_UPLOAD_RANGE_KEY: bd_key_value::Key = +const BACKPRESSURE_RETRY_INTERVAL: Duration = Duration::from_secs(30); +static PENDING_UPLOAD_RANGE_KEY: bd_key_value::Key = bd_key_value::Key::new("state_upload.pending_range.1"); @@ -567,7 +566,7 @@ impl StateUploadWorker { .set(&PENDING_UPLOAD_RANGE_KEY, &pending_range_to_proto(range)), None => self .store - .set(&PENDING_UPLOAD_RANGE_KEY, &ProtoStruct::default()), + .set(&PENDING_UPLOAD_RANGE_KEY, &StateSnapshotRange::default()), } } @@ -594,43 +593,19 @@ impl StateUploadWorker { } } -fn pending_range_to_proto(range: PendingRange) -> ProtoStruct { - let mut proto = ProtoStruct::new(); - proto.fields.insert( - "oldest_micros".to_string(), - ProtoValue { - kind: Some(value::Kind::StringValue(range.oldest_micros.to_string())), - ..Default::default() - }, - ); - proto.fields.insert( - "newest_micros".to_string(), - ProtoValue { - kind: Some(value::Kind::StringValue(range.newest_micros.to_string())), - ..Default::default() - }, - ); +fn pending_range_to_proto(range: PendingRange) -> StateSnapshotRange { + let mut proto = StateSnapshotRange::new(); + proto.oldest_micros = range.oldest_micros; + proto.newest_micros = range.newest_micros; proto } -fn pending_range_from_proto(proto: &ProtoStruct) -> Option { - let oldest = proto - .fields - .get("oldest_micros") - .and_then(proto_string_value_to_u64)?; - let newest = proto - .fields - .get("newest_micros") - .and_then(proto_string_value_to_u64)?; +fn pending_range_from_proto(proto: &StateSnapshotRange) -> Option { + if proto.oldest_micros == 0 && proto.newest_micros == 0 { + return None; + } Some(PendingRange { - oldest_micros: oldest, - newest_micros: newest, + oldest_micros: proto.oldest_micros, + newest_micros: proto.newest_micros, }) } - -fn proto_string_value_to_u64(value: &ProtoValue) -> Option { - let value::Kind::StringValue(v) = value.kind.as_ref()? else { - return None; - }; - v.parse::().ok() -} diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index 7eb047530..ec7553537 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -168,6 +168,10 @@ impl Setup { } } } + + fn count_snapshot_files(&self) -> usize { + std::fs::read_dir(&self.snapshots_dir).map_or(0, Iterator::count) + } } #[tokio::test] @@ -208,7 +212,7 @@ async fn cooldown_allows_snapshot_after_interval() { let snapshot1 = worker.create_snapshot_if_needed(batch_ts).await; assert!(snapshot1.is_some()); - let file_count_after_first = count_snapshot_files(&setup.snapshots_dir); + let file_count_after_first = setup.count_snapshot_files(); // Advance time past cooldown. setup.time_provider.advance(time::Duration::milliseconds(2)); @@ -219,7 +223,7 @@ async fn cooldown_allows_snapshot_after_interval() { let snapshot2 = worker.create_snapshot_if_needed(future_batch_ts).await; assert!(snapshot2.is_some()); assert_eq!( - count_snapshot_files(&setup.snapshots_dir), + setup.count_snapshot_files(), file_count_after_first + 1, "should create new snapshot after cooldown expires" ); @@ -276,9 +280,7 @@ async fn notify_upload_needed_keeps_range_when_wake_channel_is_full() { ) .await; - for _ in 0 .. UPLOAD_CHANNEL_CAPACITY { - handle.wake_tx.try_send(()).unwrap(); - } + handle.wake_tx.try_send(()).unwrap(); handle.notify_upload_needed(100, 200); let pending = handle.pending_accumulator.lock(); @@ -312,10 +314,6 @@ async fn cooldown_defer_keeps_pending_for_retry() { ); } -fn count_snapshot_files(snapshots_dir: &std::path::Path) -> usize { - std::fs::read_dir(snapshots_dir).map_or(0, Iterator::count) -} - #[tokio::test] async fn enqueue_backpressure_keeps_pending_range() { @@ -343,7 +341,7 @@ async fn enqueue_backpressure_keeps_pending_range() { } #[tokio::test] -async fn persisted_ack_error_does_not_advance_watermark() { +async fn persisted_ack_error_keeps_pending_range() { let setup = Setup::new().await; let snapshot_ts = setup.create_rotated_snapshot().await; @@ -365,7 +363,7 @@ async fn persisted_ack_error_does_not_advance_watermark() { } #[tokio::test] -async fn persisted_ack_channel_drop_does_not_advance_watermark() { +async fn persisted_ack_channel_drop_keeps_pending_range() { let setup = Setup::new().await; let snapshot_ts = setup.create_rotated_snapshot().await; @@ -409,7 +407,7 @@ async fn successful_enqueue_ack_clears_pending() { } #[tokio::test] -async fn plan_upload_attempt_skips_last_change_zero_already_covered_and_no_new_changes() { +async fn plan_upload_attempt_skips_when_no_state_changes_or_no_store() { let store = in_memory_store(); let stats = bd_client_stats_store::Collector::default().scope("test"); let (_handle, worker) = StateUploadHandle::new( diff --git a/bd-proto/src/protos/client/key_value.rs b/bd-proto/src/protos/client/key_value.rs index 95066227e..8dfece71e 100644 --- a/bd-proto/src/protos/client/key_value.rs +++ b/bd-proto/src/protos/client/key_value.rs @@ -686,6 +686,146 @@ pub mod app_version { } } +// @@protoc_insertion_point(message:bitdrift_public.protobuf.client.v1.StateSnapshotRange) +#[derive(PartialEq,Clone,Default,Debug)] +pub struct StateSnapshotRange { + // message fields + // @@protoc_insertion_point(field:bitdrift_public.protobuf.client.v1.StateSnapshotRange.oldest_micros) + pub oldest_micros: u64, + // @@protoc_insertion_point(field:bitdrift_public.protobuf.client.v1.StateSnapshotRange.newest_micros) + pub newest_micros: u64, + // special fields + // @@protoc_insertion_point(special_field:bitdrift_public.protobuf.client.v1.StateSnapshotRange.special_fields) + pub special_fields: ::protobuf::SpecialFields, +} + +impl<'a> ::std::default::Default for &'a StateSnapshotRange { + fn default() -> &'a StateSnapshotRange { + ::default_instance() + } +} + +impl StateSnapshotRange { + pub fn new() -> StateSnapshotRange { + ::std::default::Default::default() + } + + fn generated_message_descriptor_data() -> ::protobuf::reflect::GeneratedMessageDescriptorData { + let mut fields = ::std::vec::Vec::with_capacity(2); + let mut oneofs = ::std::vec::Vec::with_capacity(0); + fields.push(::protobuf::reflect::rt::v2::make_simpler_field_accessor::<_, _>( + "oldest_micros", + |m: &StateSnapshotRange| { &m.oldest_micros }, + |m: &mut StateSnapshotRange| { &mut m.oldest_micros }, + )); + fields.push(::protobuf::reflect::rt::v2::make_simpler_field_accessor::<_, _>( + "newest_micros", + |m: &StateSnapshotRange| { &m.newest_micros }, + |m: &mut StateSnapshotRange| { &mut m.newest_micros }, + )); + ::protobuf::reflect::GeneratedMessageDescriptorData::new_2::( + "StateSnapshotRange", + fields, + oneofs, + ) + } +} + +impl ::protobuf::Message for StateSnapshotRange { + const NAME: &'static str = "StateSnapshotRange"; + + fn is_initialized(&self) -> bool { + true + } + + fn merge_from(&mut self, is: &mut ::protobuf::CodedInputStream<'_>) -> ::protobuf::Result<()> { + while let Some(tag) = is.read_raw_tag_or_eof()? { + match tag { + 8 => { + self.oldest_micros = is.read_uint64()?; + }, + 16 => { + self.newest_micros = is.read_uint64()?; + }, + tag => { + ::protobuf::rt::read_unknown_or_skip_group(tag, is, self.special_fields.mut_unknown_fields())?; + }, + }; + } + ::std::result::Result::Ok(()) + } + + // Compute sizes of nested messages + #[allow(unused_variables)] + fn compute_size(&self) -> u64 { + let mut my_size = 0; + if self.oldest_micros != 0 { + my_size += ::protobuf::rt::uint64_size(1, self.oldest_micros); + } + if self.newest_micros != 0 { + my_size += ::protobuf::rt::uint64_size(2, self.newest_micros); + } + my_size += ::protobuf::rt::unknown_fields_size(self.special_fields.unknown_fields()); + self.special_fields.cached_size().set(my_size as u32); + my_size + } + + fn write_to_with_cached_sizes(&self, os: &mut ::protobuf::CodedOutputStream<'_>) -> ::protobuf::Result<()> { + if self.oldest_micros != 0 { + os.write_uint64(1, self.oldest_micros)?; + } + if self.newest_micros != 0 { + os.write_uint64(2, self.newest_micros)?; + } + os.write_unknown_fields(self.special_fields.unknown_fields())?; + ::std::result::Result::Ok(()) + } + + fn special_fields(&self) -> &::protobuf::SpecialFields { + &self.special_fields + } + + fn mut_special_fields(&mut self) -> &mut ::protobuf::SpecialFields { + &mut self.special_fields + } + + fn new() -> StateSnapshotRange { + StateSnapshotRange::new() + } + + fn clear(&mut self) { + self.oldest_micros = 0; + self.newest_micros = 0; + self.special_fields.clear(); + } + + fn default_instance() -> &'static StateSnapshotRange { + static instance: StateSnapshotRange = StateSnapshotRange { + oldest_micros: 0, + newest_micros: 0, + special_fields: ::protobuf::SpecialFields::new(), + }; + &instance + } +} + +impl ::protobuf::MessageFull for StateSnapshotRange { + fn descriptor() -> ::protobuf::reflect::MessageDescriptor { + static descriptor: ::protobuf::rt::Lazy<::protobuf::reflect::MessageDescriptor> = ::protobuf::rt::Lazy::new(); + descriptor.get(|| file_descriptor().message_by_package_relative_name("StateSnapshotRange").unwrap()).clone() + } +} + +impl ::std::fmt::Display for StateSnapshotRange { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + ::protobuf::text_format::fmt(self, f) + } +} + +impl ::protobuf::reflect::ProtobufValue for StateSnapshotRange { + type RuntimeType = ::protobuf::reflect::rt::RuntimeTypeMessage; +} + static file_descriptor_proto_data: &'static [u8] = b"\ \n2bitdrift_public/protobuf/client/v1/key_value.proto\x12\"bitdrift_publ\ ic.protobuf.client.v1\x1a1bitdrift_public/protobuf/logging/v1/payload.pr\ @@ -698,7 +838,9 @@ static file_descriptor_proto_data: &'static [u8] = b"\ uf.logging.v1.Log.FieldR\x06fields\"\x80\x01\n\nAppVersion\x12\x18\n\x07\ version\x18\x01\x20\x01(\tR\x07version\x12*\n\x10app_version_code\x18\ \x02\x20\x01(\x03H\0R\x0eappVersionCode\x12#\n\x0cbuild_number\x18\x03\ - \x20\x01(\tH\0R\x0bbuildNumberB\x07\n\x05extrab\x06proto3\ + \x20\x01(\tH\0R\x0bbuildNumberB\x07\n\x05extra\"^\n\x12StateSnapshotRang\ + e\x12#\n\roldest_micros\x18\x01\x20\x01(\x04R\x0coldestMicros\x12#\n\rne\ + west_micros\x18\x02\x20\x01(\x04R\x0cnewestMicrosb\x06proto3\ "; /// `FileDescriptorProto` object which was a source for this generated file @@ -718,11 +860,12 @@ pub fn file_descriptor() -> &'static ::protobuf::reflect::FileDescriptor { let mut deps = ::std::vec::Vec::with_capacity(2); deps.push(super::payload::file_descriptor().clone()); deps.push(::protobuf::well_known_types::timestamp::file_descriptor().clone()); - let mut messages = ::std::vec::Vec::with_capacity(4); + let mut messages = ::std::vec::Vec::with_capacity(5); messages.push(FixedSessionStrategyState::generated_message_descriptor_data()); messages.push(ActivitySessionStrategyState::generated_message_descriptor_data()); messages.push(CrashGlobalState::generated_message_descriptor_data()); messages.push(AppVersion::generated_message_descriptor_data()); + messages.push(StateSnapshotRange::generated_message_descriptor_data()); let mut enums = ::std::vec::Vec::with_capacity(0); ::protobuf::reflect::GeneratedFileDescriptor::new_generated( file_descriptor_proto(), From aafc827b085226c4c4b7991bb4e44c042731ab8a Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 22:54:28 -0800 Subject: [PATCH 25/32] increase default snapshot delay --- bd-runtime/src/runtime.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bd-runtime/src/runtime.rs b/bd-runtime/src/runtime.rs index d50c75aca..a641ed7ad 100644 --- a/bd-runtime/src/runtime.rs +++ b/bd-runtime/src/runtime.rs @@ -958,12 +958,12 @@ pub mod state { int_feature_flag!(MaxCapacity, "state.max_capacity_bytes", 1024 * 1024); // Minimum interval between state snapshot creations in milliseconds. This batching is primarily - // necessary for log streaming configurations where all logs are streamed rapidly. Defaults to - // 5000ms (5 seconds). + // necessary for log streaming configurations where all logs are streamed rapidly and there are a + // lot of state changes. Defaults to 5 minutes. int_feature_flag!( SnapshotCreationIntervalMs, "state.snapshot_creation_interval_ms", - 5000 + 5 * 60 * 1000 ); // Controls whether state snapshot uploads are enabled. When disabled, state snapshots are From c54e0c2a569ec6ef521e5cf9ee2befffb807e47d Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Thu, 26 Feb 2026 23:05:12 -0800 Subject: [PATCH 26/32] cleanup --- api | 2 +- bd-state/src/lib.rs | 22 ++++------------------ 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/api b/api index 1444355a4..16e9aee54 160000 --- a/api +++ b/api @@ -1 +1 @@ -Subproject commit 1444355a4a6d942c8b3661d450689b83fc318a2b +Subproject commit 16e9aee54dbe8317395b8ea34045dce9186d9940 diff --git a/bd-state/src/lib.rs b/bd-state/src/lib.rs index 9a6e7a172..9bf00d7eb 100644 --- a/bd-state/src/lib.rs +++ b/bd-state/src/lib.rs @@ -39,7 +39,6 @@ use std::sync::Arc; use time::OffsetDateTime; use tokio::sync::RwLock; - /// The key used for storing the current system session ID in the state store. pub const SYSTEM_SESSION_ID_KEY: &str = "sid"; @@ -198,9 +197,7 @@ pub struct StoreInitResult { pub data_loss: DataLoss, /// Snapshot of state from the previous process, captured before clearing ephemeral scopes pub previous_state: ScopedMaps, - /// Registry for managing snapshot retention across buffers. Each buffer should create a - /// retention handle from this registry to prevent snapshots from being cleaned up while - /// logs that reference them are still in the buffer. + /// Retention registry used for snapshot retention pub retention_registry: Arc, } @@ -221,7 +218,7 @@ pub struct StoreInitWithFallbackResult { pub previous_state: ScopedMaps, /// Whether fallback to in-memory storage occurred pub fallback_occurred: bool, - /// Registry for managing snapshot retention across buffers. Empty if fallback occurred. + /// Retention registry used for snapshot retention pub retention_registry: Arc, } @@ -391,8 +388,6 @@ impl Store { data_loss: None, previous_state: ScopedMaps::default(), fallback_occurred: true, - // In-memory store doesn't have snapshots to retain, but we still provide a registry - // so callers don't need to handle the Option case. retention_registry: Arc::new(RetentionRegistry::new( bd_runtime::runtime::state::MaxSnapshotCount::register(runtime_loader), )), @@ -542,7 +537,8 @@ impl Store { } fn record_change(&self, timestamp: OffsetDateTime) { - let micros = timestamp.unix_timestamp_micros().cast_unsigned(); + let micros = + timestamp.unix_timestamp().cast_unsigned() * 1_000_000 + u64::from(timestamp.microsecond()); self .last_change_micros .fetch_max(micros, std::sync::atomic::Ordering::Relaxed); @@ -659,7 +655,6 @@ impl Store { .collect_vec(); let mut changes = Vec::new(); - let mut latest_timestamp = None; // TODO(snowp): Ideally we should have built in support for batch deletions in the // underlying store. This leaves us open for partial deletions if something fails halfway @@ -671,10 +666,6 @@ impl Store { .unwrap_or_else(|_| OffsetDateTime::now_utc()); if old_state_value.value_type.is_some() { - // Track the latest timestamp among all changes - if latest_timestamp.is_none() || timestamp > latest_timestamp.unwrap_or(timestamp) { - latest_timestamp = Some(timestamp); - } changes.push(StateChange { scope, key, @@ -691,11 +682,6 @@ impl Store { self.record_change(ts); } - // Notify the listener once with the latest timestamp if any changes occurred - if let Some(ts) = latest_timestamp { - self.record_change(ts); - } - Ok(StateChanges { changes }) } From 7f7ab16250948745214cdc9374de1efbd320b415 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Fri, 27 Feb 2026 07:35:58 -0800 Subject: [PATCH 27/32] fix agents --- bd-logger/AGENTS.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bd-logger/AGENTS.md b/bd-logger/AGENTS.md index cb6be0a5d..02ba6b93c 100644 --- a/bd-logger/AGENTS.md +++ b/bd-logger/AGENTS.md @@ -86,10 +86,8 @@ range is available, then call `take()` to produce the log batch. ### Wake Channel Backpressure -The wake channel has capacity `UPLOAD_CHANNEL_CAPACITY` (1). If wake signaling is saturated, -`notify_upload_needed` still records the requested range in shared pending state and returns. A -missed wake does not lose coverage; the worker will observe pending state on the next wake/timer -cycle, and version tracking forces immediate reprocessing when producers update pending state while +The wake channel has capacity 1. If wake signaling is saturated, +`notify_upload_needed` still records the requested range in shared pending state and returns. A missed wake does not lose coverage; the worker will observe pending state on the next wake/timer cycle, and version tracking forces immediate reprocessing when producers update pending state while the worker is active. ### Key Invariants From b43d7a5fd375d9e562edec613be483a67a781896 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Fri, 27 Feb 2026 13:10:07 -0800 Subject: [PATCH 28/32] handle raw vs checksummed artifact files --- bd-artifact-upload/src/uploader.rs | 31 +++++--- bd-client-common/src/file.rs | 10 --- bd-client-common/src/file_test.rs | 11 --- bd-proto/src/protos/client/artifact.rs | 97 ++++++++++++++++++++++++-- 4 files changed, 113 insertions(+), 36 deletions(-) diff --git a/bd-artifact-upload/src/uploader.rs b/bd-artifact-upload/src/uploader.rs index 26f86908c..99e35145d 100644 --- a/bd-artifact-upload/src/uploader.rs +++ b/bd-artifact-upload/src/uploader.rs @@ -16,7 +16,6 @@ use bd_bounded_buffer::SendCounters; use bd_client_common::error::InvariantError; use bd_client_common::file::{ async_write_checksummed_data, - is_zlib_data, read_checksummed_data, read_compressed_protobuf, write_compressed_protobuf, @@ -28,8 +27,8 @@ use bd_error_reporter::reporter::handle_unexpected; use bd_log_primitives::LogFields; use bd_log_primitives::size::MemorySized; use bd_proto::protos::client::api::{UploadArtifactIntentRequest, UploadArtifactRequest}; -use bd_proto::protos::client::artifact::ArtifactUploadIndex; use bd_proto::protos::client::artifact::artifact_upload_index::Artifact; +use bd_proto::protos::client::artifact::{ArtifactUploadIndex, StorageFormat}; use bd_proto::protos::client::feature_flag::FeatureFlag; use bd_proto::protos::logging::payload::Data; use bd_runtime::runtime::{ConfigLoader, DurationWatch, IntWatch, artifact_upload}; @@ -124,7 +123,13 @@ struct NewUpload { #[derive(Debug)] pub enum UploadSource { + // When a file handle is provided the uploader will copy the contents of the file to disk and + // append a CRC checksum to the end of the file to allow for integrity checking when we later + // read. File(std::fs::File), + // For raw files they are directly moved to the target location without modification. This is + // intended for use cases where the data format is already self-validating (e.g. crc checksum or + // zlib compression). Path(PathBuf), } @@ -400,8 +405,12 @@ impl Uploader { return Ok(()); }; - let contents = if is_zlib_data(&contents) { - // TODO(snowp): Should we consider validating the file here? + // For client reports we copy the file into a new file with a CRC checksum appended to allow + // for integrity checking. For state snapshot since they are already zlib encoded we bypass + // this check. TODO(snowp): Consider consolidating the behavior here, but keeping + // reports the same for now to avoid more changes than necessary. + let contents = if next.storage_format.enum_value_or_default() == StorageFormat::RAW { + // TODO(snowp): Should we consider validating the file here in some way? contents } else { let Ok(contents) = read_checksummed_data(&contents) else { @@ -684,7 +693,7 @@ impl Uploader { let uuid = uuid.to_string(); let target_path = REPORT_DIRECTORY.join(&uuid); - let write_result = match source { + let (write_result, storage_format) = match source { UploadSource::File(file) => { let target_file = match self.file_system.create_file(&target_path).await { Ok(file) => file, @@ -704,10 +713,13 @@ impl Uploader { }, }; - async_write_checksummed_data(tokio::fs::File::from_std(file), target_file).await + ( + async_write_checksummed_data(tokio::fs::File::from_std(file), target_file).await, + StorageFormat::CHECKSUMMED, + ) }, UploadSource::Path(source_path) => { - if let Err(e) = self + let result = if let Err(e) = self .file_system .rename_file(&source_path, &target_path) .await @@ -739,7 +751,9 @@ impl Uploader { } } else { Ok(()) - } + }; + + (result, StorageFormat::RAW) }, }; @@ -777,6 +791,7 @@ impl Uploader { .into_iter() .map(|(key, value)| (key.into(), value.into_proto())) .collect(), + storage_format: storage_format.into(), feature_flags: feature_flags .into_iter() .map( diff --git a/bd-client-common/src/file.rs b/bd-client-common/src/file.rs index 33c944a45..3884beea9 100644 --- a/bd-client-common/src/file.rs +++ b/bd-client-common/src/file.rs @@ -60,16 +60,6 @@ pub fn read_compressed_protobuf( Ok(T::parse_from_tokio_bytes(&decompressed_bytes.into())?) } -#[must_use] -pub fn is_zlib_data(bytes: &[u8]) -> bool { - if bytes.len() < 2 || bytes[0] != 0x78 { - return false; - } - - let cmf_flg = u16::from(bytes[0]) << 8 | u16::from(bytes[1]); - cmf_flg % 31 == 0 -} - /// Writes the data and appends a CRC checksum at the end of the slice. The checksum is a 4-byte /// little-endian CRC32 checksum of the data. #[must_use] diff --git a/bd-client-common/src/file_test.rs b/bd-client-common/src/file_test.rs index 9c3834400..0db4b3b86 100644 --- a/bd-client-common/src/file_test.rs +++ b/bd-client-common/src/file_test.rs @@ -38,14 +38,3 @@ fn invalid_checksum() { "crc mismatch" ); } - -#[test] -fn identifies_zlib_data() { - let compressed = super::write_compressed(b"hello").unwrap(); - assert!(super::is_zlib_data(&compressed)); -} - -#[test] -fn identifies_non_zlib_data() { - assert!(!super::is_zlib_data(b"not-zlib")); -} diff --git a/bd-proto/src/protos/client/artifact.rs b/bd-proto/src/protos/client/artifact.rs index 4a0f9dd64..6d1a36e46 100644 --- a/bd-proto/src/protos/client/artifact.rs +++ b/bd-proto/src/protos/client/artifact.rs @@ -174,6 +174,8 @@ pub mod artifact_upload_index { pub feature_flags: ::std::vec::Vec, // @@protoc_insertion_point(field:bitdrift_public.protobuf.client.v1.ArtifactUploadIndex.Artifact.type_id) pub type_id: ::std::option::Option<::std::string::String>, + // @@protoc_insertion_point(field:bitdrift_public.protobuf.client.v1.ArtifactUploadIndex.Artifact.storage_format) + pub storage_format: ::protobuf::EnumOrUnknown, // special fields // @@protoc_insertion_point(special_field:bitdrift_public.protobuf.client.v1.ArtifactUploadIndex.Artifact.special_fields) pub special_fields: ::protobuf::SpecialFields, @@ -191,7 +193,7 @@ pub mod artifact_upload_index { } pub(in super) fn generated_message_descriptor_data() -> ::protobuf::reflect::GeneratedMessageDescriptorData { - let mut fields = ::std::vec::Vec::with_capacity(7); + let mut fields = ::std::vec::Vec::with_capacity(8); let mut oneofs = ::std::vec::Vec::with_capacity(0); fields.push(::protobuf::reflect::rt::v2::make_simpler_field_accessor::<_, _>( "name", @@ -228,6 +230,11 @@ pub mod artifact_upload_index { |m: &Artifact| { &m.type_id }, |m: &mut Artifact| { &mut m.type_id }, )); + fields.push(::protobuf::reflect::rt::v2::make_simpler_field_accessor::<_, _>( + "storage_format", + |m: &Artifact| { &m.storage_format }, + |m: &mut Artifact| { &mut m.storage_format }, + )); ::protobuf::reflect::GeneratedMessageDescriptorData::new_2::( "ArtifactUploadIndex.Artifact", fields, @@ -279,6 +286,9 @@ pub mod artifact_upload_index { 66 => { self.type_id = ::std::option::Option::Some(is.read_string()?); }, + 72 => { + self.storage_format = is.read_enum_or_unknown()?; + }, tag => { ::protobuf::rt::read_unknown_or_skip_group(tag, is, self.special_fields.mut_unknown_fields())?; }, @@ -318,6 +328,9 @@ pub mod artifact_upload_index { if let Some(v) = self.type_id.as_ref() { my_size += ::protobuf::rt::string_size(8, &v); } + if self.storage_format != ::protobuf::EnumOrUnknown::new(super::StorageFormat::CHECKSUMMED) { + my_size += ::protobuf::rt::int32_size(9, self.storage_format.value()); + } my_size += ::protobuf::rt::unknown_fields_size(self.special_fields.unknown_fields()); self.special_fields.cached_size().set(my_size as u32); my_size @@ -352,6 +365,9 @@ pub mod artifact_upload_index { if let Some(v) = self.type_id.as_ref() { os.write_string(8, v)?; } + if self.storage_format != ::protobuf::EnumOrUnknown::new(super::StorageFormat::CHECKSUMMED) { + os.write_enum(9, ::protobuf::EnumOrUnknown::value(&self.storage_format))?; + } os.write_unknown_fields(self.special_fields.unknown_fields())?; ::std::result::Result::Ok(()) } @@ -376,6 +392,7 @@ pub mod artifact_upload_index { self.session_id.clear(); self.feature_flags.clear(); self.type_id = ::std::option::Option::None; + self.storage_format = ::protobuf::EnumOrUnknown::new(super::StorageFormat::CHECKSUMMED); self.special_fields.clear(); } @@ -403,13 +420,75 @@ pub mod artifact_upload_index { } } +#[derive(Clone,Copy,PartialEq,Eq,Debug,Hash)] +// @@protoc_insertion_point(enum:bitdrift_public.protobuf.client.v1.StorageFormat) +pub enum StorageFormat { + // @@protoc_insertion_point(enum_value:bitdrift_public.protobuf.client.v1.StorageFormat.CHECKSUMMED) + CHECKSUMMED = 0, + // @@protoc_insertion_point(enum_value:bitdrift_public.protobuf.client.v1.StorageFormat.RAW) + RAW = 1, +} + +impl ::protobuf::Enum for StorageFormat { + const NAME: &'static str = "StorageFormat"; + + fn value(&self) -> i32 { + *self as i32 + } + + fn from_i32(value: i32) -> ::std::option::Option { + match value { + 0 => ::std::option::Option::Some(StorageFormat::CHECKSUMMED), + 1 => ::std::option::Option::Some(StorageFormat::RAW), + _ => ::std::option::Option::None + } + } + + fn from_str(str: &str) -> ::std::option::Option { + match str { + "CHECKSUMMED" => ::std::option::Option::Some(StorageFormat::CHECKSUMMED), + "RAW" => ::std::option::Option::Some(StorageFormat::RAW), + _ => ::std::option::Option::None + } + } + + const VALUES: &'static [StorageFormat] = &[ + StorageFormat::CHECKSUMMED, + StorageFormat::RAW, + ]; +} + +impl ::protobuf::EnumFull for StorageFormat { + fn enum_descriptor() -> ::protobuf::reflect::EnumDescriptor { + static descriptor: ::protobuf::rt::Lazy<::protobuf::reflect::EnumDescriptor> = ::protobuf::rt::Lazy::new(); + descriptor.get(|| file_descriptor().enum_by_package_relative_name("StorageFormat").unwrap()).clone() + } + + fn descriptor(&self) -> ::protobuf::reflect::EnumValueDescriptor { + let index = *self as usize; + Self::enum_descriptor().value_by_index(index) + } +} + +impl ::std::default::Default for StorageFormat { + fn default() -> Self { + StorageFormat::CHECKSUMMED + } +} + +impl StorageFormat { + fn generated_enum_descriptor_data() -> ::protobuf::reflect::GeneratedEnumDescriptorData { + ::protobuf::reflect::GeneratedEnumDescriptorData::new::("StorageFormat") + } +} + static file_descriptor_proto_data: &'static [u8] = b"\ \n1bitdrift_public/protobuf/client/v1/artifact.proto\x12\"bitdrift_publi\ c.protobuf.client.v1\x1a5bitdrift_public/protobuf/client/v1/feature_flag\ .proto\x1a1bitdrift_public/protobuf/logging/v1/payload.proto\x1a\x1fgoog\ - le/protobuf/timestamp.proto\"\xf5\x04\n\x13ArtifactUploadIndex\x12\\\n\ + le/protobuf/timestamp.proto\"\xcf\x05\n\x13ArtifactUploadIndex\x12\\\n\ \x08artifact\x18\x01\x20\x03(\x0b2@.bitdrift_public.protobuf.client.v1.A\ - rtifactUploadIndex.ArtifactR\x08artifact\x1a\xff\x03\n\x08Artifact\x12\ + rtifactUploadIndex.ArtifactR\x08artifact\x1a\xd9\x04\n\x08Artifact\x12\ \x12\n\x04name\x18\x01\x20\x01(\tR\x04name\x12.\n\x04time\x18\x02\x20\ \x01(\x0b2\x1a.google.protobuf.TimestampR\x04time\x12<\n\x1apending_inte\ nt_negotiation\x18\x03\x20\x01(\x08R\x18pendingIntentNegotiation\x12j\n\ @@ -418,9 +497,12 @@ static file_descriptor_proto_data: &'static [u8] = b"\ n_id\x18\x06\x20\x01(\tR\tsessionId\x12T\n\rfeature_flags\x18\x07\x20\ \x03(\x0b2/.bitdrift_public.protobuf.client.v1.FeatureFlagR\x0cfeatureFl\ ags\x12\x1c\n\x07type_id\x18\x08\x20\x01(\tH\0R\x06typeId\x88\x01\x01\ - \x1af\n\rMetadataEntry\x12\x10\n\x03key\x18\x01\x20\x01(\tR\x03key\x12?\ - \n\x05value\x18\x02\x20\x01(\x0b2).bitdrift_public.protobuf.logging.v1.D\ - ataR\x05value:\x028\x01B\n\n\x08_type_idb\x06proto3\ + \x12X\n\x0estorage_format\x18\t\x20\x01(\x0e21.bitdrift_public.protobuf.\ + client.v1.StorageFormatR\rstorageFormat\x1af\n\rMetadataEntry\x12\x10\n\ + \x03key\x18\x01\x20\x01(\tR\x03key\x12?\n\x05value\x18\x02\x20\x01(\x0b2\ + ).bitdrift_public.protobuf.logging.v1.DataR\x05value:\x028\x01B\n\n\x08_\ + type_id*)\n\rStorageFormat\x12\x0f\n\x0bCHECKSUMMED\x10\0\x12\x07\n\x03R\ + AW\x10\x01b\x06proto3\ "; /// `FileDescriptorProto` object which was a source for this generated file @@ -444,7 +526,8 @@ pub fn file_descriptor() -> &'static ::protobuf::reflect::FileDescriptor { let mut messages = ::std::vec::Vec::with_capacity(2); messages.push(ArtifactUploadIndex::generated_message_descriptor_data()); messages.push(artifact_upload_index::Artifact::generated_message_descriptor_data()); - let mut enums = ::std::vec::Vec::with_capacity(0); + let mut enums = ::std::vec::Vec::with_capacity(1); + enums.push(StorageFormat::generated_enum_descriptor_data()); ::protobuf::reflect::GeneratedFileDescriptor::new_generated( file_descriptor_proto(), deps, From 6d6b7b9eb78a966cbd0781500fb4611c8bdb418b Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Fri, 27 Feb 2026 14:09:57 -0800 Subject: [PATCH 29/32] avoid zlib parsing, add more docs --- api | 2 +- bd-artifact-upload/src/uploader.rs | 10 +- bd-state/README.md | 144 +++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 bd-state/README.md diff --git a/api b/api index 16e9aee54..4f817b044 160000 --- a/api +++ b/api @@ -1 +1 @@ -Subproject commit 16e9aee54dbe8317395b8ea34045dce9186d9940 +Subproject commit 4f817b04404a77f1787fed108558413329b87524 diff --git a/bd-artifact-upload/src/uploader.rs b/bd-artifact-upload/src/uploader.rs index 99e35145d..171f954ec 100644 --- a/bd-artifact-upload/src/uploader.rs +++ b/bd-artifact-upload/src/uploader.rs @@ -658,8 +658,14 @@ impl Uploader { feature_flags: Vec, mut persisted_tx: Option>>, ) { - // If we've reached our limit of entries, stop the entry currently being uploaded (the oldest - // one) to make space for the newer one. + // Previously we would always drop the oldest entry when we hit capacity, but for state + // snapshots this would result in us dropping uploads that we know we need to hydrate logs + // that were scheduled for uploads. To mitigate this we treat state snapshots differently + // and avoid dropping them when we hit capacity, which means that in the worst case if we have a + // lot of state snapshots we might fill up this queue and apply backpressure to the state + // snapshot producer, deferring the snapshot limit enforcement to the producer instead of the + // uploader. + // TODO(snowp): Consider also having a bound on the size of the files persisted to disk. // TODO(snowp): We should consider redoing how backpressure works for crash reports as well as // there are cases in which we drop reports. For now limit the backpressure mechanism to diff --git a/bd-state/README.md b/bd-state/README.md new file mode 100644 index 000000000..1ef87479c --- /dev/null +++ b/bd-state/README.md @@ -0,0 +1,144 @@ +# bd-state + +`bd-state` is the runtime state layer used by the logger pipeline. It wraps +`bd-resilient-kv::VersionedKVStore` and adds: + +- process-lifecycle semantics (capture previous process state, then clear selected scopes), +- change tracking (`last_change_micros`) for upload decisions, +- a simple async API for reads/writes, +- explicit snapshot rotation entry points used by `bd-logger`. + +This document focuses on how state is recorded, persisted, and uploaded with logs. + +## Related references + +- Journal/file format: `bd-resilient-kv/VERSIONED_FORMAT.md` +- Logger upload invariants: `bd-logger/AGENTS.md` +- Upload coordinator implementation: `bd-logger/src/state_upload.rs` + +## 1) State model and process lifecycle + +State is namespaced by `Scope` (`FeatureFlagExposure`, `GlobalState`, `System`) and keyed by +string. Values are protobuf `StateValue`s. + +On persistent startup (`Store::persistent`): + +1. `VersionedKVStore` is opened and replayed into an in-memory `ScopedMaps` cache. +2. That cache is cloned into `previous_state` (for crash/context reporting). +3. `FeatureFlagExposure` and `GlobalState` are cleared for the new process. +4. `System` scope is not blanket-cleared by this startup path. + +If persistent initialization fails, `persistent_or_fallback` falls back to in-memory storage and +returns `fallback_occurred = true`. + +## 2) How writes are recorded + +`Store` delegates to `VersionedKVStore` and tracks effective state mutations: + +- `insert(scope, key, value)`: + - no-op if new value equals existing value, + - otherwise appends a journal entry and updates cache. +- `remove(scope, key)`: + - appends a tombstone entry (empty `StateValue`) if key exists. +- `extend(scope, entries)`: + - batch write path in underlying store; currently treated as changed when non-empty. +- `clear(scope)`: + - iterates keys in that scope and removes each one. + +For real mutations, `Store` updates `last_change_micros` using `fetch_max`, so the timestamp is +monotonic non-decreasing even with concurrent writers. + +## 3) On-disk persistence format and layout + +When persistent mode is enabled, files live under the configured state directory. + +Active journal: + +- `state.jrn.` (e.g. `state.jrn.0`) +- memory-mapped for fast append and replay +- not compressed + +Archived snapshots (on rotation): + +- `snapshots/state.jrn.g.t.zz` +- zlib-compressed archived journal of the old generation + +Journal entry framing and semantics are defined in `VERSIONED_FORMAT.md`: + +- each entry stores scope + key + timestamp + protobuf payload + CRC, +- timestamp is per-entry write time (microseconds), +- tombstones represent deletions, +- compacted journals preserve original entry timestamps. + +Important timestamp nuance: snapshot filename timestamp is the **rotation time marker** for that +archived file, while each entry inside the file keeps its own original write timestamp. + +## 4) Rotation, compaction, and retention + +Rotation can happen automatically (high-water mark/capacity pressure) or manually +(`Store::rotate_journal` from `bd-state`): + +1. create a new active generation, +2. rewrite compacted live state into the new journal (preserving entry timestamps), +3. archive+compress the old generation to `snapshots/*.zz`, +4. run snapshot cleanup policy, +5. delete the old uncompressed generation file. + +Snapshot creation is retention-aware: + +- controlled by `RetentionRegistry` and `state.max_snapshot_count`, +- snapshotting is disabled if max snapshot count is `0`, +- if no retention handle requests data, rotation may skip snapshot creation, +- cleanup removes snapshots older than required retention and enforces max count safety cap. + +## 5) How snapshots are uploaded with logs + +State snapshots and logs are separate artifact streams; coordination lives in `bd-logger`. + +### Producer side (log batch flush paths) + +`BatchBuilder` tracks `(oldest_micros, newest_micros)` incrementally while logs are added. +Before consuming a batch (`take()`), uploader paths call: + +`StateUploadHandle::notify_upload_needed(oldest, newest)` + +This currently happens in: + +- continuous flush (`flush_current_batch`), +- trigger/complete flush (`flush_batch`). + +`StreamedBufferUpload::start` currently has a TODO for state upload integration. + +`notify_upload_needed` is non-blocking: + +- merges ranges in a shared accumulator, +- best-effort wakes a single worker via a capacity-1 channel, +- does not block log upload path. + +### Worker side (`StateUploadWorker`) + +Single background worker owns all decisions and retries: + +1. drains/coalesces pending range, persists it to key-value key `state_upload.pending_range.1`, +2. computes preflight decision: + - if `last_change_micros == 0`: skip, + - else if snapshots exist in requested range: upload them oldest-first, + - else create an on-demand snapshot via `state_store.rotate_journal()` (subject to cooldown), +3. enqueues each snapshot through `bd-artifact-upload` as `UploadSource::Path(...)`, +4. waits for persistence ack from artifact queue. + +Queue semantics are move-based: + +- success: snapshot file is moved out of `{state_store_path}/snapshots/` into artifact upload + storage, +- enqueue failure: source file remains in `{state_store_path}/snapshots/`, so later retries can + re-attempt. + +The worker keeps pending coverage on backpressure/errors and retries periodically; pending range is +recovered on startup so upload intent survives process restart. + +## 6) Why this matches server-side state hydration + +The server reconstructs active state for log time `T` from per-entry timestamps in snapshots +(entries with write timestamp `<= T`), not by snapshot filename time alone. The client therefore +only needs to ensure relevant snapshot files are eventually uploaded along with log traffic. From 34462fedaff33bd35a44e995991a5bc8faf45afc Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Sat, 28 Feb 2026 08:40:36 -0800 Subject: [PATCH 30/32] clarify more --- bd-logger/AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bd-logger/AGENTS.md b/bd-logger/AGENTS.md index 02ba6b93c..640cb4711 100644 --- a/bd-logger/AGENTS.md +++ b/bd-logger/AGENTS.md @@ -44,6 +44,10 @@ When the worker receives a batch's timestamp range `[oldest, newest]`, it evalua ### Snapshot Cooldown +Typically snapshots are created in response to the state journal filling up due to state updates, but +but when logs are streamed we may need to periodically create snapshots in order to upload state changes. +The state uploader may trigger manual snapshot creation by calling into the state store. + Creating a snapshot on every batch flush during high-volume streaming is wasteful. The worker tracks `last_snapshot_creation_micros` and will not create a new snapshot if one was created within `snapshot_creation_interval_micros` (a runtime-configurable value). During cooldown, the From 83b30ab88f41e05f4da4a017ec61f4014d88dda4 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Sat, 28 Feb 2026 08:47:48 -0800 Subject: [PATCH 31/32] fmt nightly --- bd-log/src/rate_limit_log.rs | 5 ++++- bd-test-helpers/src/filter.rs | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bd-log/src/rate_limit_log.rs b/bd-log/src/rate_limit_log.rs index f6840d7a5..fc17a3f3b 100644 --- a/bd-log/src/rate_limit_log.rs +++ b/bd-log/src/rate_limit_log.rs @@ -89,4 +89,7 @@ macro_rules! log_every { }; } -pub use {error_every, warn_every, warn_every_debug_assert, warn_every_debug_panic}; +pub use error_every; +pub use warn_every; +pub use warn_every_debug_assert; +pub use warn_every_debug_panic; diff --git a/bd-test-helpers/src/filter.rs b/bd-test-helpers/src/filter.rs index 50483ef39..e4e912706 100644 --- a/bd-test-helpers/src/filter.rs +++ b/bd-test-helpers/src/filter.rs @@ -79,7 +79,11 @@ pub mod macros { }; } - pub use {capture_field, field_value, regex_match_and_substitute_field, remove_field, set_field}; + pub use capture_field; + pub use field_value; + pub use regex_match_and_substitute_field; + pub use remove_field; + pub use set_field; } #[must_use] From 8ee0aaeb4ce100957fbcb7fcb30f03df3fd07c21 Mon Sep 17 00:00:00 2001 From: Snow Pettersen Date: Sun, 1 Mar 2026 20:40:42 -0800 Subject: [PATCH 32/32] fix rotation return bug, rework deduping repeat rotations and clarify state upload retention ownership --- bd-logger/AGENTS.md | 27 ++- bd-logger/src/state_upload.rs | 64 ++++--- bd-logger/src/state_upload_test.rs | 169 ++++++++++++++++-- bd-resilient-kv/VERSIONED_FORMAT.md | 12 ++ .../src/tests/versioned_kv_store_test.rs | 42 ++--- .../tests/versioned_recovery_error_test.rs | 2 +- .../src/versioned_kv_journal/store.rs | 10 +- bd-state/README.md | 19 +- bd-state/src/lib.rs | 2 +- 9 files changed, 274 insertions(+), 73 deletions(-) diff --git a/bd-logger/AGENTS.md b/bd-logger/AGENTS.md index 640cb4711..ec026d579 100644 --- a/bd-logger/AGENTS.md +++ b/bd-logger/AGENTS.md @@ -36,11 +36,15 @@ The handle and worker are created together via `StateUploadHandle::new`. When the worker receives a batch's timestamp range `[oldest, newest]`, it evaluates in order: -1. **No state changes ever recorded** (`last_change_micros == 0`) → skip. Nothing to upload. -2. **Snapshot files exist** in `{state_store_path}/snapshots/` → upload snapshots whose filename +1. **Snapshot files exist** in `{state_store_path}/snapshots/` → upload snapshots whose filename timestamp is within the current pending log range `[oldest, newest]` (oldest-first). -3. **No snapshot files exist but state changed** (`last_change_micros > 0`) → create one on-demand via - `state_store.rotate_journal()`, subject to a cooldown (see below). +2. **No in-range snapshots found** → decide whether to create one on-demand via + `state_store.rotate_journal()`, subject to cooldown and the in-process + `last_change_at_rotation` optimization (skip if no changes since last worker-triggered + rotation). + +Snapshot discovery runs before on-demand creation checks, so persisted pending coverage can match +existing on-disk snapshots across restarts. ### Snapshot Cooldown @@ -73,6 +77,21 @@ The worker persists pending coverage to key-value storage (`state_upload.pending it drains/merges producer requests, and clears it after successful processing. On startup, it reads this key and immediately processes recovered pending work before entering the normal wake loop. +During successful upload progress, the worker tightens pending coverage by advancing +`pending_range.oldest_micros` after each snapshot enqueue persistence ack. This narrowed coverage is +persisted immediately so restart resumes with the same tighter lower bound. + +### Retention Ownership + +Retention is split by responsibility: + +- **Buffer consumer retention handles** are the source of truth for logs that may still be uploaded + in the future. +- **State upload worker retention handle** protects only the uploader's current pending coverage. + +The worker sets its retention handle from `pending_range.oldest_micros` while pending work exists +and uses `RETENTION_NONE` when pending work is empty. + ### BatchBuilder Timestamp Tracking `BatchBuilder` (in `consumer.rs`) tracks `oldest_micros` and `newest_micros` incrementally as diff --git a/bd-logger/src/state_upload.rs b/bd-logger/src/state_upload.rs index 423129cf6..b2b44b9ab 100644 --- a/bd-logger/src/state_upload.rs +++ b/bd-logger/src/state_upload.rs @@ -151,6 +151,7 @@ impl StateUploadHandle { let worker = StateUploadWorker { last_snapshot_creation_micros: AtomicU64::new(0), snapshot_creation_interval_micros: u64::from(snapshot_creation_interval_ms) * 1000, + last_change_at_rotation: None, state_store_path, store, retention_handle, @@ -218,6 +219,10 @@ pub struct StateUploadWorker { last_snapshot_creation_micros: AtomicU64, /// Minimum interval between snapshot creations (microseconds). snapshot_creation_interval_micros: u64, + /// The value of `last_change_micros` at the time of the most recent worker-initiated rotation. + /// Used to avoid redundant rotations when nothing has changed since the last one. `None` means + /// the worker has not yet performed a rotation, so the first attempt always proceeds. + last_change_at_rotation: Option, state_store_path: Option, store: Arc, @@ -261,8 +266,8 @@ impl StateUploadWorker { /// Runs the worker event loop, processing upload requests until the channel is closed. pub async fn run(mut self) { log::debug!("state upload worker started"); - self.refresh_retention_handle(); self.pending_range = self.read_persisted_pending_range(); + self.refresh_retention_handle(); if self.pending_range.is_some() { self.process_pending().await; } @@ -336,7 +341,7 @@ impl StateUploadWorker { // 3) For each ready snapshot, enqueue and wait for persistence ack. // 4) On success, count the snapshot upload and continue; on failure, keep pending work for retry. async fn process_upload( - &self, + &mut self, batch_oldest_micros: u64, batch_newest_micros: u64, ) -> ProcessResult { @@ -386,6 +391,7 @@ impl StateUploadWorker { snapshot_ref.timestamp_micros ); self.stats.snapshots_uploaded.inc(); + self.advance_pending_oldest_micros(snapshot_ref.timestamp_micros); }, Ok(Err(e)) => { log::warn!("failed to persist state snapshot upload entry: {e}"); @@ -415,21 +421,27 @@ impl StateUploadWorker { } async fn plan_upload_attempt( - &self, + &mut self, batch_oldest_micros: u64, batch_newest_micros: u64, last_change: u64, ) -> UploadPreflight { - if last_change == 0 { - log::debug!("state upload: last_change=0, skipping (batch_newest={batch_newest_micros})"); - return UploadPreflight::Skipped; - } - + // Check for existing snapshots first — on restart, persisted pending ranges may reference + // snapshots created by the previous process that are still on disk. let snapshots = self.find_snapshots_in_range(batch_oldest_micros, batch_newest_micros); if !snapshots.is_empty() { return UploadPreflight::Ready(snapshots); } + // Skip rotation if we've already rotated and nothing has changed since. This is purely an + // in-process optimization — `last_change_at_rotation` is not persisted across restarts. + if self.last_change_at_rotation == Some(last_change) { + log::debug!( + "state upload: no changes since last rotation (last_change={last_change}), skipping" + ); + return UploadPreflight::Skipped; + } + let now_micros = self .time_provider .now() @@ -445,12 +457,18 @@ impl StateUploadWorker { return UploadPreflight::DeferredCooldown; } - self - .create_snapshot_if_needed(last_change) - .await - .map_or(UploadPreflight::DeferredCooldown, |snapshot| { - UploadPreflight::Ready(vec![snapshot]) - }) + let Some(snapshot) = self.create_snapshot_if_needed(last_change).await else { + // No snapshot was created (no state store, or rotation produced nothing). Record the attempt + // so we don't retry until the state store records a new change. + self.last_change_at_rotation = Some(last_change); + return UploadPreflight::Skipped; + }; + + // Record that we've rotated at this last_change value, so we don't redundantly rotate again + // until the state store records a new change. + self.last_change_at_rotation = Some(last_change); + + UploadPreflight::Ready(vec![snapshot]) } fn find_snapshots_in_range( @@ -581,16 +599,20 @@ impl StateUploadWorker { let Some(handle) = &self.retention_handle else { return; }; - let oldest_snapshot = self - .find_all_snapshots() - .into_iter() - .map(|s| s.timestamp_micros) - .min(); - match oldest_snapshot { - Some(oldest) => handle.update_retention_micros(oldest), + match self.pending_range { + Some(range) => handle.update_retention_micros(range.oldest_micros), None => handle.update_retention_micros(RetentionHandle::RETENTION_NONE), } } + + fn advance_pending_oldest_micros(&mut self, uploaded_snapshot_micros: u64) { + let Some(range) = &mut self.pending_range else { + return; + }; + range.oldest_micros = range.oldest_micros.max(uploaded_snapshot_micros); + self.persist_pending_range(); + self.refresh_retention_handle(); + } } fn pending_range_to_proto(range: PendingRange) -> StateSnapshotRange { diff --git a/bd-logger/src/state_upload_test.rs b/bd-logger/src/state_upload_test.rs index ec7553537..2832c2515 100644 --- a/bd-logger/src/state_upload_test.rs +++ b/bd-logger/src/state_upload_test.rs @@ -8,7 +8,7 @@ #![allow(clippy::unwrap_used)] use super::*; -use bd_runtime::runtime::{ConfigLoader, FeatureFlag as _}; +use bd_runtime::runtime::{ConfigLoader, FeatureFlag as _, IntWatch}; use bd_test_helpers::session::in_memory_store; use bd_time::{SystemTimeProvider, TestTimeProvider}; use time::OffsetDateTime; @@ -264,6 +264,123 @@ fn pending_range_merge_widens_bounds() { assert_eq!(pending.newest_micros, 250); } +#[tokio::test] +async fn refresh_retention_handle_uses_pending_range_oldest() { + let store = in_memory_store(); + let stats = bd_client_stats_store::Collector::default().scope("test"); + let retention_registry = Arc::new(bd_state::RetentionRegistry::new(IntWatch::new_for_testing( + 10, + ))); + let (_handle, mut worker) = StateUploadHandle::new( + None, + store, + Some(retention_registry.clone()), + None, + 0, + Arc::new(SystemTimeProvider {}), + Arc::new(bd_artifact_upload::MockClient::new()), + &stats, + ) + .await; + + worker.pending_range = Some(PendingRange { + oldest_micros: 123, + newest_micros: 999, + }); + worker.refresh_retention_handle(); + assert_eq!( + retention_registry.min_retention_timestamp().await, + Some(123) + ); + + worker.pending_range = None; + worker.refresh_retention_handle(); + assert_eq!(retention_registry.min_retention_timestamp().await, None); +} + +#[tokio::test] +async fn process_upload_advances_pending_oldest_across_multiple_snapshots() { + let setup = Setup::new().await; + let first_snapshot_ts = setup.create_snapshot_after_state_change("test_key_1").await; + let second_snapshot_ts = setup.create_snapshot_after_state_change("test_key_2").await; + + let mut mock_client = bd_artifact_upload::MockClient::new(); + mock_client + .expect_enqueue_upload() + .times(2) + .returning(|_, _, _, _, _, _, persisted_tx| { + if let Some(tx) = persisted_tx { + let _ = tx.send(Ok(())); + } + Ok(Uuid::new_v4()) + }); + + let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; + worker.pending_range = Some(PendingRange { + oldest_micros: first_snapshot_ts, + newest_micros: second_snapshot_ts, + }); + + let result = worker + .process_upload(first_snapshot_ts, second_snapshot_ts) + .await; + assert_eq!(result, ProcessResult::Progress); + assert_eq!( + worker.pending_range.map(|r| r.oldest_micros), + Some(second_snapshot_ts) + ); +} + +#[tokio::test] +async fn older_incoming_range_reexpands_pending_after_progress() { + let setup = Setup::new().await; + let first_snapshot_ts = setup.create_snapshot_after_state_change("test_key_1").await; + let second_snapshot_ts = setup.create_snapshot_after_state_change("test_key_2").await; + + let mut mock_client = bd_artifact_upload::MockClient::new(); + mock_client + .expect_enqueue_upload() + .times(2) + .returning(|_, _, _, _, _, _, persisted_tx| { + if let Some(tx) = persisted_tx { + let _ = tx.send(Ok(())); + } + Ok(Uuid::new_v4()) + }); + + let stats = bd_client_stats_store::Collector::default().scope("test"); + let (handle, mut worker) = StateUploadHandle::new( + Some(setup.state_dir.clone()), + setup.store.clone(), + Some(setup.retention_registry.clone()), + Some(setup.state_store.clone()), + 0, + setup.time_provider.clone(), + Arc::new(mock_client), + &stats, + ) + .await; + worker.pending_range = Some(PendingRange { + oldest_micros: first_snapshot_ts, + newest_micros: second_snapshot_ts, + }); + let result = worker + .process_upload(first_snapshot_ts, second_snapshot_ts) + .await; + assert_eq!(result, ProcessResult::Progress); + assert_eq!( + worker.pending_range.map(|r| r.oldest_micros), + Some(second_snapshot_ts) + ); + + handle.notify_upload_needed(first_snapshot_ts, second_snapshot_ts); + worker.drain_pending_accumulator(); + assert_eq!( + worker.pending_range.map(|r| r.oldest_micros), + Some(first_snapshot_ts) + ); +} + #[tokio::test] async fn notify_upload_needed_keeps_range_when_wake_channel_is_full() { let store = in_memory_store(); @@ -356,7 +473,7 @@ async fn persisted_ack_error_keeps_pending_range() { Ok(Uuid::new_v4()) }); - let worker = setup.worker_with_client(0, Arc::new(mock_client)).await; + let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; let result = worker.process_upload(1, snapshot_ts).await; assert_eq!(result, ProcessResult::Error); @@ -373,7 +490,7 @@ async fn persisted_ack_channel_drop_keeps_pending_range() { .times(1) .returning(|_, _, _, _, _, _, _| Ok(Uuid::new_v4())); - let worker = setup.worker_with_client(0, Arc::new(mock_client)).await; + let mut worker = setup.worker_with_client(0, Arc::new(mock_client)).await; let result = worker.process_upload(1, snapshot_ts).await; assert_eq!(result, ProcessResult::Error); @@ -410,7 +527,7 @@ async fn successful_enqueue_ack_clears_pending() { async fn plan_upload_attempt_skips_when_no_state_changes_or_no_store() { let store = in_memory_store(); let stats = bd_client_stats_store::Collector::default().scope("test"); - let (_handle, worker) = StateUploadHandle::new( + let (_handle, mut worker) = StateUploadHandle::new( None, store, None, @@ -422,14 +539,23 @@ async fn plan_upload_attempt_skips_when_no_state_changes_or_no_store() { ) .await; + // First call with last_change=0: no prior rotation recorded, so we attempt rotation. No state + // store configured, so create_snapshot_if_needed returns None → Skipped. The attempt records + // last_change_at_rotation = Some(0). let result = worker.plan_upload_attempt(0, 20, 0).await; assert!(matches!(result, UploadPreflight::Skipped)); + // Second call with last_change=0: we've already rotated at 0, nothing changed → Skipped. + let result = worker.plan_upload_attempt(0, 20, 0).await; + assert!(matches!(result, UploadPreflight::Skipped)); + + // Calls with different last_change values: no snapshots found, no state store → + // create_snapshot_if_needed returns None → Skipped. let result = worker.plan_upload_attempt(0, 20, 15).await; - assert!(matches!(result, UploadPreflight::DeferredCooldown)); + assert!(matches!(result, UploadPreflight::Skipped)); let result = worker.plan_upload_attempt(0, 20, 9).await; - assert!(matches!(result, UploadPreflight::DeferredCooldown)); + assert!(matches!(result, UploadPreflight::Skipped)); } #[tokio::test] @@ -462,7 +588,7 @@ async fn plan_upload_attempt_returns_ready_for_in_range_snapshots() { let setup = Setup::new().await; let _first_snapshot_ts = setup.create_snapshot_after_state_change("test_key_1").await; let second_snapshot_ts = setup.create_snapshot_after_state_change("test_key_2").await; - let worker = setup + let mut worker = setup .worker_with_client(0, Arc::new(bd_artifact_upload::MockClient::new())) .await; @@ -486,7 +612,7 @@ async fn plan_upload_attempt_filters_snapshots_to_pending_range() { let setup = Setup::new().await; let first_snapshot_ts = setup.create_snapshot_after_state_change("test_key_1").await; let second_snapshot_ts = setup.create_snapshot_after_state_change("test_key_2").await; - let worker = setup + let mut worker = setup .worker_with_client(0, Arc::new(bd_artifact_upload::MockClient::new())) .await; @@ -504,9 +630,12 @@ async fn plan_upload_attempt_filters_snapshots_to_pending_range() { } #[tokio::test] -async fn run_processes_persisted_pending_range_on_startup() { +async fn restart_with_zero_last_change_uploads_existing_snapshot() { + // Simulate pre-restart: create a snapshot and persist a pending range. let setup = Setup::new().await; - let snapshot_ts = setup.create_snapshot_after_state_change("startup").await; + let snapshot_ts = setup + .create_snapshot_after_state_change("pre_restart") + .await; setup.store.set( &PENDING_UPLOAD_RANGE_KEY, &pending_range_to_proto(PendingRange { @@ -515,6 +644,19 @@ async fn run_processes_persisted_pending_range_on_startup() { }), ); + // Create a fresh in-memory state store to simulate a restart where last_change_micros is 0. + // In production this happens when the previous process only wrote to the System scope (not + // cleared on restart) and ephemeral scopes were already empty. + let stats = bd_client_stats_store::Collector::default().scope("test"); + let restart_store = Arc::new(bd_state::Store::in_memory( + setup.time_provider.clone(), + None, + &ConfigLoader::new(setup.state_dir.as_path()), + &stats, + )); + assert_eq!(restart_store.last_change_micros(), 0); + + // The worker should find the snapshot on disk and upload it despite last_change being 0. let mut mock_client = bd_artifact_upload::MockClient::new(); mock_client .expect_enqueue_upload() @@ -526,12 +668,11 @@ async fn run_processes_persisted_pending_range_on_startup() { Ok(Uuid::new_v4()) }); - let stats = bd_client_stats_store::Collector::default().scope("test"); let (handle, worker) = StateUploadHandle::new( Some(setup.state_dir.clone()), setup.store.clone(), Some(setup.retention_registry.clone()), - Some(setup.state_store.clone()), + Some(restart_store), 0, setup.time_provider.clone(), Arc::new(mock_client), @@ -542,11 +683,13 @@ async fn run_processes_persisted_pending_range_on_startup() { drop(handle); worker.run().await; + // Pending range should be cleared after successful upload. assert!( setup .store .get(&PENDING_UPLOAD_RANGE_KEY) .and_then(|proto| pending_range_from_proto(&proto)) - .is_none() + .is_none(), + "pending range should be cleared after restart upload" ); } diff --git a/bd-resilient-kv/VERSIONED_FORMAT.md b/bd-resilient-kv/VERSIONED_FORMAT.md index aa684ad49..9373fbcee 100644 --- a/bd-resilient-kv/VERSIONED_FORMAT.md +++ b/bd-resilient-kv/VERSIONED_FORMAT.md @@ -255,11 +255,23 @@ required by downstream consumers. - Snapshot creation is gated by the **minimum retention timestamp**. If no handles are registered, snapshots are skipped. +- A journal rotation may therefore complete without producing an archived snapshot file when + retention does not require one. - Snapshots older than the minimum retention timestamp are eligible for deletion. - While any handle is pending, cleanup keeps all snapshots (minimum retention treated as `0`). - A **max snapshot count** safety cap bounds the number of retained snapshots unless retention requires keeping everything. +### Rotation Result Contract + +Rotation always creates a new active journal generation, but snapshot output is conditional: + +- if retention requires a snapshot, rotation returns a snapshot path and the archived `.zz` file is + created; +- if retention does not require a snapshot, rotation returns no snapshot path. + +Callers must treat snapshot production as optional and handle the no-snapshot case explicitly. + ### Runtime Controls - `state.max_snapshot_count=0` disables snapshotting entirely. This is intended as a diff --git a/bd-resilient-kv/src/tests/versioned_kv_store_test.rs b/bd-resilient-kv/src/tests/versioned_kv_store_test.rs index 836c264bc..1150e34c8 100644 --- a/bd-resilient-kv/src/tests/versioned_kv_store_test.rs +++ b/bd-resilient-kv/src/tests/versioned_kv_store_test.rs @@ -785,7 +785,7 @@ async fn test_manual_rotation() -> anyhow::Result<()> { let rotation = setup.store.rotate_journal().await?; // Verify archived file exists (compressed) - assert!(rotation.snapshot_path.exists()); + assert!(rotation.snapshot_path.as_ref().unwrap().exists()); // Verify active journal still works let (ts3, _) = setup @@ -815,7 +815,7 @@ async fn test_manual_rotation() -> anyhow::Result<()> { // Decompress the archive and load it as a Store to verify that it contains the old state. let snapshot_store = setup - .make_store_from_snapshot_file(&rotation.snapshot_path) + .make_store_from_snapshot_file(rotation.snapshot_path.as_ref().unwrap()) .await?; assert_eq!( snapshot_store.get(Scope::FeatureFlagExposure, "key1"), @@ -993,7 +993,7 @@ async fn test_multiple_rotations() -> anyhow::Result<()> { .insert(Scope::FeatureFlagExposure, key, value) .await?; let rotation = setup.store.rotate_journal().await?; - snapshot_paths.push(rotation.snapshot_path.clone()); + snapshot_paths.push(rotation.snapshot_path.clone().unwrap()); } // Verify all compressed archives exist @@ -1049,11 +1049,10 @@ async fn test_rotation_with_retention_registry() -> anyhow::Result<()> { // Rotate WITHOUT any retention handles - snapshot should NOT be created let rotation1 = store.rotate_journal().await?; - let snapshot_path1 = rotation1.snapshot_path; - // Snapshot file should NOT exist because no handles require it + // Snapshot should NOT be returned because no handles require it assert!( - !snapshot_path1.exists(), + rotation1.snapshot_path.is_none(), "Snapshot should not be created when no retention handles exist" ); @@ -1076,7 +1075,7 @@ async fn test_rotation_with_retention_registry() -> anyhow::Result<()> { time_provider.advance(1.seconds()); let rotation2 = store.rotate_journal().await?; - let snapshot_path2 = rotation2.snapshot_path; + let snapshot_path2 = rotation2.snapshot_path.unwrap(); // Snapshot file SHOULD exist because handle requires retention assert!( @@ -1102,11 +1101,10 @@ async fn test_rotation_with_retention_registry() -> anyhow::Result<()> { time_provider.advance(1.seconds()); let rotation3 = store.rotate_journal().await?; - let snapshot_path3 = rotation3.snapshot_path; // After handle is dropped, snapshot should not be created assert!( - !snapshot_path3.exists(), + rotation3.snapshot_path.is_none(), "Snapshot should not be created after handle is dropped" ); @@ -1150,38 +1148,26 @@ async fn test_multiple_rotations_with_same_timestamp() -> anyhow::Result<()> { // Perform first rotation let rotation1 = store.rotate_journal().await?; - assert!( - rotation1.snapshot_path.exists(), - "First rotation should create snapshot" - ); + let snapshot_path1 = rotation1.snapshot_path.unwrap(); // Perform second rotation WITHOUT inserting new data // This means both rotations will have the same max timestamp let rotation2 = store.rotate_journal().await?; - assert!( - rotation2.snapshot_path.exists(), - "Second rotation should create snapshot" - ); + let snapshot_path2 = rotation2.snapshot_path.unwrap(); // Verify both snapshots exist with different filenames (due to different generations) - assert!( - rotation1.snapshot_path.exists(), - "First snapshot should still exist" - ); - assert!( - rotation2.snapshot_path.exists(), - "Second snapshot should exist" - ); + assert!(snapshot_path1.exists(), "First snapshot should still exist"); + assert!(snapshot_path2.exists(), "Second snapshot should exist"); // Verify the paths are different (different generations prevent collision) assert_ne!( - rotation1.snapshot_path, rotation2.snapshot_path, + snapshot_path1, snapshot_path2, "Snapshots should have different paths despite same timestamp" ); // Verify we can read both snapshots (they should be different files) - let snapshot1_data = std::fs::read(&rotation1.snapshot_path)?; - let snapshot2_data = std::fs::read(&rotation2.snapshot_path)?; + let snapshot1_data = std::fs::read(&snapshot_path1)?; + let snapshot2_data = std::fs::read(&snapshot_path2)?; // The files should exist and be valid assert!( diff --git a/bd-resilient-kv/src/tests/versioned_recovery_error_test.rs b/bd-resilient-kv/src/tests/versioned_recovery_error_test.rs index 2c217503c..62558251d 100644 --- a/bd-resilient-kv/src/tests/versioned_recovery_error_test.rs +++ b/bd-resilient-kv/src/tests/versioned_recovery_error_test.rs @@ -138,7 +138,7 @@ async fn test_recovery_with_deletions() -> anyhow::Result<()> { let rotation = store.rotate_journal().await?; // Read the snapshot - let compressed_data = std::fs::read(&rotation.snapshot_path)?; + let compressed_data = std::fs::read(rotation.snapshot_path.as_ref().unwrap())?; let decompressed_data = decompress_zlib(&compressed_data)?; // Use u64::MAX as snapshot timestamp since we're only checking the latest state diff --git a/bd-resilient-kv/src/versioned_kv_journal/store.rs b/bd-resilient-kv/src/versioned_kv_journal/store.rs index 356c569b4..de6cab215 100644 --- a/bd-resilient-kv/src/versioned_kv_journal/store.rs +++ b/bd-resilient-kv/src/versioned_kv_journal/store.rs @@ -114,7 +114,9 @@ impl From for DataLoss { pub struct Rotation { pub new_journal_path: PathBuf, pub old_journal_path: PathBuf, - pub snapshot_path: PathBuf, + /// Path to the snapshot file, if one was created during rotation. `None` when snapshotting is + /// disabled or no retention handle requires the snapshot. + pub snapshot_path: Option, } /// Result of opening a journal file, containing the journal, initial state, and data loss info. @@ -837,7 +839,11 @@ impl PersistentStore { Ok(Rotation { new_journal_path, old_journal_path, - snapshot_path: archived_path, + snapshot_path: if should_create_snapshot { + Some(archived_path) + } else { + None + }, }) } } diff --git a/bd-state/README.md b/bd-state/README.md index 1ef87479c..382822443 100644 --- a/bd-state/README.md +++ b/bd-state/README.md @@ -91,6 +91,9 @@ Snapshot creation is retention-aware: - if no retention handle requests data, rotation may skip snapshot creation, - cleanup removes snapshots older than required retention and enforces max count safety cap. +`Store::rotate_journal()` returns `Some(snapshot_path)` when rotation produces an archived snapshot +file and returns `None` when rotation completes without archived snapshot output. + ## 5) How snapshots are uploaded with logs State snapshots and logs are separate artifact streams; coordination lives in `bd-logger`. @@ -121,12 +124,22 @@ Single background worker owns all decisions and retries: 1. drains/coalesces pending range, persists it to key-value key `state_upload.pending_range.1`, 2. computes preflight decision: - - if `last_change_micros == 0`: skip, - - else if snapshots exist in requested range: upload them oldest-first, - - else create an on-demand snapshot via `state_store.rotate_journal()` (subject to cooldown), + - if snapshots exist in requested range: upload them oldest-first, + - else create an on-demand snapshot via `state_store.rotate_journal()` (subject to cooldown and + in-process duplicate-rotation checks), 3. enqueues each snapshot through `bd-artifact-upload` as `UploadSource::Path(...)`, 4. waits for persistence ack from artifact queue. +Retention ownership is split: + +- buffer consumer retention handles represent logs that may still be uploaded in the future, +- uploader retention handle represents only uploader pending coverage. + +The uploader updates its retention from `pending_range.oldest_micros` while pending work exists and +sets `RETENTION_NONE` when pending work is empty. As uploads succeed, it tightens +`pending_range.oldest_micros` and persists the updated range so restart resumes with the same +coverage. + Queue semantics are move-based: - success: snapshot file is moved out of `{state_store_path}/snapshots/` into artifact upload diff --git a/bd-state/src/lib.rs b/bd-state/src/lib.rs index 9bf00d7eb..9d6e68996 100644 --- a/bd-state/src/lib.rs +++ b/bd-state/src/lib.rs @@ -706,7 +706,7 @@ impl Store { pub async fn rotate_journal(&self) -> Option { let mut locked = self.inner.write().await; match locked.rotate_journal().await { - Ok(rotation) => Some(rotation.snapshot_path), + Ok(rotation) => rotation.snapshot_path, Err(e) => { log::debug!("Failed to rotate journal for snapshot: {e}"); None