//! 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 { entries: [Option; N], head: usize, len: usize, } impl Lane { 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 { self.entries.get(self.head).copied().flatten() } fn pop(&mut self) -> Option { 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 { filter: LogFilter, critical: Lane, best_effort: Lane, } impl LogSinkQueue { /// 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( &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(&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 { 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( &mut self, submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R), ) -> Option { 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 { 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)); } }