diff --git a/docs/rust_data_daemon_development.md b/docs/rust_data_daemon_development.md
index d4d792a6..be75f789 100644
--- a/docs/rust_data_daemon_development.md
+++ b/docs/rust_data_daemon_development.md
@@ -41,7 +41,7 @@ flowchart LR
DISP --> ACT["per-trace actors"]
ACT -->|fire-and-forget| TW["trace_writer
batched DB write-behind"]
ACT -->|fire-and-forget| JW["json_writer (IO thread)"]
- ACT -->|spawn_blocking| FF["ffmpeg chunk encode"]
+ ACT -->|async subprocess| FF["ffmpeg chunk encode"]
TW --> DB[("SQLite WAL")]
LIS -->|recording-id queries| DB
subgraph CLOUD["cloud coordinators"]
@@ -99,7 +99,7 @@ CI uses `stable` (via `dtolnay/rust-toolchain@stable`), so any recent stable too
### System dependencies
-- **ffmpeg + ffprobe** — required by the video-encoder subprocess and the `encoding::video_encoder` / `encoding::nut_writer` test suites (tests that need ffmpeg self-skip if it's missing, but the daemon itself depends on it at runtime):
+- **ffmpeg + ffprobe** — required by the video-encoder subprocess and the daemon's `encoding::video_encoder` and the producer's `data_daemon_producer::nut_writer` test suites (tests that need ffmpeg self-skip if it's missing, but the daemon itself depends on it at runtime):
```bash
sudo apt-get update && sudo apt-get install -y ffmpeg
@@ -144,7 +144,7 @@ cargo test -p data_daemon_shared
# A specific module or test name (partial match)
cargo test -p data-daemon pipeline::dispatcher
-cargo test -p data-daemon encoding::metadata::fixture_matches_python_video_trace_output
+cargo test -p data-daemon encoding::metadata::fixture_matches_expected_video_trace_output
```
Tests that shell out to `ffmpeg` / `ffprobe` self-skip on hosts without those binaries — install them (see above) to exercise the full encoding suite.
diff --git a/neuracore-dictionary.txt b/neuracore-dictionary.txt
index bfb437d5..6b3aad59 100644
--- a/neuracore-dictionary.txt
+++ b/neuracore-dictionary.txt
@@ -127,8 +127,10 @@ embs
Emika
ENOENT
enoexec
+ENOTDIR
EPERM
EPIPE
+eprintln
erfinv
errno
ESRCH
@@ -189,12 +191,16 @@ gptj
GPTJ
groot
Groot
+hdlc
hookwrapper
hparams
hstack
huggingface
hyperparameters
iceoryx
+idat
+iend
+ihdr
iiwa
imageio
imagenet
@@ -262,6 +268,7 @@ metadatas
metafunc
metas
mimsave
+miniz
Mish
mjcf
MJFC
@@ -269,7 +276,9 @@ mline
mocap
moov
movflags
+mpng
mpsa
+mpsc
Mujoco
mujocoinclude
multihead
@@ -394,6 +403,8 @@ rtype
rustls
rustup
Safetensors
+scanline
+scanlines
schematypens
SCTP
sdecode
@@ -427,6 +438,7 @@ synchronizable
syncpoint
syncpoints
targetbody
+tdefl
TDVB
teleop
temb
@@ -450,6 +462,7 @@ torq
tqdm
traj
triu
+truecolour
trunc
tryfirst
TTYNTK
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 94bea9cd..00229f26 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -448,6 +454,7 @@ dependencies = [
"data_daemon_shared",
"iceoryx2",
"libc",
+ "miniz_oxide",
"pyo3",
"serde",
"serde_json",
@@ -1622,6 +1629,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
[[package]]
name = "mio"
version = "1.2.0"
diff --git a/rust/data_daemon/src/api/client.rs b/rust/data_daemon/src/api/client.rs
index 66fb8d27..e66b1368 100644
--- a/rust/data_daemon/src/api/client.rs
+++ b/rust/data_daemon/src/api/client.rs
@@ -690,6 +690,71 @@ mod tests {
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
+ #[tokio::test]
+ async fn exhausts_retry_budget_then_surfaces_status() {
+ // A persistently failing retryable status must stop after the budget
+ // (`max_retries` attempts total) and surface the error rather than
+ // retrying forever. `expect(3)` pins the bounded attempt count.
+ let server = MockServer::start().await;
+ Mock::given(method("POST"))
+ .and(path("/org/org-1/recording/traces/batch-register"))
+ .respond_with(ResponseTemplate::new(503))
+ .expect(3)
+ .mount(&server)
+ .await;
+
+ let client = client(&server);
+ let error = client.batch_register("org-1", &[]).await.unwrap_err();
+ match error {
+ ApiClientError::Status { status, .. } => {
+ assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
+ }
+ other => panic!("unexpected error: {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn reloads_auth_at_most_once_on_repeated_401() {
+ // A 401 triggers exactly one token reload; a second 401 surfaces as an
+ // error instead of looping forever on reload + retry.
+ let server = MockServer::start().await;
+ Mock::given(method("POST"))
+ .and(path("/org/org-1/recording/traces/batch-register"))
+ .respond_with(ResponseTemplate::new(401))
+ .mount(&server)
+ .await;
+
+ let calls = Arc::new(AtomicUsize::new(0));
+ struct CountingProvider {
+ calls: Arc,
+ }
+ #[async_trait::async_trait]
+ impl AuthProvider for CountingProvider {
+ async fn bearer_token(&self) -> Result {
+ Ok("token".to_string())
+ }
+ async fn reload(&self) -> Result<(), AuthError> {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ Ok(())
+ }
+ }
+ let auth = Arc::new(CountingProvider {
+ calls: Arc::clone(&calls),
+ });
+ let client = ApiClient::new(options(server.uri()), auth).unwrap();
+
+ let error = client.batch_register("org-1", &[]).await.unwrap_err();
+ assert!(
+ matches!(error, ApiClientError::Status { status, .. } if status == StatusCode::UNAUTHORIZED),
+ "a repeated 401 surfaces as an error, not an infinite reload loop"
+ );
+ assert_eq!(
+ calls.load(Ordering::SeqCst),
+ 1,
+ "auth is reloaded exactly once, not on every 401"
+ );
+ }
+
#[tokio::test]
async fn non_retryable_status_surfaces_error() {
let server = MockServer::start().await;
diff --git a/rust/data_daemon/src/cloud/coordinators/progress.rs b/rust/data_daemon/src/cloud/coordinators/progress.rs
index dc02e4f4..01658355 100644
--- a/rust/data_daemon/src/cloud/coordinators/progress.rs
+++ b/rust/data_daemon/src/cloud/coordinators/progress.rs
@@ -534,4 +534,175 @@ mod tests {
ProgressReportStatus::Reported
));
}
+
+ #[tokio::test]
+ async fn sweep_skips_when_org_id_unset() {
+ // No current org (e.g. not logged in / no org selected): the sweep must
+ // skip the recording without POSTing anything, leaving it pending.
+ let server = MockServer::start().await;
+ Mock::given(method("PUT"))
+ .and(path("/org/org-1/recording/rec-1/expected-trace-count"))
+ .respond_with(ResponseTemplate::new(200))
+ .expect(0)
+ .mount(&server)
+ .await;
+ Mock::given(method("POST"))
+ .and(path("/org/org-1/recording/rec-1/traces-metadata"))
+ .respond_with(ResponseTemplate::new(200))
+ .expect(0)
+ .mount(&server)
+ .await;
+
+ let (store, _dir) = open_store().await;
+ let recording_index = seed_recording(&store, "rec-1").await;
+ store
+ .create_trace(recording_index, "t-1", Some("JOINT_POSITIONS"), None)
+ .await
+ .unwrap();
+ store
+ .update_trace(
+ "t-1",
+ TraceUpdate {
+ write_status: Some(TraceWriteStatus::Written),
+ total_bytes: Some(5),
+ ..TraceUpdate::default()
+ },
+ )
+ .await
+ .unwrap();
+ store
+ .mark_recording_stopped(recording_index, 0)
+ .await
+ .unwrap();
+
+ sweep_once(&Arc::new(store.clone()), &client(&server), &org_rx(None)).await;
+
+ let recording = store.get_recording(recording_index).await.unwrap().unwrap();
+ assert_eq!(
+ recording.expected_trace_count_reported, 0,
+ "no org → nothing reported"
+ );
+ assert!(matches!(
+ recording.progress_reported,
+ ProgressReportStatus::Pending
+ ));
+ }
+
+ #[tokio::test]
+ async fn progress_post_failure_rolls_back_to_pending() {
+ // The expected-count PUT succeeds but the traces-metadata POST fails:
+ // the recording's progress status must roll Reporting → Pending so the
+ // next tick retries, never wedging in the transient Reporting state.
+ let server = MockServer::start().await;
+ Mock::given(method("PUT"))
+ .and(path("/org/org-1/recording/rec-1/expected-trace-count"))
+ .respond_with(ResponseTemplate::new(200))
+ .expect(1)
+ .mount(&server)
+ .await;
+ Mock::given(method("POST"))
+ .and(path("/org/org-1/recording/rec-1/traces-metadata"))
+ .respond_with(ResponseTemplate::new(500))
+ .mount(&server)
+ .await;
+
+ let (store, _dir) = open_store().await;
+ let recording_index = seed_recording(&store, "rec-1").await;
+ for (trace_id, bytes) in [("t-1", 10), ("t-2", 20)] {
+ store
+ .create_trace(recording_index, trace_id, Some("JOINT_POSITIONS"), None)
+ .await
+ .unwrap();
+ store
+ .update_trace(
+ trace_id,
+ TraceUpdate {
+ write_status: Some(TraceWriteStatus::Written),
+ total_bytes: Some(bytes),
+ ..TraceUpdate::default()
+ },
+ )
+ .await
+ .unwrap();
+ }
+ store
+ .mark_recording_stopped(recording_index, 0)
+ .await
+ .unwrap();
+
+ sweep_once(
+ &Arc::new(store.clone()),
+ &client(&server),
+ &org_rx(Some("org-1")),
+ )
+ .await;
+
+ let recording = store.get_recording(recording_index).await.unwrap().unwrap();
+ assert_eq!(
+ recording.expected_trace_count_reported, 2,
+ "the expected-count PUT succeeded"
+ );
+ assert!(
+ matches!(recording.progress_reported, ProgressReportStatus::Pending),
+ "a failed progress POST rolls Reporting back to Pending for retry"
+ );
+ }
+
+ #[tokio::test]
+ async fn expected_count_put_failure_leaves_it_unreported() {
+ // The count is persisted locally first, then PUT to the backend. If the
+ // PUT fails it must NOT be marked reported (the next tick re-sends it),
+ // but the local count is retained.
+ let server = MockServer::start().await;
+ Mock::given(method("PUT"))
+ .and(path("/org/org-1/recording/rec-1/expected-trace-count"))
+ .respond_with(ResponseTemplate::new(500))
+ .mount(&server)
+ .await;
+ Mock::given(method("POST"))
+ .and(path("/org/org-1/recording/rec-1/traces-metadata"))
+ .respond_with(ResponseTemplate::new(200))
+ .mount(&server)
+ .await;
+
+ let (store, _dir) = open_store().await;
+ let recording_index = seed_recording(&store, "rec-1").await;
+ store
+ .create_trace(recording_index, "t-1", Some("JOINT_POSITIONS"), None)
+ .await
+ .unwrap();
+ store
+ .update_trace(
+ "t-1",
+ TraceUpdate {
+ write_status: Some(TraceWriteStatus::Written),
+ total_bytes: Some(7),
+ ..TraceUpdate::default()
+ },
+ )
+ .await
+ .unwrap();
+ store
+ .mark_recording_stopped(recording_index, 0)
+ .await
+ .unwrap();
+
+ sweep_once(
+ &Arc::new(store.clone()),
+ &client(&server),
+ &org_rx(Some("org-1")),
+ )
+ .await;
+
+ let recording = store.get_recording(recording_index).await.unwrap().unwrap();
+ assert_eq!(
+ recording.expected_trace_count,
+ Some(1),
+ "the count is persisted locally first"
+ );
+ assert_eq!(
+ recording.expected_trace_count_reported, 0,
+ "a failed PUT does not mark the count reported"
+ );
+ }
}
diff --git a/rust/data_daemon/src/cloud/coordinators/registration.rs b/rust/data_daemon/src/cloud/coordinators/registration.rs
index d4c38a87..9fbf5425 100644
--- a/rust/data_daemon/src/cloud/coordinators/registration.rs
+++ b/rust/data_daemon/src/cloud/coordinators/registration.rs
@@ -765,4 +765,127 @@ mod tests {
Some("Unexpected error during registration")
);
}
+
+ #[tokio::test]
+ async fn silent_omission_retries_under_budget() {
+ // The backend returns neither a registered nor a failed entry for the
+ // trace. It must be retried (rolled back to pending) under the bounded
+ // budget, never silently dropped.
+ let server = MockServer::start().await;
+ Mock::given(method("POST"))
+ .and(path("/org/org-1/recording/traces/batch-register"))
+ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+ "registered_traces": [],
+ "failed_traces": []
+ })))
+ .mount(&server)
+ .await;
+
+ let (store, _dir) = open_store().await;
+ seed_written_trace(&store, "trace-1", Some("cloud-rec-1")).await;
+ let bus = EventBus::new();
+ let api = client(&server);
+
+ let claimed = store
+ .claim_traces_for_registration(BATCH_SIZE, 0.0)
+ .await
+ .unwrap();
+ submit_batch(
+ &Arc::new(store.clone()),
+ &bus,
+ &api,
+ &org_rx(Some("org-1")),
+ claimed,
+ &mut HashMap::new(),
+ )
+ .await;
+
+ let trace = store.get_trace("trace-1").await.unwrap().unwrap();
+ assert_eq!(
+ trace.registration_status,
+ TraceRegistrationStatus::Pending,
+ "a silently-omitted trace is retried, not lost"
+ );
+ }
+
+ #[tokio::test]
+ async fn drain_once_registers_and_emits_ready() {
+ // End-to-end through the coordinator's own drain: claim → register →
+ // promotion sweep. Exercises drain_once (which the per-call tests above
+ // bypass by calling submit_batch directly).
+ let server = MockServer::start().await;
+ Mock::given(method("POST"))
+ .and(path("/org/org-1/recording/traces/batch-register"))
+ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+ "registered_traces": [{
+ "trace_id": "trace-1",
+ "upload_session_uris": {"JOINT_POSITIONS/arm0/trace.json": "https://upload/abc"}
+ }],
+ "failed_traces": []
+ })))
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let (store, _dir) = open_store().await;
+ seed_written_trace(&store, "trace-1", Some("cloud-rec-1")).await;
+ let bus = EventBus::new();
+ let mut subscriber = bus.subscribe();
+ let api = client(&server);
+
+ // `Duration::ZERO` max_wait claims the freshly-seeded trace immediately.
+ drain_once(
+ &Arc::new(store.clone()),
+ &bus,
+ &api,
+ &org_rx(Some("org-1")),
+ Duration::from_secs(0),
+ &mut HashMap::new(),
+ )
+ .await;
+
+ let trace = store.get_trace("trace-1").await.unwrap().unwrap();
+ assert_eq!(
+ trace.registration_status,
+ TraceRegistrationStatus::Registered
+ );
+ assert_eq!(trace.upload_status, TraceUploadStatus::Queued);
+ let mut saw_ready = false;
+ while let Ok(event) = subscriber.try_recv() {
+ if matches!(event, DaemonEvent::ReadyForUpload { .. }) {
+ saw_ready = true;
+ }
+ }
+ assert!(
+ saw_ready,
+ "drain_once promotes the registered+written trace to ReadyForUpload"
+ );
+ }
+
+ #[tokio::test]
+ async fn drain_once_with_no_claimable_traces_is_a_noop() {
+ // Nothing claimable → no register POST, no panic (the empty-claim early
+ // return).
+ let server = MockServer::start().await;
+ Mock::given(method("POST"))
+ .and(path("/org/org-1/recording/traces/batch-register"))
+ .respond_with(ResponseTemplate::new(200))
+ .expect(0)
+ .mount(&server)
+ .await;
+
+ let (store, _dir) = open_store().await;
+ let bus = EventBus::new();
+ let api = client(&server);
+
+ drain_once(
+ &Arc::new(store.clone()),
+ &bus,
+ &api,
+ &org_rx(Some("org-1")),
+ Duration::from_secs(0),
+ &mut HashMap::new(),
+ )
+ .await;
+ }
}
diff --git a/rust/data_daemon/src/cloud/coordinators/status.rs b/rust/data_daemon/src/cloud/coordinators/status.rs
index d52f0612..5e7080e5 100644
--- a/rust/data_daemon/src/cloud/coordinators/status.rs
+++ b/rust/data_daemon/src/cloud/coordinators/status.rs
@@ -371,6 +371,50 @@ impl RecordingBatch {
mod tests {
use super::*;
+ use crate::api::auth::StaticAuthProvider;
+ use crate::api::client::ApiClientOptions;
+ use crate::state::store::NewRecording;
+ use tempfile::TempDir;
+ use wiremock::matchers::{body_json, method, path};
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+
+ async fn open_store() -> (SqliteStateStore, TempDir) {
+ let dir = TempDir::new().unwrap();
+ let store = SqliteStateStore::open(&dir.path().join("state.db"))
+ .await
+ .unwrap();
+ (store, dir)
+ }
+
+ fn client(server: &MockServer) -> Arc {
+ let auth = Arc::new(StaticAuthProvider::new("test"));
+ let mut options = ApiClientOptions::new(server.uri());
+ options.max_backoff = Duration::from_millis(10);
+ Arc::new(ApiClient::new(options, auth).unwrap())
+ }
+
+ /// A live-org receiver fixed at `org`. The sender is leaked so the channel
+ /// stays open for the test's duration (matches `progress.rs`).
+ fn org_rx(org: Option<&str>) -> OrgIdRx {
+ let (org_tx, org_rx) = tokio::sync::watch::channel(org.map(str::to_string));
+ Box::leak(Box::new(org_tx));
+ org_rx
+ }
+
+ /// Create a recording stamped with the given cloud `recording_id` so the
+ /// wiremock URL expectations resolve. Returns the local `recording_index`.
+ async fn seed_recording(store: &SqliteStateStore, cloud_recording_id: &str) -> i64 {
+ let recording = store
+ .create_recording(NewRecording::default())
+ .await
+ .unwrap();
+ store
+ .mark_recording_start_notified(recording.recording_index, cloud_recording_id)
+ .await
+ .unwrap();
+ recording.recording_index
+ }
+
#[test]
fn batch_records_completion_flag() {
let mut batch = RecordingBatch::new(1);
@@ -418,4 +462,273 @@ mod tests {
batch.add(StatusUpdate::completed(1, "t".to_string(), 1));
assert!(batch.deadline() < baseline);
}
+
+ #[tokio::test]
+ async fn flush_batch_sends_coalesced_updates() {
+ // The whole point of the coordinator: the per-trace coalesced state
+ // reaches the backend in one batch-update PUT. The body asserts the
+ // coalescing — t1's later byte count supersedes the earlier one, and
+ // t2 carries its completion status + totals.
+ let server = MockServer::start().await;
+ Mock::given(method("PUT"))
+ .and(path("/org/org-1/recording/rec-1/traces/batch-update"))
+ .and(body_json(serde_json::json!({
+ "updates": {
+ "t1": {"uploaded_bytes": 30},
+ "t2": {"status": "UPLOAD_COMPLETE", "uploaded_bytes": 200, "total_bytes": 200}
+ }
+ })))
+ .respond_with(ResponseTemplate::new(200))
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let (store, _dir) = open_store().await;
+ let index = seed_recording(&store, "rec-1").await;
+ let mut batch = RecordingBatch::new(index);
+ batch.add(StatusUpdate::in_progress(index, "t1".to_string(), 10));
+ batch.add(StatusUpdate::in_progress(index, "t1".to_string(), 30)); // supersedes 10
+ batch.add(StatusUpdate::completed(index, "t2".to_string(), 200));
+
+ let result = flush_batch(
+ Arc::new(store.clone()),
+ client(&server),
+ org_rx(Some("org-1")),
+ batch,
+ )
+ .await;
+ assert!(result.is_none(), "a sent batch is not re-queued");
+ }
+
+ #[tokio::test]
+ async fn flush_batch_defers_when_recording_row_missing() {
+ // The start notifier hasn't written the recording row yet. The batch
+ // must be re-queued with its deadline pushed into the future so the
+ // select loop doesn't busy-wait on a permanently-past deadline.
+ let server = MockServer::start().await; // no mock mounted: nothing is sent
+ let (store, _dir) = open_store().await;
+ let mut batch = RecordingBatch::new(999); // never created
+ batch.add(StatusUpdate::in_progress(999, "t1".to_string(), 5));
+
+ let before = Instant::now();
+ let result = flush_batch(
+ Arc::new(store.clone()),
+ client(&server),
+ org_rx(Some("org-1")),
+ batch,
+ )
+ .await;
+ let deferred = result.expect("a missing recording row re-queues the batch");
+ assert!(
+ deferred.deadline() > before,
+ "the re-queued batch's deadline is pushed into the future"
+ );
+ }
+
+ #[tokio::test]
+ async fn flush_batch_defers_when_org_id_unset() {
+ let server = MockServer::start().await; // nothing is sent
+ let (store, _dir) = open_store().await;
+ let index = seed_recording(&store, "rec-1").await;
+ let mut batch = RecordingBatch::new(index);
+ batch.add(StatusUpdate::in_progress(index, "t1".to_string(), 5));
+
+ let result = flush_batch(
+ Arc::new(store.clone()),
+ client(&server),
+ org_rx(None),
+ batch,
+ )
+ .await;
+ assert!(
+ result.is_some(),
+ "no org_id → the batch is deferred, not sent"
+ );
+ }
+
+ #[tokio::test]
+ async fn flush_batch_defers_when_cloud_recording_id_unset() {
+ let server = MockServer::start().await; // nothing is sent
+ let (store, _dir) = open_store().await;
+ // Created but NOT start-notified, so the cloud recording_id is absent.
+ let index = store
+ .create_recording(NewRecording::default())
+ .await
+ .unwrap()
+ .recording_index;
+ let mut batch = RecordingBatch::new(index);
+ batch.add(StatusUpdate::in_progress(index, "t1".to_string(), 5));
+
+ let result = flush_batch(
+ Arc::new(store.clone()),
+ client(&server),
+ org_rx(Some("org-1")),
+ batch,
+ )
+ .await;
+ assert!(
+ result.is_some(),
+ "no cloud recording_id → the batch is deferred, not sent"
+ );
+ }
+
+ #[tokio::test]
+ async fn flush_batch_skips_empty_batch() {
+ let server = MockServer::start().await; // nothing is sent
+ let (store, _dir) = open_store().await;
+ let index = seed_recording(&store, "rec-1").await;
+ let batch = RecordingBatch::new(index); // no updates added
+
+ let result = flush_batch(
+ Arc::new(store.clone()),
+ client(&server),
+ org_rx(Some("org-1")),
+ batch,
+ )
+ .await;
+ assert!(
+ result.is_none(),
+ "an empty batch is a no-op, nothing is sent"
+ );
+ }
+
+ #[tokio::test]
+ async fn flush_all_drains_every_pending_batch() {
+ // The shutdown path: every pending recording's batch is flushed and the
+ // map left empty.
+ let server = MockServer::start().await;
+ for recording_id in ["rec-1", "rec-2"] {
+ Mock::given(method("PUT"))
+ .and(path(format!(
+ "/org/org-1/recording/{recording_id}/traces/batch-update"
+ )))
+ .respond_with(ResponseTemplate::new(200))
+ .expect(1)
+ .mount(&server)
+ .await;
+ }
+
+ let (store, _dir) = open_store().await;
+ let first = seed_recording(&store, "rec-1").await;
+ let second = seed_recording(&store, "rec-2").await;
+ let mut pending: HashMap = HashMap::new();
+ let mut first_batch = RecordingBatch::new(first);
+ first_batch.add(StatusUpdate::in_progress(first, "t1".to_string(), 1));
+ pending.insert(first, first_batch);
+ let mut second_batch = RecordingBatch::new(second);
+ second_batch.add(StatusUpdate::completed(second, "t2".to_string(), 9));
+ pending.insert(second, second_batch);
+
+ flush_all(
+ &Arc::new(store.clone()),
+ &client(&server),
+ &org_rx(Some("org-1")),
+ &mut pending,
+ )
+ .await;
+ assert!(pending.is_empty(), "flush_all drains the pending map");
+ }
+
+ #[tokio::test]
+ async fn flush_due_spawns_only_batches_past_their_deadline() {
+ // The periodic tick must flush only batches whose deadline has elapsed,
+ // leaving younger batches pending to keep coalescing.
+ let server = MockServer::start().await;
+ Mock::given(method("PUT"))
+ .and(path("/org/org-1/recording/rec-1/traces/batch-update"))
+ .respond_with(ResponseTemplate::new(200))
+ .mount(&server)
+ .await;
+
+ let (store, _dir) = open_store().await;
+ let due_index = seed_recording(&store, "rec-1").await;
+ let fresh_index = seed_recording(&store, "rec-2").await;
+ let mut pending: HashMap = HashMap::new();
+ // A batch whose deadline is already well in the past.
+ let mut due = RecordingBatch::new(due_index);
+ due.add(StatusUpdate::in_progress(due_index, "t1".to_string(), 1));
+ due.opened_at = Instant::now() - Duration::from_secs(60);
+ pending.insert(due_index, due);
+ // A just-opened batch whose deadline is comfortably in the future.
+ let mut fresh = RecordingBatch::new(fresh_index);
+ fresh.add(StatusUpdate::in_progress(fresh_index, "t2".to_string(), 1));
+ pending.insert(fresh_index, fresh);
+
+ let mut background: JoinSet