use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use secrecy::SecretString;
use tokio::sync::mpsc;
use super::outbox::{DrainLimits, DrainOutcome, Outbox};
use super::{CloudConfig, CloudError, CloudEvent, CloudState, CredentialProvider};
use crate::core::cloud::envelope::build_cloud_headers;
const MAX_BATCH_BYTES: usize = 262_144;
const MAX_BATCH_EVENTS: usize = 100;
const SHUTDOWN_FLUSH_BUDGET: Duration = Duration::from_secs(3);
fn far_future() -> tokio::time::Instant {
tokio::time::Instant::now() + Duration::from_secs(86_400)
}
#[cfg(not(test))]
fn health_delay(state: &CloudState) -> Duration {
state.next_health_delay()
}
#[cfg(test)]
fn health_delay(_state: &CloudState) -> Duration {
Duration::from_millis(50)
}
fn note_recovery(degraded: &mut Option<&'static str>, state: &CloudState) {
if let Some(reason) = degraded.take() {
tracing::info!(
reason,
spooled = state.consecutive_drops(),
"cloud worker: connectivity restored"
);
}
}
fn clear_auth_error_latch(
local: &mut bool,
cloud_state: &CloudState,
openlatch_dir: &Path,
reason: &'static str,
) {
*local = false;
cloud_state.clear_auth_error();
if let Err(e) = persist_cloud_state(openlatch_dir, false) {
tracing::warn!(
error = %e,
reason,
"cloud worker: failed to persist cloud_state.json on auth clear"
);
}
}
const EMERGENCY_DROP_THRESHOLD: u64 = 100;
const EMERGENCY_WINDOW_MS: u64 = 10_000;
const EMERGENCY_RECOVERY_TICKS: u32 = 2;
pub(crate) const HIGH_WATER_TRIP_PCT: u64 = 80;
pub(crate) const HIGH_WATER_CLEAR_PCT: u64 = 50;
pub(crate) const HIGH_WATER_WINDOW_MS: u64 = 10_000;
const OUTBOX_MAX_ATTEMPTS: u32 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EmergencyTrigger {
LiveDrops,
ChannelHighWater,
}
impl EmergencyTrigger {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::LiveDrops => "live_drops",
Self::ChannelHighWater => "channel_high_water",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EmergencyTransition {
Enter,
Exit,
}
impl EmergencyTransition {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Enter => "enter",
Self::Exit => "exit",
}
}
}
fn update_emergency_mode(state: &CloudState, config: &CloudConfig, recovery_ticks: &mut u32) {
let drops = state.consecutive_live_drops();
let window_start = state.live_drops_window_start_ms();
let window_duration = super::now_unix_ms().saturating_sub(window_start);
let high_water_window_ms = state.channel_high_water_window_ms();
let channel_size = config.channel_size;
if !state.is_emergency_mode() {
let live_drop_sustained = drops > EMERGENCY_DROP_THRESHOLD
&& window_start > 0
&& window_duration > EMERGENCY_WINDOW_MS;
let high_water_sustained = high_water_window_ms > HIGH_WATER_WINDOW_MS;
if !live_drop_sustained && !high_water_sustained {
return;
}
state.set_emergency_mode(true);
*recovery_ticks = 0;
let (trigger, reported_duration) = if live_drop_sustained {
(EmergencyTrigger::LiveDrops, window_duration)
} else {
(EmergencyTrigger::ChannelHighWater, high_water_window_ms)
};
tracing::warn!(
code = crate::error::ERR_CLOUD_CHANNEL_EMERGENCY,
trigger = trigger.as_str(),
drops_in_window = drops,
window_duration_ms = reported_duration,
channel_size,
"cloud channel under emergency drop — pausing replay until backlog clears"
);
crate::telemetry::capture_global(
crate::telemetry::Event::cloud_channel_overflow_emergency(
EmergencyTransition::Enter.as_str(),
trigger.as_str(),
drops,
reported_duration,
channel_size,
channel_size,
),
);
return;
}
if drops > 0 || high_water_window_ms > 0 {
*recovery_ticks = 0;
return;
}
*recovery_ticks = recovery_ticks.saturating_add(1);
if *recovery_ticks < EMERGENCY_RECOVERY_TICKS {
return;
}
state.set_emergency_mode(false);
*recovery_ticks = 0;
tracing::info!("cloud channel emergency recovered — resuming replay");
crate::telemetry::capture_global(crate::telemetry::Event::cloud_channel_overflow_emergency(
EmergencyTransition::Exit.as_str(),
EmergencyTrigger::LiveDrops.as_str(),
0,
window_duration,
0,
channel_size,
));
}
pub fn build_cloud_client(config: &CloudConfig) -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(Duration::from_millis(config.timeout_connect_ms))
.timeout(Duration::from_millis(config.timeout_total_ms))
.pool_max_idle_per_host(4)
.use_rustls_tls()
.build()
.expect("failed to build cloud reqwest client")
}
pub async fn run_cloud_worker(
rx: mpsc::Receiver<CloudEvent>,
credential_provider: Arc<dyn CredentialProvider>,
config: CloudConfig,
cloud_state: CloudState,
openlatch_dir: PathBuf,
outbox: Option<Arc<Outbox>>,
shutdown: Option<tokio::sync::watch::Receiver<bool>>,
) {
let mut rx = rx;
run_cloud_worker_on(
&mut rx,
credential_provider,
config,
cloud_state,
openlatch_dir,
outbox,
shutdown,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn run_cloud_worker_on(
rx: &mut mpsc::Receiver<CloudEvent>,
credential_provider: Arc<dyn CredentialProvider>,
config: CloudConfig,
cloud_state: CloudState,
openlatch_dir: PathBuf,
outbox: Option<Arc<Outbox>>,
shutdown: Option<tokio::sync::watch::Receiver<bool>>,
) {
let (_shutdown_keepalive, mut shutdown_rx) = match shutdown {
Some(rx) => (None, rx),
None => {
let (tx, rx) = tokio::sync::watch::channel(false);
(Some(tx), rx)
}
};
let client = build_cloud_client(&config);
let credential_poll_interval = Duration::from_millis(config.credential_poll_interval_ms);
if let Some(outbox) = outbox.as_ref() {
let outbox = outbox.clone();
let client = client.clone();
let config = config.clone();
let provider = credential_provider.clone();
let state = cloud_state.clone();
let dir = openlatch_dir.clone();
let notify = state.drain_notify.clone();
let agent_id = crate::config::sniff_agent_id(&dir).unwrap_or_default();
tokio::spawn(async move {
let attempts: Arc<std::sync::Mutex<HashMap<String, u32>>> =
Arc::new(std::sync::Mutex::new(HashMap::new()));
let ctx = OutboxDrainCtx {
outbox: &outbox,
client: &client,
config: &config,
credential_provider: &provider,
cloud_state: &state,
openlatch_dir: &dir,
agent_id: &agent_id,
attempts: &attempts,
};
drain_outbox_once(&ctx).await;
loop {
notify.notified().await;
if state.is_emergency_mode() {
tracing::debug!(
"outbox drain: skipping pass — cloud channel in emergency mode"
);
continue;
}
drain_outbox_once(&ctx).await;
}
});
}
let provider = credential_provider.clone();
let mut current_key: Option<SecretString> =
tokio::task::spawn_blocking(move || provider.retrieve())
.await
.unwrap_or(None);
cloud_state.set_no_credential(current_key.is_none());
let mut auth_error = false;
if let Err(e) = persist_cloud_state(&openlatch_dir, auth_error) {
tracing::warn!(
error = %e,
"cloud worker: failed to persist initial cloud_state.json"
);
}
let mut missing_key_warned = current_key.is_none();
let mut degraded: Option<&'static str> = None;
if current_key.is_none() {
tracing::warn!(
code = "OL-1200",
"cloud worker: no API key available — cloud forwarding disabled until you run \
'openlatch auth login' or set [cloud] enabled = false in config.toml (fail-open, \
events are still logged locally)"
);
}
let mut last_credential_poll = tokio::time::Instant::now();
let mut last_health_tick = tokio::time::Instant::now();
let mut emergency_recovery_ticks: u32 = 0;
let size_trigger = config.batch_max_events.max(1);
let mut buf: Vec<CloudEvent> = Vec::with_capacity(size_trigger);
let mut deadline: Option<tokio::time::Instant> = None;
let flush_ctx = FlushCtx {
client: &client,
config: &config,
openlatch_dir: &openlatch_dir,
outbox: outbox.as_ref(),
cloud_state: &cloud_state,
};
loop {
if last_credential_poll.elapsed() >= credential_poll_interval {
let provider = credential_provider.clone();
let new_key = tokio::task::spawn_blocking(move || provider.retrieve())
.await
.unwrap_or(None);
let key_changed = match (¤t_key, &new_key) {
(None, None) => false,
(Some(_), None) | (None, Some(_)) => true,
(Some(old), Some(new)) => {
use secrecy::ExposeSecret;
old.expose_secret() != new.expose_secret()
}
};
if new_key.is_some() {
let out_of_band_clear = !key_changed && auth_error && !cloud_state.is_auth_error();
if key_changed {
tracing::info!(
"cloud worker: credential refreshed — resetting auth_error state"
);
clear_auth_error_latch(
&mut auth_error,
&cloud_state,
&openlatch_dir,
"credential refresh",
);
} else if out_of_band_clear {
clear_auth_error_latch(
&mut auth_error,
&cloud_state,
&openlatch_dir,
"out-of-band auth clear",
);
}
missing_key_warned = false;
current_key = new_key;
}
cloud_state.set_no_credential(current_key.is_none());
last_credential_poll = tokio::time::Instant::now();
}
let flush_at = deadline.unwrap_or_else(far_future);
tokio::select! {
biased;
_ = tokio::time::sleep_until(flush_at), if deadline.is_some() => {
flush_batch(
&flush_ctx,
&mut buf,
current_key.as_ref(),
"time",
&mut auth_error,
&mut degraded,
).await;
deadline = None;
continue;
}
_ = shutdown_rx.changed() => {
tracing::info!("cloud worker: shutdown signalled, flushing in-flight batch");
break;
}
_ = tokio::time::sleep_until(
last_credential_poll + credential_poll_interval
) => {
continue;
}
_ = tokio::time::sleep_until(
last_health_tick + health_delay(&cloud_state)
) => {
last_health_tick = tokio::time::Instant::now();
match cloud_health_check(&client, &config).await {
Ok(()) => {
note_recovery(&mut degraded, &cloud_state);
cloud_state.record_health_ok();
cloud_state.notify_drain();
}
Err(e) => {
cloud_state.record_probe_failure();
tracing::debug!(
error = %e,
consecutive_failures = cloud_state.consecutive_probe_failures(),
"cloud health check failed"
);
}
}
update_emergency_mode(&cloud_state, &config, &mut emergency_recovery_ticks);
continue;
}
maybe = rx.recv() => {
let Some(event) = maybe else {
tracing::info!("cloud worker: channel closed, exiting");
break;
};
if auth_error {
tracing::debug!(
code = "OL-1201",
"cloud worker: auth_error active — spooling event to outbox until credential refresh"
);
spool_event(outbox.as_ref(), &event, SpoolReason::AuthError);
continue;
}
if current_key.is_none() {
if !missing_key_warned {
tracing::warn!(
code = "OL-1200",
"cloud worker: no API key available — cloud forwarding disabled until \
you run 'openlatch auth login' or set [cloud] enabled = false in \
config.toml (fail-open, events are still logged locally)"
);
missing_key_warned = true;
} else {
tracing::debug!(
code = "OL-1200",
"cloud worker: skipping event — still no API key available"
);
}
spool_event(outbox.as_ref(), &event, SpoolReason::NoCredential);
continue;
}
if buf.is_empty() {
deadline = Some(
tokio::time::Instant::now()
+ Duration::from_millis(config.batch_max_wait_ms),
);
}
buf.push(event);
if buf.len() >= size_trigger {
flush_batch(
&flush_ctx,
&mut buf,
current_key.as_ref(),
"size",
&mut auth_error,
&mut degraded,
).await;
deadline = None;
}
}
}
}
let snapshot = buf.clone();
if tokio::time::timeout(
SHUTDOWN_FLUSH_BUDGET,
flush_batch(
&flush_ctx,
&mut buf,
current_key.as_ref(),
"shutdown",
&mut auth_error,
&mut degraded,
),
)
.await
.is_err()
{
tracing::warn!(
code = "OL-1200",
buffered = snapshot.len(),
"cloud worker: shutdown flush exceeded its budget — spooling the batch to the outbox"
);
for event in &snapshot {
spool_event(flush_ctx.outbox, event, SpoolReason::Network);
}
}
}
struct FlushCtx<'a> {
client: &'a reqwest::Client,
config: &'a CloudConfig,
openlatch_dir: &'a Path,
outbox: Option<&'a Arc<Outbox>>,
cloud_state: &'a CloudState,
}
async fn flush_batch(
ctx: &FlushCtx<'_>,
buf: &mut Vec<CloudEvent>,
key: Option<&SecretString>,
reason: &'static str,
auth_error: &mut bool,
degraded: &mut Option<&'static str>,
) {
if buf.is_empty() {
return;
}
let events = std::mem::take(buf);
tracing::debug!(
batch_size = events.len(),
reason,
"cloud worker: flushing event batch"
);
let prepared = prepare_batch(&events, ctx.cloud_state);
if prepared.is_empty() {
return;
}
let Some(key) = key else {
spool_group(ctx.outbox, &prepared, SpoolReason::NoCredential);
return;
};
for group in split_batches(&prepared, ctx.config.batch_max_events) {
if *auth_error {
spool_group(ctx.outbox, group, SpoolReason::AuthError);
continue;
}
let n = group.len() as u64;
match post_batch(ctx.client, ctx.config, key, group, ctx.openlatch_dir).await {
Ok(()) => {
note_recovery(degraded, ctx.cloud_state);
tracing::debug!(batch_size = n, "cloud worker: batch forwarded successfully");
ctx.cloud_state.record_successful_forwards(n);
ctx.cloud_state.notify_drain();
}
Err(CloudError::AuthError) => {
tracing::warn!(
code = "OL-1201",
batch_size = n,
"cloud worker: auth error (401/403) — pausing POSTs until credential refresh"
);
*auth_error = true;
ctx.cloud_state
.auth_error
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Err(e) = persist_cloud_state(ctx.openlatch_dir, true) {
tracing::warn!(error = %e, "cloud worker: failed to persist cloud_state.json");
}
spool_group(ctx.outbox, group, SpoolReason::AuthError);
}
Err(CloudError::RateLimit { retry_after_secs }) => {
tracing::debug!(
code = "OL-1202",
retry_after_secs,
batch_size = n,
"cloud worker: rate limited (429) — backing off and retrying"
);
tokio::time::sleep(Duration::from_secs(retry_after_secs)).await;
match post_batch(ctx.client, ctx.config, key, group, ctx.openlatch_dir).await {
Ok(()) => ctx.cloud_state.record_successful_forwards(n),
Err(_) => {
ctx.cloud_state.record_drops(n);
spool_group(ctx.outbox, group, SpoolReason::RateLimit);
}
}
}
Err(CloudError::ServerError) => {
if degraded.is_none() {
tracing::warn!(
code = "OL-1200",
batch_size = n,
"cloud worker: server error (5xx) — retrying once; suppressing further warnings until recovery"
);
*degraded = Some("server_error");
} else {
tracing::debug!(code = "OL-1200", "cloud worker: 5xx during degraded streak");
}
tokio::time::sleep(Duration::from_millis(ctx.config.retry_delay_ms)).await;
match post_batch(ctx.client, ctx.config, key, group, ctx.openlatch_dir).await {
Ok(()) => ctx.cloud_state.record_successful_forwards(n),
Err(_) => {
ctx.cloud_state.record_drops(n);
spool_group(ctx.outbox, group, SpoolReason::ServerError);
}
}
}
Err(CloudError::Network) => {
if degraded.is_none() {
tracing::warn!(
code = "OL-1200",
batch_size = n,
"cloud worker: network error — retrying once; suppressing further warnings until recovery"
);
*degraded = Some("network");
} else {
tracing::debug!(
code = "OL-1200",
"cloud worker: network error during degraded streak"
);
}
tokio::time::sleep(Duration::from_millis(ctx.config.retry_delay_ms)).await;
match post_batch(ctx.client, ctx.config, key, group, ctx.openlatch_dir).await {
Ok(()) => ctx.cloud_state.record_successful_forwards(n),
Err(_) => {
ctx.cloud_state.record_drops(n);
spool_group(ctx.outbox, group, SpoolReason::Network);
}
}
}
Err(CloudError::ClientError(code)) => {
tracing::warn!(
http_status = code,
batch_size = n,
"cloud worker: unexpected 4xx — dropping batch (no retry)"
);
ctx.cloud_state.record_drops(n);
}
}
}
}
async fn cloud_health_check(
client: &reqwest::Client,
config: &CloudConfig,
) -> Result<(), reqwest::Error> {
let base = config.api_url.trim_end_matches('/');
let url = format!("{base}/api/v1/health");
let resp = client.get(&url).send().await?;
resp.error_for_status().map(|_| ())
}
#[derive(Debug, Clone, Copy)]
enum SpoolReason {
Network,
ServerError,
RateLimit,
AuthError,
NoCredential,
}
impl SpoolReason {
fn as_str(self) -> &'static str {
match self {
SpoolReason::Network => "network",
SpoolReason::ServerError => "server_error",
SpoolReason::RateLimit => "rate_limit",
SpoolReason::AuthError => "auth_error",
SpoolReason::NoCredential => "no_credential",
}
}
}
fn spool_event(outbox: Option<&Arc<Outbox>>, event: &CloudEvent, reason: SpoolReason) {
if outbox.is_none() {
return;
}
spool_envelope(outbox, &stamp_extensions(event), reason);
}
fn spool_envelope(outbox: Option<&Arc<Outbox>>, envelope: &serde_json::Value, reason: SpoolReason) {
let Some(outbox) = outbox else { return };
match outbox.append(envelope) {
Ok(()) => {
crate::telemetry::capture_global(crate::telemetry::Event::cloud_event_spooled(
reason.as_str(),
));
}
Err(e) => {
tracing::warn!(
code = crate::error::ERR_OUTBOX_WRITE_FAILED,
error = %e,
path = %outbox.path().display(),
"cloud worker: failed to spool event to outbox"
);
}
}
}
fn spool_group(outbox: Option<&Arc<Outbox>>, group: &[PreparedEvent], reason: SpoolReason) {
if outbox.is_none() {
return;
}
for prepared in group {
spool_envelope(outbox, &prepared.envelope, reason);
}
}
fn stamp_extensions(event: &CloudEvent) -> serde_json::Value {
let mut envelope = event.envelope.clone();
if let Some(obj) = envelope.as_object_mut() {
obj.insert(
"agentid".to_string(),
serde_json::Value::String(event.agent_id.clone()),
);
obj.insert(
"clientversion".to_string(),
serde_json::Value::String(env!("OPENLATCH_VERSION").to_string()),
);
}
envelope
}
struct PreparedEvent {
json: String,
envelope: serde_json::Value,
}
fn prepare_one(envelope: serde_json::Value) -> Option<PreparedEvent> {
let json = match serde_json::to_string(&envelope) {
Ok(json) => json,
Err(e) => {
tracing::warn!(
code = crate::error::ERR_EVENT_TOO_LARGE,
error = %e,
"cloud worker: dropping event that cannot be serialized"
);
return None;
}
};
if json.len() + 2 > MAX_BATCH_BYTES {
tracing::debug!(
code = crate::error::ERR_EVENT_TOO_LARGE,
bytes = json.len(),
max_bytes = MAX_BATCH_BYTES,
"cloud worker: dropping oversized event — it cannot fit in any batch"
);
return None;
}
Some(PreparedEvent { json, envelope })
}
fn prepare_batch(events: &[CloudEvent], cloud_state: &CloudState) -> Vec<PreparedEvent> {
let mut out = Vec::with_capacity(events.len());
for event in events {
match prepare_one(stamp_extensions(event)) {
Some(prepared) => out.push(prepared),
None => cloud_state.record_drop(),
}
}
out
}
fn split_batches(prepared: &[PreparedEvent], batch_max_events: usize) -> Vec<&[PreparedEvent]> {
let cap = batch_max_events.clamp(1, MAX_BATCH_EVENTS);
let mut out = Vec::new();
let mut start = 0usize;
let mut payload = 0usize;
let mut count = 0usize;
for (i, event) in prepared.iter().enumerate() {
if count > 0 {
let framed = payload + event.json.len() + 2 + count;
if count >= cap || framed > MAX_BATCH_BYTES {
out.push(&prepared[start..i]);
start = i;
payload = 0;
count = 0;
}
}
payload += event.json.len();
count += 1;
}
if count > 0 {
out.push(&prepared[start..]);
}
out
}
struct OutboxDrainCtx<'a> {
outbox: &'a Arc<Outbox>,
client: &'a reqwest::Client,
config: &'a CloudConfig,
credential_provider: &'a Arc<dyn CredentialProvider>,
cloud_state: &'a CloudState,
openlatch_dir: &'a Path,
agent_id: &'a str,
attempts: &'a Arc<std::sync::Mutex<HashMap<String, u32>>>,
}
async fn drain_outbox_once(ctx: &OutboxDrainCtx<'_>) {
let provider = ctx.credential_provider.clone();
let key: Option<SecretString> = tokio::task::spawn_blocking(move || provider.retrieve())
.await
.unwrap_or(None);
let Some(key) = key else {
return;
};
let limits = DrainLimits {
max_entries: ctx.config.batch_max_events.clamp(1, MAX_BATCH_EVENTS),
max_bytes: MAX_BATCH_BYTES,
};
let stats = ctx
.outbox
.drain(limits, |envelopes| {
let client = ctx.client.clone();
let config = ctx.config.clone();
let key = key.clone();
let openlatch_dir = ctx.openlatch_dir.to_path_buf();
let agent_id = ctx.agent_id.to_string();
let attempts = ctx.attempts.clone();
async move {
let group_len = envelopes.len();
let mut outcomes = vec![DrainOutcome::Forwarded; group_len];
let mut ids: Vec<Option<String>> = Vec::with_capacity(group_len);
let mut prepared: Vec<PreparedEvent> = Vec::with_capacity(group_len);
for (idx, envelope) in envelopes.into_iter().enumerate() {
ids.push(
envelope
.get("id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string),
);
let event = CloudEvent {
envelope,
agent_id: agent_id.clone(),
};
match prepare_one(stamp_extensions(&event)) {
Some(p) => prepared.push(p),
None => outcomes[idx] = DrainOutcome::Quarantined,
}
}
let mut failure: Option<CloudError> = None;
for batch in split_batches(&prepared, config.batch_max_events) {
match post_batch(&client, &config, &key, batch, &openlatch_dir).await {
Ok(()) => {}
Err(CloudError::ClientError(code)) => {
tracing::warn!(
http_status = code,
batch_size = batch.len(),
"outbox drain: dropping entries on unexpected 4xx"
);
}
Err(e) => {
failure = Some(e);
break;
}
}
}
let Some(e) = failure else {
let mut guard = attempts.lock().unwrap();
if !guard.is_empty() {
for id in ids.iter().flatten() {
guard.remove(id);
}
}
return Ok(outcomes);
};
let mut all_exhausted = true;
{
let mut guard = attempts.lock().unwrap();
for id in &ids {
let Some(id) = id else {
all_exhausted = false;
continue;
};
let entry = guard.entry(id.clone()).or_insert(0);
*entry = entry.saturating_add(1);
if *entry < OUTBOX_MAX_ATTEMPTS {
all_exhausted = false;
}
}
}
if all_exhausted {
let mut guard = attempts.lock().unwrap();
for id in ids.iter().flatten() {
tracing::warn!(
code = crate::error::ERR_OUTBOX_QUARANTINED,
event_id = %id,
attempts = OUTBOX_MAX_ATTEMPTS,
error = %e,
"outbox: quarantining repeatedly-failing entry"
);
guard.remove(id);
}
return Ok(vec![DrainOutcome::Quarantined; group_len]);
}
tracing::debug!(
error = %e,
batch_size = group_len,
"outbox drain: halted on transient failure — retaining remaining entries"
);
Err(())
}
})
.await;
match stats {
Ok(stats) => {
if stats.drained > 0 || stats.failed > 0 || stats.corrupt > 0 || stats.quarantined > 0 {
tracing::info!(
drained = stats.drained,
failed = stats.failed,
corrupt = stats.corrupt,
quarantined = stats.quarantined,
remaining = ctx.outbox.pending_count(),
"outbox drain completed"
);
crate::telemetry::capture_global(crate::telemetry::Event::cloud_outbox_drained(
stats.drained,
stats.failed,
stats.corrupt,
stats.quarantined,
));
}
if stats.drained > 0 {
ctx.cloud_state
.forwarded_count
.fetch_add(stats.drained, std::sync::atomic::Ordering::Relaxed);
}
if stats.failed > 0 {
tracing::warn!(
code = crate::error::ERR_OUTBOX_DRAIN_PARTIAL,
failed = stats.failed,
remaining = ctx.outbox.pending_count(),
"outbox drain: partial — some entries could not be replayed yet"
);
}
}
Err(e) => {
tracing::warn!(
code = crate::error::ERR_OUTBOX_DRAIN_PARTIAL,
error = %e,
"outbox drain: I/O error while replaying"
);
}
}
}
async fn post_batch(
client: &reqwest::Client,
config: &CloudConfig,
key: &SecretString,
batch: &[PreparedEvent],
_openlatch_dir: &Path,
) -> Result<(), CloudError> {
let request_id = uuid::Uuid::now_v7().to_string();
let mut body = String::with_capacity(batch.iter().map(|e| e.json.len() + 1).sum::<usize>() + 2);
body.push('[');
for (i, event) in batch.iter().enumerate() {
if i > 0 {
body.push(',');
}
body.push_str(&event.json);
}
body.push(']');
let headers = build_cloud_headers(key, &request_id);
let base = config.api_url.trim_end_matches('/');
let url = format!("{base}/api/v1/events/ingest");
let response = client
.post(&url)
.headers(headers)
.body(body)
.send()
.await
.map_err(|_| CloudError::Network)?;
let status = response.status();
if status.is_success() {
return Ok(());
}
match status.as_u16() {
401 | 403 => Err(CloudError::AuthError),
429 => {
let retry_after_secs = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(config.rate_limit_default_secs);
Err(CloudError::RateLimit { retry_after_secs })
}
500..=599 => Err(CloudError::ServerError),
code => Err(CloudError::ClientError(code)),
}
}
pub fn persist_cloud_state(openlatch_dir: &Path, auth_error: bool) -> std::io::Result<()> {
std::fs::create_dir_all(openlatch_dir)?;
let updated_at = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let content = format!(
"{{\"auth_error\":{},\"updated_at\":\"{}\"}}\n",
auth_error, updated_at
);
let tmp_path = openlatch_dir.join("cloud_state.json.tmp");
let final_path = openlatch_dir.join("cloud_state.json");
std::fs::write(&tmp_path, &content)?;
std::fs::rename(&tmp_path, &final_path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::SecretString;
use std::sync::atomic::Ordering;
use std::sync::Mutex;
use tokio::sync::mpsc;
struct TestCredentialProvider {
key: Mutex<Option<String>>,
retrievals: std::sync::atomic::AtomicU64,
}
impl TestCredentialProvider {
fn with_key(key: &str) -> Arc<Self> {
Arc::new(Self {
key: Mutex::new(Some(key.to_string())),
retrievals: std::sync::atomic::AtomicU64::new(0),
})
}
fn empty() -> Arc<Self> {
Arc::new(Self {
key: Mutex::new(None),
retrievals: std::sync::atomic::AtomicU64::new(0),
})
}
fn set_key(&self, key: &str) {
*self.key.lock().unwrap() = Some(key.to_string());
}
fn retrievals(&self) -> u64 {
self.retrievals.load(Ordering::Relaxed)
}
}
impl CredentialProvider for TestCredentialProvider {
fn retrieve(&self) -> Option<SecretString> {
self.retrievals.fetch_add(1, Ordering::Relaxed);
self.key
.lock()
.ok()
.and_then(|g| g.as_ref().map(|k| SecretString::from(k.clone())))
}
}
#[test]
fn test_update_emergency_mode_engages_after_sustained_streak() {
use std::time::{SystemTime, UNIX_EPOCH};
let state = CloudState::new();
let config = CloudConfig::default();
let mut recovery_ticks = 0u32;
for _ in 0..(EMERGENCY_DROP_THRESHOLD + 1) {
state.record_live_drop();
}
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
state.live_drops_window_start.store(
now_ms.saturating_sub(EMERGENCY_WINDOW_MS + 1_000),
Ordering::Relaxed,
);
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(
state.is_emergency_mode(),
"detector must engage on sustained streak"
);
}
#[test]
fn test_update_emergency_mode_does_not_engage_on_brief_spike() {
let state = CloudState::new();
let config = CloudConfig::default();
let mut recovery_ticks = 0u32;
for _ in 0..(EMERGENCY_DROP_THRESHOLD + 1) {
state.record_live_drop();
}
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(
!state.is_emergency_mode(),
"detector must not engage on a brief spike (window too short)"
);
}
#[test]
fn test_update_emergency_mode_clears_after_two_clean_ticks() {
let state = CloudState::new();
let config = CloudConfig::default();
let mut recovery_ticks = 0u32;
state.set_emergency_mode(true);
assert!(state.is_emergency_mode());
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(state.is_emergency_mode());
assert_eq!(recovery_ticks, 1);
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(!state.is_emergency_mode());
}
#[test]
fn test_update_emergency_mode_engages_on_sustained_high_water() {
let state = CloudState::new();
let config = CloudConfig::default();
let mut recovery_ticks = 0u32;
let now_ms = super::super::now_unix_ms();
state.channel_high_water_start_ms.store(
now_ms.saturating_sub(HIGH_WATER_WINDOW_MS + 1_000),
Ordering::Relaxed,
);
assert_eq!(state.consecutive_live_drops(), 0);
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(
state.is_emergency_mode(),
"high-water sustained should engage emergency mode without live drops"
);
}
#[test]
fn test_update_emergency_mode_does_not_clear_while_high_water_active() {
let state = CloudState::new();
let config = CloudConfig::default();
let mut recovery_ticks = 0u32;
state.set_emergency_mode(true);
let now_ms = super::super::now_unix_ms();
state
.channel_high_water_start_ms
.store(now_ms.saturating_sub(500), Ordering::Relaxed);
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(state.is_emergency_mode());
assert_eq!(
recovery_ticks, 0,
"recovery debounce must stay at 0 while high-water remains"
);
}
#[test]
fn test_build_cloud_client_creates_client_with_pool_max_idle_per_host() {
let config = CloudConfig::default();
let _client = build_cloud_client(&config);
}
#[tokio::test]
async fn test_worker_exits_cleanly_when_channel_closed() {
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-key");
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
drop(tx);
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
CloudConfig::default(),
state,
dir.path().to_path_buf(),
None,
None,
));
let result = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
assert!(
result.is_ok(),
"worker must exit when channel is closed (timed out waiting)"
);
}
#[tokio::test]
async fn test_worker_skips_posts_when_no_credential_available() {
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::empty();
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
CloudConfig::default(),
state,
dir.path().to_path_buf(),
None,
None,
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({}),
agent_id: "agt_test".to_string(),
})
.await;
drop(tx);
let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
assert!(result.is_ok(), "worker must exit cleanly");
}
#[test]
fn test_persist_cloud_state_writes_valid_json_with_auth_error_true() {
let dir = tempfile::tempdir().unwrap();
persist_cloud_state(dir.path(), true).expect("persist must succeed");
let content = std::fs::read_to_string(dir.path().join("cloud_state.json"))
.expect("cloud_state.json must exist");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("must be valid JSON");
assert_eq!(
parsed["auth_error"], true,
"auth_error must be true: {content}"
);
assert!(
parsed["updated_at"].as_str().is_some(),
"updated_at must be present: {content}"
);
}
#[test]
fn test_persist_cloud_state_writes_valid_json_with_auth_error_false() {
let dir = tempfile::tempdir().unwrap();
persist_cloud_state(dir.path(), false).expect("persist must succeed");
let content = std::fs::read_to_string(dir.path().join("cloud_state.json"))
.expect("cloud_state.json must exist");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("must be valid JSON");
assert_eq!(parsed["auth_error"], false);
}
#[test]
fn test_persist_cloud_state_creates_parent_directory_if_missing() {
let base = tempfile::tempdir().unwrap();
let nested = base.path().join("a").join("b").join("c");
assert!(!nested.exists());
persist_cloud_state(&nested, false).expect("must create directories and write");
assert!(nested.join("cloud_state.json").exists());
}
#[tokio::test]
async fn test_worker_auth_error_set_when_credential_available_but_server_returns_401() {
use std::sync::atomic::Ordering;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(401)
.with_body("{}")
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_wait_ms: 50,
..Default::default()
};
let state_clone = state.clone();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
state_clone,
dir.path().to_path_buf(),
None,
None,
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({"id": "evt_test"}),
agent_id: "agt_test".to_string(),
})
.await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(
state.auth_error.load(Ordering::Relaxed),
"auth_error must be true after 401 response"
);
let state_path = dir.path().join("cloud_state.json");
assert!(state_path.exists(), "cloud_state.json must be written");
let content = std::fs::read_to_string(&state_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(parsed["auth_error"], true);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock.assert_async().await;
}
#[tokio::test]
async fn worker_startup_clears_a_stale_auth_error_left_by_a_dead_daemon() {
let dir = tempfile::tempdir().unwrap();
let state_path = dir.path().join("cloud_state.json");
persist_cloud_state(dir.path(), true).unwrap();
let stale: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
assert_eq!(
stale["auth_error"], true,
"precondition: stale latch on disk"
);
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
CloudConfig::default(),
state.clone(),
dir.path().to_path_buf(),
None,
None,
));
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
assert_eq!(
parsed["auth_error"], false,
"a freshly started worker must publish its own state, not inherit \
the previous daemon's latch"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
}
#[tokio::test]
async fn test_worker_retries_once_on_5xx_then_drops() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(500)
.with_body("{}")
.expect(2) .create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
retry_delay_ms: 10, ..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
state,
dir.path().to_path_buf(),
None,
None,
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({}),
agent_id: "agt_test".to_string(),
})
.await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock.assert_async().await;
}
#[tokio::test]
async fn test_worker_honors_retry_after_header_on_429() {
let mut server = mockito::Server::new_async().await;
let mock_429 = server
.mock("POST", "/api/v1/events/ingest")
.with_status(429)
.with_header("Retry-After", "1")
.with_body("{}")
.expect(1)
.create_async()
.await;
let mock_200 = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_body("{\"status\":\"accepted\"}")
.expect(1)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_wait_ms: 50,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
state,
dir.path().to_path_buf(),
None,
None,
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({}),
agent_id: "agt_test".to_string(),
})
.await;
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock_429.assert_async().await;
mock_200.assert_async().await;
}
#[tokio::test]
async fn test_health_tick_clears_consecutive_drops_on_2xx() {
let mut server = mockito::Server::new_async().await;
let mock_health = server
.mock("GET", "/api/v1/health")
.with_status(200)
.with_body(r#"{"status":"ok"}"#)
.expect_at_least(1)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
state.record_drop();
state.record_drop();
state.record_drop();
assert_eq!(state.consecutive_drops(), 3);
assert_eq!(state.drop_count(), 3);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let state_clone = state.clone();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
state_clone,
dir.path().to_path_buf(),
None,
None,
));
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
assert_eq!(
state.consecutive_drops(),
0,
"health tick must clear consecutive_drops on 2xx"
);
assert_eq!(
state.drop_count(),
3,
"lifetime drop_count must be preserved"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock_health.assert_async().await;
}
#[tokio::test]
async fn test_health_tick_does_not_clear_on_5xx() {
let mut server = mockito::Server::new_async().await;
let mock_health = server
.mock("GET", "/api/v1/health")
.with_status(500)
.with_body("{}")
.expect_at_least(1)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
state.record_drop();
state.record_drop();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let state_clone = state.clone();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
state_clone,
dir.path().to_path_buf(),
None,
None,
));
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
assert_eq!(
state.consecutive_drops(),
2,
"failed health probe must not reset consecutive_drops"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock_health.assert_async().await;
}
#[tokio::test]
async fn test_successful_post_clears_consecutive_drops() {
let mut server = mockito::Server::new_async().await;
let _mock_health = server
.mock("GET", "/api/v1/health")
.with_status(500)
.with_body("{}")
.create_async()
.await;
let mock_ingest = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_body(r#"{"status":"accepted"}"#)
.expect(1)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
state.record_drop();
state.record_drop();
state.record_drop();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_wait_ms: 50,
..Default::default()
};
let state_clone = state.clone();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
state_clone,
dir.path().to_path_buf(),
None,
None,
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({"id": "evt_test"}),
agent_id: "agt_test".to_string(),
})
.await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(
state.consecutive_drops(),
0,
"successful forward must clear consecutive_drops"
);
assert_eq!(state.drop_count(), 3, "lifetime drop_count preserved");
assert_eq!(state.forwarded_count(), 1);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock_ingest.assert_async().await;
}
#[tokio::test]
async fn test_worker_recovers_when_credential_appears_after_startup() {
let mut server = mockito::Server::new_async().await;
let mock_ingest = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_body(r#"{"status":"accepted"}"#)
.expect(1)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::empty();
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
credential_poll_interval_ms: 50,
..Default::default()
};
let provider_handle = provider.clone();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
state.clone(),
dir.path().to_path_buf(),
None,
None,
));
for i in 0..3 {
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({"id": format!("evt_{i}")}),
agent_id: "agt_test".to_string(),
})
.await;
}
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
assert_eq!(
state.forwarded_count(),
0,
"events must not be forwarded before a credential is available"
);
provider_handle.set_key("late-arriving-key");
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({"id": "evt_after_reload"}),
agent_id: "agt_test".to_string(),
})
.await;
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock_ingest.assert_async().await;
assert_eq!(
state.forwarded_count(),
1,
"exactly one event should be forwarded after credential hot-reload"
);
}
#[tokio::test]
async fn test_outbox_quarantines_on_persistent_5xx() {
use crate::core::cloud::outbox::Outbox;
let mut server = mockito::Server::new_async().await;
let _mock_ingest = server
.mock("POST", "/api/v1/events/ingest")
.with_status(500)
.with_body("boom")
.expect_at_least(2)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let config = CloudConfig {
api_url: server.url(),
retry_delay_ms: 5,
..Default::default()
};
outbox
.append(&serde_json::json!({"id": "evt_poison"}))
.unwrap();
assert_eq!(outbox.pending_count(), 1);
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
state.clone(),
dir.path().to_path_buf(),
Some(outbox.clone()),
None,
));
for _ in 0..(OUTBOX_MAX_ATTEMPTS as usize + 4) {
state.notify_drain();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
assert_eq!(
outbox.pending_count(),
0,
"quarantine must drain the outbox past the poison entry"
);
}
#[tokio::test]
async fn test_outbox_spools_on_failure_and_drains_on_recovery() {
use crate::core::cloud::outbox::Outbox;
let mut server = mockito::Server::new_async().await;
let mock_fail_ingest = server
.mock("POST", "/api/v1/events/ingest")
.with_status(500)
.with_body("boom")
.expect(2) .create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let config = CloudConfig {
api_url: server.url(),
batch_max_wait_ms: 50,
retry_delay_ms: 10,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
state.clone(),
dir.path().to_path_buf(),
Some(outbox.clone()),
None,
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({"id": "evt_offline_1"}),
agent_id: "agt_test".to_string(),
})
.await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
mock_fail_ingest.assert_async().await;
assert_eq!(outbox.pending_count(), 1, "envelope must be spooled");
assert_eq!(state.forwarded_count(), 0);
server.reset();
let mock_health = server
.mock("GET", "/api/v1/health")
.with_status(200)
.expect_at_least(1)
.create_async()
.await;
let mock_replay = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_body(r#"{"status":"accepted"}"#)
.expect(1)
.create_async()
.await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
mock_health.assert_async().await;
mock_replay.assert_async().await;
assert_eq!(
outbox.pending_count(),
0,
"outbox must be drained after cloud recovery"
);
assert!(
state.forwarded_count() >= 1,
"forwarded_count must reflect the replayed event, got {}",
state.forwarded_count()
);
}
type Captured = Arc<Mutex<Vec<String>>>;
async fn capture_ingest(
server: &mut mockito::ServerGuard,
status: usize,
) -> (mockito::Mock, Captured) {
let captured: Captured = Arc::new(Mutex::new(Vec::new()));
let sink = captured.clone();
let mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(status)
.with_body_from_request(move |req| {
let body = req
.utf8_lossy_body()
.map(|b| b.to_string())
.unwrap_or_default();
sink.lock().unwrap().push(body);
br#"{"status":"accepted"}"#.to_vec()
})
.expect_at_least(0)
.create_async()
.await;
(mock, captured)
}
async fn quiet_health(server: &mut mockito::ServerGuard) -> mockito::Mock {
server
.mock("GET", "/api/v1/health")
.with_status(500)
.with_body("{}")
.expect_at_least(0)
.create_async()
.await
}
fn evt(id: &str) -> CloudEvent {
CloudEvent {
envelope: serde_json::json!({"id": id, "specversion": "1.0"}),
agent_id: "agt_test".to_string(),
}
}
fn batch_sizes(captured: &Captured) -> Vec<usize> {
captured
.lock()
.unwrap()
.iter()
.map(|body| {
serde_json::from_str::<Vec<serde_json::Value>>(body)
.expect("every request body must be a JSON array")
.len()
})
.collect()
}
#[tokio::test]
async fn exact_request_count_for_n_events() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(200);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
state.clone(),
dir.path().to_path_buf(),
None,
None,
));
for i in 0..120 {
tx.send(evt(&format!("evt_{i:03}"))).await.unwrap();
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
assert_eq!(
batch_sizes(&captured),
vec![50, 50, 20],
"120 events at batch_max_events=50 must be exactly 3 requests"
);
assert_eq!(state.forwarded_count(), 120);
}
#[tokio::test]
async fn flushes_on_time_when_under_size() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 150,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
));
for i in 0..3 {
tx.send(evt(&format!("evt_{i}"))).await.unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
assert_eq!(
batch_sizes(&captured),
vec![3],
"3 events must leave as one time-triggered request"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn deadline_anchored_to_first_item() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 1000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
));
tx.send(evt("evt_first")).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
tx.send(evt("evt_second")).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
assert_eq!(
batch_sizes(&captured),
vec![2],
"the batch must flush 1000ms after the FIRST event, not after the last"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn no_request_exceeds_caps() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(400);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 100,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
));
let padding = "x".repeat(4096);
for i in 0..300 {
tx.send(CloudEvent {
envelope: serde_json::json!({"id": format!("evt_{i:03}"), "data": padding}),
agent_id: "agt_test".to_string(),
})
.await
.unwrap();
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(10), handle).await;
let bodies = captured.lock().unwrap().clone();
assert!(!bodies.is_empty(), "events must have been forwarded");
let mut total = 0usize;
let mut byte_capped = false;
for body in &bodies {
assert!(
body.len() <= MAX_BATCH_BYTES,
"request body of {} bytes exceeds the 256KB cap",
body.len()
);
let elements: Vec<serde_json::Value> = serde_json::from_str(body).unwrap();
assert!(
elements.len() <= MAX_BATCH_EVENTS,
"request carried {} events, over the 100-event cap",
elements.len()
);
if elements.len() < 100 {
byte_capped = true;
}
total += elements.len();
}
assert_eq!(total, 300, "every event must still be delivered");
assert!(
byte_capped,
"4KB payloads must close batches on the byte cap before the 100-event cap"
);
}
#[tokio::test]
async fn oversized_single_event_dropped() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 100,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
state.clone(),
dir.path().to_path_buf(),
None,
None,
));
tx.send(CloudEvent {
envelope: serde_json::json!({"id": "evt_huge", "data": "x".repeat(300_000)}),
agent_id: "agt_test".to_string(),
})
.await
.unwrap();
tx.send(evt("evt_ok_1")).await.unwrap();
tx.send(evt("evt_ok_2")).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
assert_eq!(
batch_sizes(&captured),
vec![2],
"only the two normal events may be posted — the queue must not wedge"
);
assert_eq!(
state.drop_count(),
1,
"the oversized event counts as a drop"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn failed_batch_spools_every_event() {
use crate::core::cloud::outbox::Outbox;
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 500).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(20);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 100,
retry_delay_ms: 10,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
state.clone(),
dir.path().to_path_buf(),
Some(outbox.clone()),
None,
));
for i in 0..7 {
tx.send(evt(&format!("evt_{i}"))).await.unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
assert_eq!(
batch_sizes(&captured),
vec![7, 7],
"one batch attempt plus exactly one retry"
);
let body = std::fs::read_to_string(outbox.path()).expect("outbox.jsonl must exist");
assert_eq!(
body.lines().count(),
7,
"a failed batch of 7 must spool 7 LINES, not one batch line: {body}"
);
for line in body.lines() {
serde_json::from_str::<serde_json::Value>(line)
.expect("every spooled line must be a standalone envelope");
}
assert_eq!(state.drop_count(), 7, "a spooled batch of 7 is 7 drops");
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn outbox_drain_rebatches() {
use crate::core::cloud::outbox::Outbox;
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
for i in 0..120 {
outbox
.append(&serde_json::json!({"id": format!("evt_{i:03}")}))
.unwrap();
}
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
CloudState::new(),
dir.path().to_path_buf(),
Some(outbox.clone()),
None,
));
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
assert_eq!(
batch_sizes(&captured),
vec![50, 50, 20],
"120 outbox entries must replay as 3 requests"
);
assert_eq!(outbox.pending_count(), 0, "the outbox must be empty");
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn shutdown_flushes_in_flight() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(20);
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
state.clone(),
dir.path().to_path_buf(),
None,
Some(shutdown_rx),
));
for i in 0..7 {
tx.send(evt(&format!("evt_{i}"))).await.unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
assert!(
captured.lock().unwrap().is_empty(),
"the batch must still be in flight — neither trigger has fired"
);
shutdown_tx.send(true).unwrap();
let exited = tokio::time::timeout(std::time::Duration::from_secs(3), handle).await;
assert!(exited.is_ok(), "worker must exit on the shutdown signal");
assert_eq!(
batch_sizes(&captured),
vec![7],
"all 7 buffered events must flush at shutdown — zero lost"
);
assert_eq!(state.forwarded_count(), 7);
drop(tx);
}
#[tokio::test]
async fn select_loop_loses_nothing() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(100);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 25,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
state.clone(),
dir.path().to_path_buf(),
None,
None,
));
for i in 0..1000u32 {
tx.send(evt(&format!("evt_{i:04}"))).await.unwrap();
match i % 7 {
0 => tokio::time::sleep(std::time::Duration::from_millis(1)).await,
3 => tokio::task::yield_now().await,
_ => {}
}
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), handle).await;
let delivered: usize = batch_sizes(&captured).iter().sum();
assert_eq!(delivered, 1000, "every enqueued event must be delivered");
assert_eq!(state.forwarded_count(), 1000);
}
#[tokio::test]
async fn agentid_stamped_on_every_envelope() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(20);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 3,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
));
for i in 0..9 {
tx.send(CloudEvent {
envelope: serde_json::json!({"id": format!("evt_{i}")}),
agent_id: format!("agt_{i}"),
})
.await
.unwrap();
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
let bodies = captured.lock().unwrap().clone();
assert_eq!(
bodies.len(),
3,
"9 events at batch_max_events=3 is 3 requests"
);
let mut seen = 0;
for body in &bodies {
let elements: Vec<serde_json::Value> = serde_json::from_str(body).unwrap();
for element in elements {
let id = element["id"].as_str().unwrap();
let n = id.trim_start_matches("evt_");
assert_eq!(
element["agentid"].as_str(),
Some(format!("agt_{n}").as_str()),
"every envelope must carry its own agentid: {element}"
);
seen += 1;
}
}
assert_eq!(seen, 9);
}
#[test]
fn clientversion_stamped_when_producer_omitted_it() {
let event = CloudEvent {
envelope: serde_json::json!({
"specversion": "1.0",
"id": "evt_boundary",
"source": "claude-code",
"type": "ai.openlatch.economics.usage",
"datacontenttype": "application/json",
"data": {"tokens": 42},
}),
agent_id: "agt_test".to_string(),
};
let stamped = stamp_extensions(&event);
assert_eq!(
stamped["clientversion"].as_str(),
Some(env!("OPENLATCH_VERSION")),
"an envelope that reaches egress without a version must leave with one"
);
assert_eq!(stamped["agentid"].as_str(), Some("agt_test"));
assert_eq!(stamped["data"]["tokens"], 42);
assert_eq!(stamped["type"], "ai.openlatch.economics.usage");
}
#[test]
fn clientversion_overwrites_a_stale_producer_value() {
let event = CloudEvent {
envelope: serde_json::json!({
"id": "evt_stale_hook",
"clientversion": "0.0.1-stale",
}),
agent_id: "agt_test".to_string(),
};
let stamped = stamp_extensions(&event);
assert_eq!(
stamped["clientversion"].as_str(),
Some(env!("OPENLATCH_VERSION")),
"the forwarder's version must win over the emitter's"
);
}
#[tokio::test]
async fn clientversion_stamped_on_every_envelope() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(20);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 3,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
));
for i in 0..6 {
let envelope = if i % 2 == 0 {
serde_json::json!({"id": format!("evt_{i}")})
} else {
serde_json::json!({"id": format!("evt_{i}"), "clientversion": "0.0.1-stale"})
};
tx.send(CloudEvent {
envelope,
agent_id: format!("agt_{i}"),
})
.await
.unwrap();
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
let bodies = captured.lock().unwrap().clone();
let mut seen = 0;
for body in &bodies {
let elements: Vec<serde_json::Value> = serde_json::from_str(body).unwrap();
for element in elements {
assert_eq!(
element["clientversion"].as_str(),
Some(env!("OPENLATCH_VERSION")),
"every envelope on the wire must carry the forwarder version: {element}"
);
seen += 1;
}
}
assert_eq!(seen, 6, "every enqueued event must reach the wire");
}
#[tokio::test]
async fn error_mapping_unchanged() {
let dir = tempfile::tempdir().unwrap();
let key = SecretString::from("test-api-key".to_string());
let batch = vec![prepare_one(serde_json::json!({"id": "evt_1"})).unwrap()];
for (status, header, expected) in [
(401u16, None, CloudError::AuthError),
(403, None, CloudError::AuthError),
(
429,
Some(("Retry-After", "7")),
CloudError::RateLimit {
retry_after_secs: 7,
},
),
(500, None, CloudError::ServerError),
(503, None, CloudError::ServerError),
(418, None, CloudError::ClientError(418)),
] {
let mut server = mockito::Server::new_async().await;
let mut mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(status as usize);
if let Some((name, value)) = header {
mock = mock.with_header(name, value);
}
let _mock = mock.with_body("{}").create_async().await;
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let client = build_cloud_client(&config);
let err = post_batch(&client, &config, &key, &batch, dir.path())
.await
.expect_err("non-2xx must map to an error");
assert_eq!(
format!("{err:?}"),
format!("{expected:?}"),
"HTTP {status} must map to {expected:?}"
);
}
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(429)
.with_body("{}")
.create_async()
.await;
let config = CloudConfig {
api_url: server.url(),
rate_limit_default_secs: 42,
..Default::default()
};
let client = build_cloud_client(&config);
let err = post_batch(&client, &config, &key, &batch, dir.path())
.await
.unwrap_err();
assert!(
matches!(
err,
CloudError::RateLimit {
retry_after_secs: 42
}
),
"missing Retry-After must fall back to rate_limit_default_secs, got {err:?}"
);
let config = CloudConfig {
api_url: "http://127.0.0.1:1".to_string(),
timeout_connect_ms: 500,
timeout_total_ms: 1000,
..Default::default()
};
let client = build_cloud_client(&config);
let err = post_batch(&client, &config, &key, &batch, dir.path())
.await
.unwrap_err();
assert!(
matches!(err, CloudError::Network),
"transport failure must map to Network, got {err:?}"
);
}
#[tokio::test]
async fn existing_select_branches_still_fire() {
let mut server = mockito::Server::new_async().await;
let health = server
.mock("GET", "/api/v1/health")
.with_status(200)
.with_body(r#"{"status":"ok"}"#)
.expect_at_least(1)
.create_async()
.await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(20);
let provider = TestCredentialProvider::with_key("test-api-key");
let observer = provider.clone();
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
credential_poll_interval_ms: 50,
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
state.clone(),
dir.path().to_path_buf(),
None,
None,
));
for i in 0..3 {
tx.send(evt(&format!("evt_{i}"))).await.unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
assert!(
captured.lock().unwrap().is_empty(),
"the buffer must still be held — otherwise this proves nothing"
);
health.assert_async().await;
assert!(
observer.retrievals() >= 2,
"the credential-poll branch must keep firing while a batch waits, got {} retrievals",
observer.retrievals()
);
assert_eq!(
state.consecutive_probe_failures(),
0,
"successful health probes must still be recorded"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), handle).await;
assert_eq!(batch_sizes(&captured), vec![3]);
}
#[tokio::test]
async fn due_timer_wins_over_saturated_channel() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(64);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 100_000,
batch_max_wait_ms: 25,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
));
let producer_tx = tx.clone();
let producer = tokio::spawn(async move {
for i in 0..2000u32 {
if producer_tx.send(evt(&format!("evt_{i:04}"))).await.is_err() {
break;
}
if i % 20 == 0 {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
}
});
producer.await.unwrap();
let mid_stream = captured.lock().unwrap().len();
assert!(
mid_stream >= 2,
"the due timer must win against a continuously-ready channel — \
expected multiple mid-stream flushes, got {mid_stream}"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(10), handle).await;
}
#[tokio::test]
async fn forwarded_count_is_batch_length() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(100);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
state.clone(),
dir.path().to_path_buf(),
None,
None,
));
for i in 0..50 {
tx.send(evt(&format!("evt_{i:02}"))).await.unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
assert_eq!(
batch_sizes(&captured),
vec![50],
"one request for 50 events"
);
assert_eq!(
state.forwarded_count(),
50,
"forwarded_count must be the batch LENGTH, not 1"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
fn prepared_of(size: usize, count: usize) -> Vec<PreparedEvent> {
(0..count)
.map(|i| {
prepare_one(serde_json::json!({"id": format!("evt_{i}"), "data": "x".repeat(size)}))
.expect("fixture must be sendable")
})
.collect()
}
#[test]
fn split_batches_caps_on_element_count() {
let prepared = prepared_of(8, 250);
let groups = split_batches(&prepared, 50);
assert_eq!(
groups.iter().map(|g| g.len()).collect::<Vec<_>>(),
vec![50; 5]
);
}
#[test]
fn split_batches_never_exceeds_the_platform_element_cap() {
let prepared = prepared_of(8, 250);
let groups = split_batches(&prepared, 100_000);
assert!(groups.iter().all(|g| g.len() <= MAX_BATCH_EVENTS));
assert_eq!(groups[0].len(), MAX_BATCH_EVENTS);
}
#[test]
fn split_batches_closes_on_the_byte_cap() {
let prepared = prepared_of(16_000, 40);
let groups = split_batches(&prepared, 100);
for group in &groups {
let framed: usize = group.iter().map(|e| e.json.len()).sum::<usize>()
+ 2
+ group.len().saturating_sub(1);
assert!(
framed <= MAX_BATCH_BYTES,
"group of {} serialises to {framed} bytes",
group.len()
);
}
assert_eq!(
groups.iter().map(|g| g.len()).sum::<usize>(),
40,
"the split must not lose events"
);
assert!(groups.len() > 1, "40 x 16KB cannot be one request");
}
#[test]
fn split_batches_handles_a_single_event() {
let prepared = prepared_of(8, 1);
let groups = split_batches(&prepared, 50);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 1);
}
#[test]
fn prepare_one_rejects_an_unbatchable_event() {
assert!(
prepare_one(serde_json::json!({"id": "big", "data": "x".repeat(300_000)})).is_none(),
"an event that cannot fit in any batch must be dropped, not retried forever"
);
assert!(prepare_one(serde_json::json!({"id": "small"})).is_some());
}
}