use alloc::{collections::VecDeque, string::String, vec::Vec};
use core::{num::NonZeroUsize, ops::ControlFlow};
const TRUNCATION_SUFFIX: &str = "...[truncated]";
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Level {
Debug,
Info,
Ok,
Warn,
Error,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Limits {
pub max_entries: NonZeroUsize,
pub max_message_bytes: usize,
pub max_label_bytes: usize,
pub max_text_bytes: usize,
}
#[derive(Clone, Copy, Debug)]
pub struct EntryInput<'a> {
pub created_at_secs: u64,
pub level: Level,
pub source: &'a str,
pub topic: Option<&'a str>,
pub message: &'a str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Entry {
pub sequence: u64,
pub created_at_secs: u64,
pub level: Level,
pub source: String,
pub topic: Option<String>,
pub message: String,
pub truncated: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Label {
Source,
Topic,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AppendError {
LabelTooLong {
label: Label,
actual_bytes: usize,
max_bytes: usize,
},
TimestampRegressed {
previous: u64,
current: u64,
},
SequenceExhausted,
TextBudgetExceeded {
required_bytes: u128,
max_bytes: usize,
},
}
impl core::fmt::Display for AppendError {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::LabelTooLong {
label,
actual_bytes,
max_bytes,
} => {
let label = match label {
Label::Source => "source",
Label::Topic => "topic",
};
write!(
formatter,
"log {label} label exceeds {max_bytes} bytes ({actual_bytes} supplied)"
)
}
Self::TimestampRegressed { previous, current } => write!(
formatter,
"log timestamp regressed from {previous} to {current} seconds"
),
Self::SequenceExhausted => formatter.write_str("log sequence identities are exhausted"),
Self::TextBudgetExceeded {
required_bytes,
max_bytes,
} => write!(
formatter,
"log row requires {required_bytes} text bytes (limit {max_bytes})"
),
}
}
}
impl core::error::Error for AppendError {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AppendReceipt {
pub sequence: u64,
pub truncated: bool,
pub evicted: usize,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum TopicMatch<'a> {
#[default]
Any,
Absent,
Exact(&'a str),
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Filter<'a> {
pub source: Option<&'a str>,
pub topic: TopicMatch<'a>,
pub min_level: Option<Level>,
}
impl Filter<'_> {
fn matches(self, entry: &Entry) -> bool {
let source_matches = self.source.is_none_or(|source| source == entry.source);
let topic_matches = match self.topic {
TopicMatch::Any => true,
TopicMatch::Absent => entry.topic.is_none(),
TopicMatch::Exact(topic) => entry.topic.as_deref() == Some(topic),
};
let level_matches = self.min_level.is_none_or(|level| entry.level >= level);
source_matches && topic_matches && level_matches
}
}
#[derive(Clone, Copy, Debug)]
pub struct PageRequest {
pub before: Option<u64>,
pub max_results: NonZeroUsize,
pub max_scanned: NonZeroUsize,
}
#[derive(Debug)]
pub struct Page<'a> {
pub entries: Vec<&'a Entry>,
pub scanned: usize,
pub next_before: Option<u64>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PageScan {
pub matched: usize,
pub scanned: usize,
pub next_before: Option<u64>,
}
#[derive(Clone, Copy, Debug)]
pub struct LogCheckpoint<'a> {
limits: Limits,
entries: &'a VecDeque<Entry>,
next_sequence: Option<u64>,
last_created_at_secs: Option<u64>,
}
impl<'a> LogCheckpoint<'a> {
#[must_use]
pub const fn limits(&self) -> Limits {
self.limits
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = &'a Entry> + DoubleEndedIterator + 'a {
self.entries.iter()
}
#[must_use]
pub const fn next_sequence(&self) -> Option<u64> {
self.next_sequence
}
#[must_use]
pub const fn last_created_at_secs(&self) -> Option<u64> {
self.last_created_at_secs
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetentionBatch {
pub removed: usize,
pub more_due: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LogSnapshot {
pub limits: Limits,
pub entries: Vec<Entry>,
pub next_sequence: Option<u64>,
pub last_created_at_secs: Option<u64>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestoreError {
TooManyEntries,
InvalidSequence,
InvalidTimestamp,
InvalidEntry,
TextBudgetExceeded,
}
impl core::fmt::Display for RestoreError {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter.write_str(match self {
Self::TooManyEntries => "log checkpoint exceeds its entry limit",
Self::InvalidSequence => "invalid log checkpoint sequence",
Self::InvalidTimestamp => "invalid log checkpoint timestamp",
Self::InvalidEntry => "invalid bounded log checkpoint entry",
Self::TextBudgetExceeded => "log checkpoint exceeds its text budget",
})
}
}
impl core::error::Error for RestoreError {}
#[derive(Debug)]
pub struct Log {
limits: Limits,
entries: VecDeque<Entry>,
retained_text_bytes: usize,
next_sequence: Option<u64>,
last_created_at_secs: Option<u64>,
}
impl Log {
#[must_use]
pub const fn new(limits: Limits) -> Self {
Self {
limits,
entries: VecDeque::new(),
retained_text_bytes: 0,
next_sequence: Some(0),
last_created_at_secs: None,
}
}
#[must_use]
pub const fn limits(&self) -> Limits {
self.limits
}
#[must_use]
pub const fn retained_text_bytes(&self) -> usize {
self.retained_text_bytes
}
#[must_use]
pub const fn checkpoint(&self) -> LogCheckpoint<'_> {
LogCheckpoint {
limits: self.limits,
entries: &self.entries,
next_sequence: self.next_sequence,
last_created_at_secs: self.last_created_at_secs,
}
}
#[must_use]
pub fn snapshot(&self) -> LogSnapshot {
LogSnapshot {
limits: self.limits,
entries: self.entries.iter().cloned().collect(),
next_sequence: self.next_sequence,
last_created_at_secs: self.last_created_at_secs,
}
}
#[must_use]
pub fn into_snapshot(self) -> LogSnapshot {
LogSnapshot {
limits: self.limits,
entries: self.entries.into(),
next_sequence: self.next_sequence,
last_created_at_secs: self.last_created_at_secs,
}
}
pub fn restore(snapshot: LogSnapshot) -> Result<Self, RestoreError> {
let retained_text_bytes = validate_snapshot(&snapshot)?;
let LogSnapshot {
limits,
entries,
next_sequence,
last_created_at_secs,
} = snapshot;
Ok(Self {
limits,
entries: entries.into(),
retained_text_bytes,
next_sequence,
last_created_at_secs,
})
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn append(&mut self, input: EntryInput<'_>) -> Result<AppendReceipt, AppendError> {
self.validate_label(Label::Source, input.source)?;
if let Some(topic) = input.topic {
self.validate_label(Label::Topic, topic)?;
}
if let Some(previous) = self.last_created_at_secs
&& input.created_at_secs < previous
{
return Err(AppendError::TimestampRegressed {
previous,
current: input.created_at_secs,
});
}
let sequence = self.next_sequence.ok_or(AppendError::SequenceExhausted)?;
let (prefix, suffix) = message_parts(input.message, self.limits.max_message_bytes);
let required_bytes = text_lengths(input.source, input.topic, prefix.len() + suffix.len())
.into_iter()
.map(|bytes| bytes as u128)
.sum();
let text_bytes = usize::try_from(required_bytes)
.ok()
.filter(|bytes| *bytes <= self.limits.max_text_bytes)
.ok_or(AppendError::TextBudgetExceeded {
required_bytes,
max_bytes: self.limits.max_text_bytes,
})?;
let truncated = input.message.len() > self.limits.max_message_bytes;
let entry = Entry {
sequence,
created_at_secs: input.created_at_secs,
level: input.level,
source: String::from(input.source),
topic: input.topic.map(String::from),
message: bounded_message(prefix, suffix),
truncated,
};
let mut evicted = 0;
while (self.entries.len() >= self.limits.max_entries.get()
|| self.retained_text_bytes > self.limits.max_text_bytes - text_bytes)
&& self.remove_oldest()
{
evicted += 1;
}
self.entries.push_back(entry);
self.retained_text_bytes += text_bytes;
self.next_sequence = sequence.checked_add(1);
self.last_created_at_secs = Some(input.created_at_secs);
Ok(AppendReceipt {
sequence,
truncated,
evicted,
})
}
pub fn prune_expired(
&mut self,
now_secs: u64,
max_age_secs: u64,
max_remove: usize,
) -> RetentionBatch {
let cutoff = now_secs.saturating_sub(max_age_secs);
let mut removed = 0;
while removed < max_remove && self.expired_before(cutoff) {
self.remove_oldest();
removed += 1;
}
RetentionBatch {
removed,
more_due: self.expired_before(cutoff),
}
}
#[must_use]
pub fn next_expiry_secs(&self, max_age_secs: u64) -> Option<u64> {
self.entries
.front()?
.created_at_secs
.checked_add(max_age_secs)?
.checked_add(1)
}
#[must_use]
pub fn page(&self, filter: Filter<'_>, request: PageRequest) -> Page<'_> {
let mut entries = Vec::new();
let scan = self.visit_page(filter, request, |entry| {
entries.push(entry);
ControlFlow::Continue(())
});
Page {
entries,
scanned: scan.scanned,
next_before: scan.next_before,
}
}
pub fn visit_page<'a>(
&'a self,
filter: Filter<'_>,
request: PageRequest,
mut visit: impl FnMut(&'a Entry) -> ControlFlow<()>,
) -> PageScan {
let mut cursor = request.before.map_or(self.entries.len(), |sequence| {
self.entries
.binary_search_by_key(&sequence, |entry| entry.sequence)
.unwrap_or_else(|index| index)
});
let mut matched = 0;
let mut scanned = 0;
while cursor > 0
&& scanned < request.max_scanned.get()
&& matched < request.max_results.get()
{
cursor -= 1;
let entry = &self.entries[cursor];
scanned += 1;
if filter.matches(entry) {
matched += 1;
if visit(entry).is_break() {
break;
}
}
}
PageScan {
matched,
scanned,
next_before: if cursor > 0 {
self.entries.get(cursor).map(|entry| entry.sequence)
} else {
None
},
}
}
const fn validate_label(&self, label: Label, text: &str) -> Result<(), AppendError> {
if text.len() > self.limits.max_label_bytes {
return Err(AppendError::LabelTooLong {
label,
actual_bytes: text.len(),
max_bytes: self.limits.max_label_bytes,
});
}
Ok(())
}
fn expired_before(&self, cutoff: u64) -> bool {
self.entries
.front()
.is_some_and(|entry| entry.created_at_secs < cutoff)
}
fn remove_oldest(&mut self) -> bool {
let Some(entry) = self.entries.pop_front() else {
return false;
};
for bytes in text_lengths(&entry.source, entry.topic.as_deref(), entry.message.len()) {
self.retained_text_bytes -= bytes;
}
true
}
}
fn text_lengths(source: &str, topic: Option<&str>, message_bytes: usize) -> [usize; 3] {
[source.len(), topic.map_or(0, str::len), message_bytes]
}
fn validate_snapshot(snapshot: &LogSnapshot) -> Result<usize, RestoreError> {
let LogSnapshot {
limits,
entries,
next_sequence,
last_created_at_secs,
} = snapshot;
let (next_sequence, last_created_at_secs) = (*next_sequence, *last_created_at_secs);
if entries.len() > limits.max_entries.get() {
return Err(RestoreError::TooManyEntries);
}
if (next_sequence == Some(0)) != last_created_at_secs.is_none() {
return Err(RestoreError::InvalidTimestamp);
}
if entries.is_empty() && last_created_at_secs == Some(u64::MAX) {
return Err(RestoreError::InvalidTimestamp);
}
let mut previous: Option<&Entry> = None;
let mut retained_text_bytes = 0_usize;
for entry in entries {
if entry.source.len() > limits.max_label_bytes
|| entry
.topic
.as_ref()
.is_some_and(|topic| topic.len() > limits.max_label_bytes)
|| !valid_retained_message(entry, limits.max_message_bytes)
{
return Err(RestoreError::InvalidEntry);
}
for bytes in text_lengths(&entry.source, entry.topic.as_deref(), entry.message.len()) {
retained_text_bytes = retained_text_bytes
.checked_add(bytes)
.filter(|total| *total <= limits.max_text_bytes)
.ok_or(RestoreError::TextBudgetExceeded)?;
}
if let Some(previous) = previous {
if previous.sequence.checked_add(1) != Some(entry.sequence) {
return Err(RestoreError::InvalidSequence);
}
if entry.created_at_secs < previous.created_at_secs {
return Err(RestoreError::InvalidTimestamp);
}
}
previous = Some(entry);
}
if let Some(last) = previous {
if last.sequence.checked_add(1) != next_sequence {
return Err(RestoreError::InvalidSequence);
}
if Some(last.created_at_secs) != last_created_at_secs {
return Err(RestoreError::InvalidTimestamp);
}
}
Ok(retained_text_bytes)
}
const fn truncation_suffix(max_bytes: usize) -> &'static str {
if max_bytes > TRUNCATION_SUFFIX.len() {
TRUNCATION_SUFFIX
} else {
""
}
}
fn valid_retained_message(entry: &Entry, max_bytes: usize) -> bool {
if entry.message.len() > max_bytes {
return false;
}
if !entry.truncated {
return true;
}
let suffix = truncation_suffix(max_bytes);
let Some(prefix) = entry.message.strip_suffix(suffix) else {
return false;
};
let budget = max_bytes - suffix.len();
budget.saturating_sub(prefix.len()) < 4
}
fn bounded_message(prefix: &str, suffix: &str) -> String {
let mut retained = String::with_capacity(prefix.len() + suffix.len());
retained.push_str(prefix);
retained.push_str(suffix);
retained
}
fn message_parts(message: &str, max_bytes: usize) -> (&str, &'static str) {
if message.len() <= max_bytes {
return (message, "");
}
let suffix = truncation_suffix(max_bytes);
let mut end = max_bytes - suffix.len();
while !message.is_char_boundary(end) {
end -= 1;
}
(&message[..end], suffix)
}
#[cfg(test)]
mod tests;