Files
kaptan/firmware/libs/kaptan-log/src/hub.rs
T

471 lines
16 KiB
Rust

//! Bounded priority handling for fixed human-readable diagnostic records.
use core::fmt;
use crate::{
DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags,
LogRecord, LogText, Severity, StaticError, StaticErrorMailbox,
};
/// Outcome from one bounded diagnostic admission attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PublishOutcome {
/// The record was retained.
Queued,
/// A record was excluded by this sink's configured policy.
Filtered,
/// A best-effort record was replaced by newer optional information.
ReplacedOldest,
/// The critical lane kept its earlier record.
RejectedFull,
}
/// Result returned by the sole telemetry owner after attempting to admit a
/// record to its lower transport.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportAdmission {
/// The lower transport retained the record, so it may leave the queue.
Accepted,
/// The lower transport did not accept it yet; retain it for retry.
Retry,
}
struct Lane<const N: usize> {
entries: [Option<LogRecord>; N],
head: usize,
len: usize,
}
impl<const N: usize> Lane<N> {
const fn new() -> Self {
assert!(N > 0, "diagnostic lanes must be nonempty");
Self {
entries: [None; N],
head: 0,
len: 0,
}
}
fn publish(&mut self, record: LogRecord, replace_oldest: bool) -> PublishOutcome {
if self.len == N {
if replace_oldest {
self.entries[self.head] = Some(record);
self.head = (self.head + 1) % N;
return PublishOutcome::ReplacedOldest;
}
return PublishOutcome::RejectedFull;
}
let index = (self.head + self.len) % N;
self.entries[index] = Some(record);
self.len += 1;
PublishOutcome::Queued
}
const fn is_full(&self) -> bool {
self.len == N
}
fn peek(&self) -> Option<LogRecord> {
self.entries.get(self.head).copied().flatten()
}
fn pop(&mut self) -> Option<LogRecord> {
if self.len == 0 {
return None;
}
let record = self.entries[self.head].take();
self.head = (self.head + 1) % N;
self.len -= 1;
record
}
}
/// One bounded priority queue owned by a log sink.
///
/// It owns no global logger, MAVLink state, DMA, filesystem, or other sink.
pub struct LogSinkQueue<const CRITICAL: usize, const BEST_EFFORT: usize> {
filter: LogFilter,
critical: Lane<CRITICAL>,
best_effort: Lane<BEST_EFFORT>,
}
impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BEST_EFFORT> {
/// Creates one sink queue with its immutable boot-active policy.
pub const fn new(filter: LogFilter) -> Self {
Self {
filter,
critical: Lane::new(),
best_effort: Lane::new(),
}
}
/// Immutable sink policy.
pub const fn filter(&self) -> LogFilter {
self.filter
}
/// Whether an error source may be consumed without losing an enabled
/// critical record. Filtered errors may be consumed intentionally.
pub const fn can_publish_error(&self) -> bool {
!self.filter.allows(Severity::Error) || !self.critical.is_full()
}
/// Consumes at most one source-local static-error mailbox in round-robin
/// order. An enabled source remains untouched while the critical lane is
/// full; a filtered source is consumed intentionally.
pub fn drain_next_error_mailbox<const SOURCES: usize>(
&mut self,
mailboxes: [&StaticErrorMailbox; SOURCES],
cursor: &mut u8,
) -> bool {
assert!(
SOURCES != 0 && SOURCES <= u8::MAX as usize,
"error mailbox fan-in must fit its u8 cursor"
);
if !self.can_publish_error() {
return false;
}
if usize::from(*cursor) >= SOURCES {
*cursor = 0;
}
let mut checked = 0;
while checked < SOURCES {
let index = usize::from(*cursor);
*cursor = if index + 1 == SOURCES { 0 } else { *cursor + 1 };
checked += 1;
let (error, coalesced) = mailboxes[index].take();
let Some(error) = error else {
continue;
};
let flags = if coalesced {
LogFlags::COALESCED
} else {
LogFlags::NONE
};
let outcome = self.publish_error_static(flags, error);
debug_assert!(matches!(
outcome,
PublishOutcome::Queued | PublishOutcome::Filtered
));
return true;
}
false
}
/// Formats and retains one ordinary task/worker diagnostic. Error-level
/// severities are emitted through `KAPTAN_ERROR`; lower severities use
/// `KAPTAN_LOG`.
pub fn publish_arguments(
&mut self,
severity: Severity,
flags: LogFlags,
arguments: fmt::Arguments<'_>,
) -> PublishOutcome {
self.publish_text(severity, flags, LogText::from_arguments(arguments))
}
/// Retains one static derived error without formatting or exposing source
/// values. The adapter maps it to `KAPTAN_ERROR`.
pub fn publish_error<E: ErrorReport>(&mut self, flags: LogFlags, error: &E) -> PublishOutcome {
self.publish_record(LogRecord::from_error(flags, error))
}
/// Retains one static mailbox error. This is the ISR-safe counterpart to
/// [`Self::publish_error`].
pub fn publish_error_static(
&mut self,
flags: LogFlags,
error: &'static StaticError,
) -> PublishOutcome {
self.publish_record(LogRecord::from_static_error(flags, error))
}
/// Retains a preformatted error identity and optional detail. Both
/// transport-neutral components already have the exact common diagnostic
/// envelope capacities, so a sink adapter need not retain or clip them
/// again.
pub fn publish_error_text(
&mut self,
flags: LogFlags,
path: ErrorPath,
additional_information: ErrorAdditionalInformation,
) -> PublishOutcome {
self.publish_record(LogRecord::error(flags, path, additional_information))
}
/// Retains one preformatted bounded text payload. Error-or-higher
/// severities are independently encoded as `KAPTAN_ERROR`.
pub fn publish_text(
&mut self,
severity: Severity,
flags: LogFlags,
text: LogText,
) -> PublishOutcome {
self.publish_record(LogRecord::new(severity, flags, text))
}
/// Applies this sink's policy and retains one already-constructed event.
///
/// Composition may submit the same transport-neutral record to multiple
/// sink queues without coupling event creation to any sink policy.
pub fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
if !self.filter.allows(record.severity()) {
return PublishOutcome::Filtered;
}
let delivery = record.delivery();
match delivery {
DeliveryClass::Critical => self.critical.publish(record, false),
DeliveryClass::BestEffort => self.best_effort.publish(record, true),
}
}
/// Highest-priority record without relinquishing it.
pub fn peek(&self) -> Option<LogRecord> {
self.critical.peek().or_else(|| self.best_effort.peek())
}
/// Submits the current record through the sink owner and removes it only
/// after `Accepted`. This keeps encoder/admission retry semantics local
/// for every severity.
pub fn submit_next<R>(
&mut self,
submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R),
) -> Option<R> {
let record = self.peek()?;
let (admission, result) = submit(&record);
match admission {
TransportAdmission::Accepted => {
let _ = self.pop();
}
TransportAdmission::Retry => {}
}
Some(result)
}
fn pop(&mut self) -> Option<LogRecord> {
self.critical.pop().or_else(|| self.best_effort.pop())
}
}
#[cfg(test)]
mod tests {
use super::{LogSinkQueue, PublishOutcome, TransportAdmission};
use crate::{
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogRecord,
LogText, RecordChannel, Severity, StaticError,
};
#[test]
fn sink_policy_applies_to_errors_and_ordinary_logs() {
let mut disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
assert_eq!(
disabled.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("error")
),
PublishOutcome::Filtered
);
assert!(disabled.peek().is_none());
let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
assert_eq!(
queue.publish_text(
Severity::Debug,
LogFlags::NONE,
LogText::from_message("debug")
),
PublishOutcome::Filtered
);
assert_eq!(
queue.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("error")
),
PublishOutcome::Queued
);
let record = queue.peek().unwrap();
assert_eq!(record.error_path().unwrap().as_str(), "error");
assert!(record.additional_information().unwrap().is_empty());
assert_eq!(record.channel(), RecordChannel::Error);
}
#[test]
fn full_critical_lane_backpressures_only_an_enabled_error_source() {
let mut enabled = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
assert!(enabled.can_publish_error());
assert_eq!(
enabled.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("pending")
),
PublishOutcome::Queued
);
assert!(!enabled.can_publish_error());
let disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
assert!(disabled.can_publish_error());
}
#[test]
fn mailbox_fan_in_survives_backpressure_and_is_round_robin() {
static FIRST: StaticError = StaticError::new("first");
static SECOND: StaticError = StaticError::new("second");
static THIRD: StaticError = StaticError::new("third");
let first = crate::StaticErrorMailbox::new();
let second = crate::StaticErrorMailbox::new();
let third = crate::StaticErrorMailbox::new();
let mailboxes = [&first, &second, &third];
first.record(&FIRST);
second.record(&SECOND);
third.record(&THIRD);
let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
let mut cursor = 0;
assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor));
assert_eq!(
queue.peek().unwrap().error_path().unwrap().as_str(),
"first"
);
first.record(&FIRST);
assert!(!queue.drain_next_error_mailbox(mailboxes, &mut cursor));
assert_eq!(
queue.peek().unwrap().error_path().unwrap().as_str(),
"first"
);
let mut expect = |path| {
assert_eq!(queue.peek().unwrap().error_path().unwrap().as_str(), path);
assert_eq!(
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor));
};
expect("first");
expect("second");
expect("third");
assert_eq!(
queue.peek().unwrap().error_path().unwrap().as_str(),
"first"
);
}
#[test]
fn identical_events_remain_distinct_records() {
static FAULT: StaticError = StaticError::new("driver::Fault");
let mut queue = LogSinkQueue::<2, 1>::new(LogFilter::up_to(Severity::Debug));
assert_eq!(
queue.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
);
assert_eq!(
queue.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
);
let record = queue.peek().unwrap();
assert!(!record.flags().contains(LogFlags::COALESCED));
assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault");
assert!(record.additional_information().unwrap().is_empty());
assert_eq!(
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert_eq!(queue.peek(), Some(record));
}
#[test]
fn static_error_detail_needs_no_producer_formatting() {
static FAULT: StaticError =
StaticError::with_additional_information("driver::Dma::Fault", "stream 7");
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
);
let record = hub.peek().unwrap();
assert_eq!(record.error_path().unwrap().as_str(), "driver::Dma::Fault");
assert_eq!(
record.additional_information().unwrap().as_str(),
"stream 7"
);
}
#[test]
fn error_identity_and_detail_are_independent_record_components() {
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
let _ = hub.publish_error_text(
LogFlags::NONE,
ErrorPath::from_message("driver::Icm42688::Transfer"),
ErrorAdditionalInformation::from_message("SPI DMA timeout"),
);
let record = hub.peek().unwrap();
assert_eq!(record.channel(), RecordChannel::Error);
assert_eq!(
record.error_path().unwrap().as_str(),
"driver::Icm42688::Transfer"
);
assert_eq!(
record.additional_information().unwrap().as_str(),
"SPI DMA timeout"
);
}
#[test]
fn retry_retains_every_severity() {
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("fault"),
);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::Retry, ())),
Some(())
);
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert!(hub.peek().is_none());
let _ = hub.publish_text(
Severity::Info,
LogFlags::NONE,
LogText::from_message("detail"),
);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::Retry, ())),
Some(())
);
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::BestEffort);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert!(hub.peek().is_none());
}
#[test]
fn independent_sink_policies_do_not_suppress_each_other() {
let record = LogRecord::new(
Severity::Info,
LogFlags::NONE,
LogText::from_message("flight ready"),
);
let mut mavlink = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
let mut blackbox = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
assert_eq!(mavlink.publish_record(record), PublishOutcome::Filtered);
assert_eq!(blackbox.publish_record(record), PublishOutcome::Queued);
assert!(mavlink.peek().is_none());
assert_eq!(blackbox.peek(), Some(record));
}
}