#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::collections::BTreeMap;
use std::fmt;
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, Instant};
use serde::Serialize;
use super::super::manifest::SandboxCapability;
use super::DenialReason;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum CapabilityOutcome {
Allowed,
QuotaExceeded,
Denied(DenialReason),
}
impl fmt::Display for CapabilityOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Allowed => f.write_str("allowed"),
Self::QuotaExceeded => f.write_str("quota-exceeded"),
Self::Denied(reason) => write!(f, "denied:{reason}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct CapabilityEvent {
pub capability: SandboxCapability,
pub operation: &'static str,
pub target: String,
pub outcome: CapabilityOutcome,
#[serde(skip)]
pub at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct DroppedCalls {
pub count: u64,
pub first: Instant,
pub last: Instant,
}
pub const MAX_LOG_EVENTS: usize = 4096;
#[derive(Debug, Default)]
pub struct PluginActivityLog {
state: Mutex<LogState>,
}
#[derive(Debug, Default)]
struct LogState {
entries: BTreeMap<(Instant, u64), (String, CapabilityEvent)>,
dropped: BTreeMap<(Instant, u64), DroppedRecord>,
seq: u64,
folded: BTreeMap<(String, u64), (Instant, u64)>,
origin: Option<Instant>,
shed: Option<Instant>,
}
fn apart(a: Instant, b: Instant) -> Duration {
a.max(b).saturating_duration_since(a.min(b))
}
#[derive(Debug)]
struct DroppedRecord {
plugin: String,
count: u64,
first: Instant,
}
const FOLD_BUCKET_SECS: u64 = 3600;
const DROPPED_BUCKET: Duration = Duration::from_secs(1);
impl PluginActivityLog {
#[must_use]
pub fn new() -> Self {
let state = LogState {
origin: Some(crate::time::ambient_instant()),
..LogState::default()
};
Self {
state: Mutex::new(state),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, LogState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn ingest(&self, plugin: &str, events: impl IntoIterator<Item = CapabilityEvent>) {
let mut state = self.lock();
for event in events {
if state.entries.len() >= MAX_LOG_EVENTS
&& let Some(((at, _), (who, _))) = state.entries.pop_first()
{
state.note_dropped(at, at, &who, 1);
}
state.seq = state.seq.wrapping_add(1);
let key = (event.at, state.seq);
state.entries.insert(key, (plugin.to_owned(), event));
}
}
pub fn ingest_dropped(&self, plugin: &str, dropped: u64) {
if dropped == 0 {
return;
}
let now = crate::time::ambient_instant();
self.lock().note_dropped(now, now, plugin, dropped);
}
pub fn ingest_dropped_calls(&self, plugin: &str, calls: DroppedCalls) {
if calls.count == 0 {
return;
}
let (first, last) = (calls.first.min(calls.last), calls.first.max(calls.last));
self.lock().note_dropped(first, last, plugin, calls.count);
}
#[must_use]
pub fn summary(&self, plugin: &str, window: Duration) -> ActivitySummary {
let cutoff = crate::time::ambient_instant();
let in_window = |at: Instant| cutoff.saturating_duration_since(at) <= window;
let mut summary = ActivitySummary {
plugin: plugin.to_owned(),
window,
..ActivitySummary::default()
};
let state = self.lock();
for ((at, _), (who, event)) in &state.entries {
if who == plugin && in_window(*at) {
summary.record(event);
}
}
summary.dropped = state
.dropped
.iter()
.filter(|((at, _), record)| record.plugin == plugin && in_window(*at))
.fold(0_u64, |sum, (_, record)| sum.saturating_add(record.count));
let folded: u64 = state
.folded
.range((plugin.to_owned(), 0)..=(plugin.to_owned(), u64::MAX))
.filter(|(_, (at, _))| in_window(*at))
.fold(0_u64, |sum, (_, (_, count))| sum.saturating_add(*count));
summary.dropped = summary.dropped.saturating_add(folded);
summary.truncated = state.shed.is_some_and(in_window);
drop(state);
summary
}
#[must_use]
pub fn plugins(&self) -> Vec<String> {
let mut names: Vec<String> = {
let state = self.lock();
state
.entries
.values()
.map(|(who, _)| who.clone())
.chain(state.dropped.values().map(|record| record.plugin.clone()))
.chain(state.folded.keys().map(|(who, _)| who.clone()))
.collect()
};
names.sort();
names.dedup();
names
}
}
impl LogState {
fn note_dropped(&mut self, first: Instant, last: Instant, plugin: &str, count: u64) {
if let Some((&(newest, seq), record)) = self.dropped.last_key_value()
&& record.plugin == plugin
&& apart(first, record.first) < DROPPED_BUCKET
&& apart(last, record.first) < DROPPED_BUCKET
&& apart(last, newest) < DROPPED_BUCKET
{
if let Some(mut record) = self.dropped.remove(&(newest, seq)) {
record.count = record.count.saturating_add(count);
record.first = record.first.min(first);
self.dropped.insert((newest.max(last), seq), record);
}
return;
}
self.seq = self.seq.wrapping_add(1);
self.dropped.insert(
(last, self.seq),
DroppedRecord {
plugin: plugin.to_owned(),
count,
first,
},
);
while self.dropped.len() > MAX_LOG_EVENTS {
let Some((
(oldest, _),
DroppedRecord {
plugin: who, count, ..
},
)) = self.dropped.pop_first()
else {
break;
};
let origin = *self.origin.get_or_insert(oldest);
let hour = oldest.saturating_duration_since(origin).as_secs() / FOLD_BUCKET_SECS;
let folded = self.folded.entry((who, hour)).or_insert((oldest, 0));
folded.0 = folded.0.max(oldest);
folded.1 = folded.1.saturating_add(count);
}
while self.folded.len() > MAX_LOG_EVENTS {
let Some(oldest) = self.folded.keys().min_by_key(|(_, hour)| *hour).cloned() else {
break;
};
if let Some((at, _)) = self.folded.remove(&oldest) {
self.shed = Some(self.shed.map_or(at, |seen| seen.max(at)));
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct ActivitySummary {
pub allowed: BTreeMap<&'static str, u64>,
pub denied: BTreeMap<&'static str, u64>,
pub quota_hits: BTreeMap<&'static str, u64>,
pub hosts: BTreeMap<String, u64>,
pub tables: BTreeMap<String, u64>,
pub job_types: BTreeMap<String, u64>,
pub refused_targets: BTreeMap<String, u64>,
pub plugin: String,
pub window: Duration,
pub dropped: u64,
pub truncated: bool,
}
impl ActivitySummary {
fn record(&mut self, event: &CapabilityEvent) {
let bucket = match event.outcome {
CapabilityOutcome::Allowed => &mut self.allowed,
CapabilityOutcome::QuotaExceeded => &mut self.quota_hits,
CapabilityOutcome::Denied(_) => &mut self.denied,
};
let counter = bucket.entry(event.operation).or_default();
*counter = counter.saturating_add(1);
let allowed = matches!(
event.outcome,
CapabilityOutcome::Allowed
| CapabilityOutcome::Denied(
DenialReason::ResponseTooLarge
| DenialReason::ResultRejected
| DenialReason::BackendError
)
);
let ran = matches!(
event.outcome,
CapabilityOutcome::Allowed
| CapabilityOutcome::Denied(
DenialReason::ResponseTooLarge | DenialReason::ResultRejected
)
);
let enqueued = ran;
let touched = ran;
let bucket = match (event.capability, allowed) {
(SandboxCapability::HttpOutbound, true) => Some(&mut self.hosts),
(SandboxCapability::Db, true) if touched => Some(&mut self.tables),
(SandboxCapability::Jobs, true) if enqueued => Some(&mut self.job_types),
(
SandboxCapability::HttpOutbound | SandboxCapability::Db | SandboxCapability::Jobs,
_,
) => Some(&mut self.refused_targets),
_ => None,
};
if let Some(bucket) = bucket {
let counter = bucket.entry(event.target.clone()).or_default();
*counter = counter.saturating_add(1);
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.allowed.is_empty() && self.denied.is_empty() && self.quota_hits.is_empty()
}
fn write_report(&self, out: &mut String) {
use std::fmt::Write as _;
let _ = writeln!(
out,
"sandboxed plugin `{plugin}` — last {secs}s",
plugin = self.plugin,
secs = self.window.as_secs()
);
if self.is_empty() && self.dropped == 0 && !self.truncated {
out.push_str(" no capability calls\n");
return;
}
if self.truncated {
out.push_str(
" \u{26A0} the log shed older drop counts. Every number here is a floor\n",
);
}
if self.dropped > 0 {
let _ = writeln!(
out,
" \u{26A0} {dropped} further call(s) were made and are NOT counted below: \
every number here is a floor",
dropped = self.dropped
);
}
for (label, bucket) in [
("performed", &self.allowed),
("denied", &self.denied),
("over quota", &self.quota_hits),
] {
if bucket.is_empty() {
continue;
}
let _ = writeln!(out, " {label}:");
for (operation, count) in bucket {
let _ = writeln!(out, " {operation} × {count}");
}
}
for (label, bucket) in [
("hosts called", &self.hosts),
("tables touched", &self.tables),
("jobs enqueued", &self.job_types),
("targets refused", &self.refused_targets),
] {
if bucket.is_empty() {
continue;
}
let _ = writeln!(out, " {label}:");
for (target, count) in bucket {
let _ = writeln!(out, " {target} × {count}");
}
}
}
}
impl fmt::Display for ActivitySummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut out = String::new();
self.write_report(&mut out);
f.write_str(&out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin_sandbox::manifest::SandboxCapability;
fn event(operation: &'static str, outcome: CapabilityOutcome) -> CapabilityEvent {
CapabilityEvent {
capability: SandboxCapability::Kv,
operation,
target: "cart".to_owned(),
outcome,
at: crate::time::ambient_instant(),
}
}
#[test]
fn a_log_from_a_nested_timeline_ages_out_on_the_outer_one() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let hour = Duration::from_secs(3600);
let log = PluginActivityLog::new();
let outer = TickingClock::starting_at(epoch);
let _outer = install_ambient(Arc::new(outer.clone()));
let inner = TickingClock::starting_at(epoch);
let guard = install_ambient(Arc::new(inner.clone()));
inner.advance(2 * hour);
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
log.ingest_dropped("shop", 3);
drop(guard);
let summary = log.summary("shop", hour);
assert_eq!(summary.allowed.get("kv-get").copied(), Some(1));
assert_eq!(summary.dropped, 3);
outer.advance(hour + Duration::from_secs(1));
let summary = log.summary("shop", hour);
assert_eq!(summary.allowed.get("kv-get"), None);
assert_eq!(summary.dropped, 0);
}
#[test]
fn a_request_s_drops_count_while_its_last_drop_is_in_the_window() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let minute = Duration::from_secs(60);
let clock = TickingClock::starting_at(
chrono::Utc
.with_ymd_and_hms(2020, 1, 1, 0, 0, 0)
.single()
.expect("a valid date"),
);
let _clock = install_ambient(Arc::new(clock.clone()));
let log = PluginActivityLog::new();
let first = crate::time::ambient_instant();
clock.advance(90 * minute);
let last = crate::time::ambient_instant();
clock.advance(30 * minute);
log.ingest_dropped_calls(
"shop",
DroppedCalls {
count: 4,
first,
last,
},
);
assert_eq!(log.summary("shop", 40 * minute).dropped, 4);
assert_eq!(log.summary("shop", 20 * minute).dropped, 0);
log.ingest_dropped("shop", 1);
assert_eq!(log.summary("shop", 20 * minute).dropped, 1);
assert_eq!(log.summary("shop", 40 * minute).dropped, 5);
}
#[test]
fn the_ring_evicts_the_oldest_rather_than_growing() {
let log = PluginActivityLog::new();
log.ingest(
"shop",
(0..MAX_LOG_EVENTS + 100).map(|_| event("kv-get", CapabilityOutcome::Allowed)),
);
let summary = log.summary("shop", Duration::from_secs(3600));
assert_eq!(
summary.allowed.get("kv-get").copied(),
Some(MAX_LOG_EVENTS as u64),
"the log holds its ceiling and no more"
);
}
#[test]
fn a_summary_covers_one_plugin_and_one_window() {
let log = PluginActivityLog::new();
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
log.ingest("other", [event("kv-set", CapabilityOutcome::Allowed)]);
let shop = log.summary("shop", Duration::from_secs(3600));
assert_eq!(shop.allowed.get("kv-get").copied(), Some(1));
assert!(!shop.allowed.contains_key("kv-set"), "{shop:?}");
assert!(log.summary("shop", Duration::ZERO).is_empty());
assert!(log.summary("nobody", Duration::from_secs(3600)).is_empty());
assert_eq!(log.plugins(), vec!["other".to_owned(), "shop".to_owned()]);
}
#[test]
fn a_truncated_summary_says_so_before_it_says_anything_else() {
let log = PluginActivityLog::new();
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
log.ingest_dropped("shop", 0);
assert_eq!(log.summary("shop", Duration::from_secs(3600)).dropped, 0);
log.ingest_dropped("shop", 7);
log.ingest_dropped("shop", 5);
let summary = log.summary("shop", Duration::from_secs(3600));
assert_eq!(summary.dropped, 12);
let rendered = summary.to_string();
assert!(rendered.contains("floor"), "{rendered}");
let warn = rendered.find("floor").unwrap_or(usize::MAX);
let count = rendered.find("kv-get").unwrap_or(0);
assert!(warn < count, "{rendered}");
}
#[test]
fn a_summary_names_the_window_it_was_taken_over() {
let log = PluginActivityLog::new();
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
let rendered = log.summary("shop", Duration::from_secs(60)).to_string();
assert!(rendered.contains("last 60s"), "{rendered}");
assert!(rendered.contains("`shop`"), "{rendered}");
}
#[test]
fn an_event_the_ring_evicted_is_reported_as_dropped_rather_than_forgotten() {
let log = PluginActivityLog::new();
for _ in 0..MAX_LOG_EVENTS + 100 {
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
}
let summary = log.summary("shop", Duration::from_secs(3600));
assert_eq!(
summary.allowed.get("kv-get").copied(),
Some(MAX_LOG_EVENTS as u64),
"the ring still holds its ceiling"
);
assert_eq!(summary.dropped, 100, "and says what it could not hold");
assert!(
summary.to_string().contains("NOT counted below"),
"{summary}"
);
}
#[test]
fn a_plugin_visible_only_as_drops_is_still_enumerated() {
let log = PluginActivityLog::new();
log.ingest_dropped("quiet", 7);
for _ in 0..MAX_LOG_EVENTS {
log.ingest("busy", [event("kv-get", CapabilityOutcome::Allowed)]);
}
let names = log.plugins();
assert!(
names.contains(&"quiet".to_owned()),
"a plugin the log can summarise must be one the log can name: {names:?}"
);
assert!(names.contains(&"busy".to_owned()), "{names:?}");
assert_eq!(
log.summary("quiet", Duration::from_secs(3600)).dropped,
7,
"and the summary it was omitted from is not empty"
);
}
#[test]
fn a_dropped_count_leaves_the_window_with_the_calls_it_describes() {
let log = PluginActivityLog::new();
log.ingest_dropped("shop", 9);
assert_eq!(log.summary("shop", Duration::from_secs(3600)).dropped, 9);
assert_eq!(
log.summary("shop", Duration::ZERO).dropped,
0,
"a window covering nothing covers no drops either"
);
assert_eq!(
log.summary("other", Duration::from_secs(3600)).dropped,
0,
"and a drop belongs to the plugin that made it"
);
}
#[test]
fn the_dropped_ledger_is_itself_bounded() {
let log = PluginActivityLog::new();
for _ in 0..MAX_LOG_EVENTS * 3 {
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
}
let held = log
.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.dropped
.len();
assert!(held <= MAX_LOG_EVENTS, "the dropped ring grew to {held}");
assert!(
log.summary("shop", Duration::from_secs(3600)).dropped >= 1,
"and still reports the drops"
);
}
#[test]
fn a_refused_enqueue_is_not_reported_as_a_job() {
let log = PluginActivityLog::new();
log.ingest(
"shop",
[
CapabilityEvent {
capability: SandboxCapability::Jobs,
operation: "job-enqueue",
target: "reindex".to_owned(),
outcome: CapabilityOutcome::Allowed,
at: crate::time::ambient_instant(),
},
CapabilityEvent {
capability: SandboxCapability::Jobs,
operation: "job-enqueue",
target: "reindex".to_owned(),
outcome: CapabilityOutcome::Denied(DenialReason::BackendError),
at: crate::time::ambient_instant(),
},
],
);
let summary = log.summary("shop", Duration::from_secs(3600));
assert_eq!(
summary.job_types.get("reindex").copied(),
Some(1),
"only the accepted enqueue is a job: {summary:?}"
);
assert_eq!(
summary.refused_targets.get("reindex").copied(),
Some(1),
"and the refusal is still visible: {summary:?}"
);
}
fn db_event(outcome: CapabilityOutcome) -> CapabilityEvent {
CapabilityEvent {
capability: SandboxCapability::Db,
operation: "db-insert",
target: "orders".to_owned(),
outcome,
at: crate::time::ambient_instant(),
}
}
#[test]
fn a_failed_db_call_is_not_a_table_touched() {
let log = PluginActivityLog::new();
log.ingest(
"shop",
[
db_event(CapabilityOutcome::Allowed),
db_event(CapabilityOutcome::Denied(DenialReason::BackendError)),
],
);
let summary = log.summary("shop", Duration::from_secs(3600));
assert_eq!(
summary.tables.get("orders").copied(),
Some(1),
"{summary:?}"
);
assert_eq!(
summary.refused_targets.get("orders").copied(),
Some(1),
"{summary:?}"
);
}
#[test]
fn an_event_keeps_the_time_it_happened() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let hour = Duration::from_secs(3600);
let clock = TickingClock::starting_at(epoch);
let _guard = install_ambient(Arc::new(clock.clone()));
let log = PluginActivityLog::new();
let early = event("kv-get", CapabilityOutcome::Allowed);
clock.advance(2 * hour);
let late = event("kv-set", CapabilityOutcome::Allowed);
log.ingest("shop", [early, late]);
let summary = log.summary("shop", hour);
assert_eq!(summary.allowed.get("kv-get"), None, "{summary:?}");
assert_eq!(summary.allowed.get("kv-set").copied(), Some(1));
}
#[test]
fn a_full_dropped_ledger_keeps_every_count() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let clock = TickingClock::starting_at(epoch);
let _guard = install_ambient(Arc::new(clock.clone()));
let log = PluginActivityLog::new();
let records = MAX_LOG_EVENTS + 50;
for _ in 0..records {
log.ingest_dropped("shop", 1);
clock.advance(Duration::from_secs(2));
}
let summary = log.summary("shop", Duration::from_secs(86_400));
assert_eq!(summary.dropped, records as u64);
}
#[test]
fn the_dropped_ledger_evicts_the_oldest_time_not_the_oldest_insert() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let hour = Duration::from_secs(3600);
let clock = TickingClock::starting_at(epoch);
let _guard = install_ambient(Arc::new(clock.clone()));
let log = PluginActivityLog::new();
let old: Vec<_> = (0..MAX_LOG_EVENTS)
.map(|_| event("kv-get", CapabilityOutcome::Allowed))
.collect();
clock.advance(10 * hour);
for _ in 0..MAX_LOG_EVENTS {
log.ingest_dropped("shop", 1);
clock.advance(Duration::from_secs(2));
}
log.ingest("shop", old);
log.ingest(
"shop",
(0..10).map(|_| event("kv-set", CapabilityOutcome::Allowed)),
);
log.ingest_dropped("shop", 1);
let recent = log.summary("shop", Duration::from_secs(3 * 3600));
assert_eq!(recent.dropped, MAX_LOG_EVENTS as u64 + 1);
let wide = log.summary("shop", 24 * hour);
assert!(wide.dropped > recent.dropped, "{}", wide.dropped);
}
#[test]
fn a_steady_dropper_does_not_stretch_one_record_across_hours() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let clock = TickingClock::starting_at(epoch);
let _guard = install_ambient(Arc::new(clock.clone()));
let log = PluginActivityLog::new();
for _ in 0..60_000 {
log.ingest_dropped("shop", 1);
clock.advance(Duration::from_millis(300));
}
let hour = log.summary("shop", Duration::from_secs(3600)).dropped;
assert!(hour <= 12_000 + 4, "the last hour reported {hour} drops");
assert!(hour >= 12_000, "the last hour lost drops: {hour}");
let all = log.summary("shop", Duration::from_secs(6 * 3600)).dropped;
assert_eq!(all, 60_000);
}
#[test]
fn a_summary_never_sees_a_call_in_neither_ledger() {
use std::sync::Arc;
let log = Arc::new(PluginActivityLog::new());
log.ingest(
"shop",
(0..MAX_LOG_EVENTS).map(|_| event("kv-get", CapabilityOutcome::Allowed)),
);
let writer = {
let log = Arc::clone(&log);
std::thread::spawn(move || {
for _ in 0..20_000 {
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
}
})
};
let mut last = 0_u64;
for _ in 0..2_000 {
let summary = log.summary("shop", Duration::from_secs(3600));
let seen = summary.allowed.get("kv-get").copied().unwrap_or(0) + summary.dropped;
assert!(
seen >= last,
"a call vanished mid-transfer: {seen} < {last}"
);
last = seen;
}
writer.join().expect("the writer finishes");
}
#[test]
fn the_ring_evicts_the_oldest_event_by_time_not_by_arrival() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let hour = Duration::from_secs(3600);
let clock = TickingClock::starting_at(epoch);
let _guard = install_ambient(Arc::new(clock.clone()));
let log = PluginActivityLog::new();
let old: Vec<_> = (0..MAX_LOG_EVENTS)
.map(|_| event("kv-get", CapabilityOutcome::Allowed))
.collect();
clock.advance(10 * hour);
log.ingest("shop", [event("kv-set", CapabilityOutcome::Allowed)]);
log.ingest("shop", old);
log.ingest("shop", [event("kv-delete", CapabilityOutcome::Allowed)]);
let recent = log.summary("shop", hour);
assert_eq!(recent.allowed.get("kv-set").copied(), Some(1), "{recent:?}");
assert_eq!(recent.allowed.get("kv-delete").copied(), Some(1));
}
#[test]
fn folded_counts_leave_the_window_with_their_hour() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let hour = Duration::from_secs(3600);
let clock = TickingClock::starting_at(epoch);
let _guard = install_ambient(Arc::new(clock.clone()));
let log = PluginActivityLog::new();
for _ in 0..MAX_LOG_EVENTS {
log.ingest_dropped("shop", 1);
clock.advance(Duration::from_secs(2));
}
clock.advance(10 * hour);
for _ in 0..MAX_LOG_EVENTS {
log.ingest_dropped("shop", 1);
clock.advance(Duration::from_secs(2));
}
let recent = log.summary("shop", 3 * hour);
assert_eq!(recent.dropped, MAX_LOG_EVENTS as u64, "{}", recent.dropped);
let all = log.summary("shop", 100 * hour);
assert_eq!(all.dropped, 2 * MAX_LOG_EVENTS as u64);
}
#[test]
fn a_fold_from_before_the_first_overflow_keeps_its_own_hour() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let hour = Duration::from_secs(3600);
let clock = TickingClock::starting_at(epoch);
let _guard = install_ambient(Arc::new(clock.clone()));
let log = PluginActivityLog::new();
let old: Vec<_> = (0..MAX_LOG_EVENTS)
.map(|_| event("kv-get", CapabilityOutcome::Allowed))
.collect();
log.ingest("shop", old);
clock.advance(10 * hour);
for _ in 0..MAX_LOG_EVENTS + 5 {
log.ingest_dropped("shop", 1);
clock.advance(Duration::from_secs(2));
}
log.ingest(
"shop",
(0..5).map(|_| event("kv-set", CapabilityOutcome::Allowed)),
);
let recent = log.summary("shop", 3 * hour);
assert_eq!(
recent.dropped,
MAX_LOG_EVENTS as u64 + 5,
"{}",
recent.dropped
);
}
#[test]
fn a_summary_says_so_when_the_log_lost_drop_counts() {
let log = PluginActivityLog::new();
let hour = Duration::from_secs(3600);
for n in 0..(MAX_LOG_EVENTS * 2 + 10) {
log.ingest_dropped(&format!("p{n}"), 1);
}
assert!(
log.summary("p0", hour).truncated,
"counts were lost, and the summary must say so"
);
assert!(
!PluginActivityLog::new().summary("p0", hour).truncated,
"a log that lost nothing is not truncated"
);
}
#[test]
fn a_truncation_leaves_the_window_with_its_time() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let hour = Duration::from_secs(3600);
let clock = TickingClock::starting_at(epoch);
let _guard = install_ambient(Arc::new(clock.clone()));
let log = PluginActivityLog::new();
for n in 0..(MAX_LOG_EVENTS * 2 + 10) {
log.ingest_dropped(&format!("p{n}"), 1);
}
clock.advance(10 * hour);
assert!(!log.summary("p0", hour).truncated, "the loss is old");
assert!(log.summary("p0", 11 * hour).truncated);
}
#[test]
fn truncation_is_exact_at_the_window_edge_and_reaches_every_plugin() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let hour = Duration::from_secs(3600);
let clock = TickingClock::starting_at(epoch);
let _guard = install_ambient(Arc::new(clock.clone()));
let log = PluginActivityLog::new();
for n in 0..(MAX_LOG_EVENTS * 2 + 10) {
log.ingest_dropped(&format!("p{n}"), 1);
}
let shed = log.lock().shed.expect("a count was shed");
clock.advance(hour);
let age = crate::time::ambient_instant().saturating_duration_since(shed);
assert!(log.summary("other", age).truncated, "at the edge");
assert!(
!log.summary("other", age.saturating_sub(Duration::from_millis(1)))
.truncated,
"just outside"
);
}
#[test]
fn a_truncated_summary_is_not_reported_as_empty() {
let summary = ActivitySummary {
plugin: "shop".to_owned(),
truncated: true,
..ActivitySummary::default()
};
let text = summary.to_string();
assert!(text.contains("shed older drop counts"), "{text}");
assert!(!text.contains("no capability calls"), "{text}");
}
}