use std::collections::VecDeque;
use std::sync::{Mutex, PoisonError};
use serde::Serialize;
pub const DEFAULT_CAPACITY: usize = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
Write,
BulkWrite,
Management,
Ingest,
}
#[derive(Debug, Clone, Serialize)]
pub struct Rejection {
pub when: i64,
pub namespace: String,
pub value: String,
pub reason: String,
pub source: Source,
}
#[derive(Debug)]
pub struct Rejections {
entries: Mutex<VecDeque<Rejection>>,
capacity: usize,
}
impl Default for Rejections {
fn default() -> Self {
Self::new(DEFAULT_CAPACITY)
}
}
impl Rejections {
pub fn new(capacity: usize) -> Self {
Self {
entries: Mutex::new(VecDeque::with_capacity(capacity.min(DEFAULT_CAPACITY))),
capacity,
}
}
pub fn enabled(&self) -> bool {
self.capacity > 0
}
pub fn record(&self, namespace: &str, value: &str, reason: &str, source: Source) {
if !self.enabled() {
return;
}
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
while entries.len() >= self.capacity {
entries.pop_front();
}
entries.push_back(Rejection {
when: chrono::Utc::now().timestamp(),
namespace: namespace.to_string(),
value: value.to_string(),
reason: reason.to_string(),
source,
});
}
pub fn recent(&self, namespace: Option<&str>, limit: usize) -> Vec<Rejection> {
let entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
entries
.iter()
.rev()
.filter(|entry| match namespace {
Some(wanted) => {
entry.namespace == wanted
|| entry
.namespace
.strip_prefix(wanted)
.is_some_and(|rest| rest.starts_with('/'))
}
None => true,
})
.take(limit)
.cloned()
.collect()
}
pub fn capacity(&self) -> usize {
self.capacity
}
pub fn len(&self) -> usize {
self.entries
.lock()
.unwrap_or_else(PoisonError::into_inner)
.len()
}
pub fn clear(&self) {
self.entries
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn log() -> Rejections {
Rejections::new(3)
}
#[test]
fn the_oldest_entry_goes_when_the_buffer_is_full() {
let log = log();
for value in ["a", "b", "c", "d"] {
log.record("ns", value, "nope", Source::Ingest);
}
assert_eq!(log.len(), 3, "the cap was not honoured");
let recent = log.recent(None, 10);
let values: Vec<&str> = recent.iter().map(|r| r.value.as_str()).collect();
assert_eq!(values, ["d", "c", "b"]);
}
#[test]
fn a_capacity_of_zero_keeps_nothing() {
let log = Rejections::new(0);
log.record("ns", "a", "nope", Source::Write);
assert!(!log.enabled());
assert_eq!(log.len(), 0);
assert!(log.recent(None, 10).is_empty());
}
#[test]
fn a_namespace_filter_matches_the_subtree_and_not_a_neighbour() {
let log = Rejections::new(10);
log.record("feeds/misp/ips", "a", "nope", Source::Ingest);
log.record("feeds", "b", "nope", Source::Ingest);
log.record("feeds-internal", "c", "nope", Source::Ingest);
log.record("other", "d", "nope", Source::Ingest);
let found = log.recent(Some("feeds"), 10);
let values: Vec<&str> = found.iter().map(|r| r.value.as_str()).collect();
assert_eq!(values, ["b", "a"], "prefix match caught a neighbour");
}
#[test]
fn an_empty_value_is_recorded_as_itself() {
let log = Rejections::new(10);
log.record(
"ns",
"",
"Refusing to write an empty value.",
Source::BulkWrite,
);
let recent = log.recent(None, 10);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].value, "");
assert_eq!(recent[0].source, Source::BulkWrite);
}
#[test]
fn clearing_empties_the_log() {
let log = log();
log.record("ns", "a", "nope", Source::Write);
log.clear();
assert_eq!(log.len(), 0);
}
}