1use alloc::{collections::VecDeque, string::String, vec::Vec};
6use core::num::NonZeroUsize;
7
8const TRUNCATION_SUFFIX: &str = "...[truncated]";
9
10#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
12pub enum Level {
13 Debug,
15 Info,
17 Ok,
19 Warn,
21 Error,
23}
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub struct Limits {
28 pub max_entries: NonZeroUsize,
30 pub max_message_bytes: usize,
32 pub max_label_bytes: usize,
34}
35
36#[derive(Clone, Copy, Debug)]
38pub struct EntryInput<'a> {
39 pub created_at_secs: u64,
41 pub level: Level,
43 pub source: &'a str,
45 pub topic: Option<&'a str>,
47 pub message: &'a str,
49}
50
51#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct Entry {
54 pub sequence: u64,
56 pub created_at_secs: u64,
58 pub level: Level,
60 pub source: String,
62 pub topic: Option<String>,
64 pub message: String,
66 pub truncated: bool,
68}
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum Label {
73 Source,
75 Topic,
77}
78
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub enum AppendError {
82 LabelTooLong {
84 label: Label,
86 actual_bytes: usize,
88 max_bytes: usize,
90 },
91 TimestampRegressed {
93 previous: u64,
95 current: u64,
97 },
98 SequenceExhausted,
100}
101
102#[derive(Clone, Copy, Debug, Default)]
104pub struct Filter<'a> {
105 pub source: Option<&'a str>,
107 pub topic: Option<&'a str>,
109 pub min_level: Option<Level>,
111}
112
113impl Filter<'_> {
114 fn matches(self, entry: &Entry) -> bool {
115 let source_matches = self.source.is_none_or(|source| source == entry.source);
116 let topic_matches = self
117 .topic
118 .is_none_or(|topic| Some(topic) == entry.topic.as_deref());
119 let level_matches = self.min_level.is_none_or(|level| entry.level >= level);
120 source_matches && topic_matches && level_matches
121 }
122}
123
124#[derive(Clone, Copy, Debug)]
126pub struct PageRequest {
127 pub before: Option<u64>,
129 pub max_results: NonZeroUsize,
131 pub max_scanned: NonZeroUsize,
133}
134
135#[derive(Debug)]
137pub struct Page<'a> {
138 pub entries: Vec<&'a Entry>,
140 pub scanned: usize,
142 pub next_before: Option<u64>,
144}
145
146#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148pub struct RetentionBatch {
149 pub removed: usize,
151 pub more_due: bool,
153}
154
155#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct LogSnapshot {
161 pub limits: Limits,
163 pub entries: Vec<Entry>,
165 pub next_sequence: Option<u64>,
167 pub last_created_at_secs: Option<u64>,
169}
170
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173pub enum RestoreError {
174 TooManyEntries,
176 InvalidSequence,
178 InvalidTimestamp,
180 InvalidEntry,
182}
183
184impl core::fmt::Display for RestoreError {
185 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
186 formatter.write_str(match self {
187 Self::TooManyEntries => "log checkpoint exceeds its entry limit",
188 Self::InvalidSequence => "invalid log checkpoint sequence",
189 Self::InvalidTimestamp => "invalid log checkpoint timestamp",
190 Self::InvalidEntry => "invalid bounded log checkpoint entry",
191 })
192 }
193}
194
195impl core::error::Error for RestoreError {}
196
197#[derive(Debug)]
199pub struct Log {
200 limits: Limits,
201 entries: VecDeque<Entry>,
202 next_sequence: Option<u64>,
203 last_created_at_secs: Option<u64>,
204}
205
206impl Log {
207 #[must_use]
209 pub const fn new(limits: Limits) -> Self {
210 Self {
211 limits,
212 entries: VecDeque::new(),
213 next_sequence: Some(0),
214 last_created_at_secs: None,
215 }
216 }
217
218 #[must_use]
220 pub const fn limits(&self) -> Limits {
221 self.limits
222 }
223
224 #[must_use]
227 pub fn snapshot(&self) -> LogSnapshot {
228 LogSnapshot {
229 limits: self.limits,
230 entries: self.entries.iter().cloned().collect(),
231 next_sequence: self.next_sequence,
232 last_created_at_secs: self.last_created_at_secs,
233 }
234 }
235
236 pub fn restore(snapshot: LogSnapshot) -> Result<Self, RestoreError> {
246 let LogSnapshot {
247 limits,
248 entries,
249 next_sequence,
250 last_created_at_secs,
251 } = snapshot;
252 if entries.len() > limits.max_entries.get() {
253 return Err(RestoreError::TooManyEntries);
254 }
255 if (next_sequence == Some(0)) != last_created_at_secs.is_none() {
256 return Err(RestoreError::InvalidTimestamp);
257 }
258 let mut previous: Option<&Entry> = None;
259 for entry in &entries {
260 if entry.source.len() > limits.max_label_bytes
261 || entry
262 .topic
263 .as_ref()
264 .is_some_and(|topic| topic.len() > limits.max_label_bytes)
265 || !valid_retained_message(entry, limits.max_message_bytes)
266 {
267 return Err(RestoreError::InvalidEntry);
268 }
269 if let Some(previous) = previous {
270 if previous.sequence.checked_add(1) != Some(entry.sequence) {
271 return Err(RestoreError::InvalidSequence);
272 }
273 if entry.created_at_secs < previous.created_at_secs {
274 return Err(RestoreError::InvalidTimestamp);
275 }
276 }
277 previous = Some(entry);
278 }
279 if let Some(last) = previous {
280 if last.sequence.checked_add(1) != next_sequence {
281 return Err(RestoreError::InvalidSequence);
282 }
283 if Some(last.created_at_secs) != last_created_at_secs {
284 return Err(RestoreError::InvalidTimestamp);
285 }
286 }
287 Ok(Self {
288 limits,
289 entries: entries.into(),
290 next_sequence,
291 last_created_at_secs,
292 })
293 }
294
295 #[must_use]
297 pub fn len(&self) -> usize {
298 self.entries.len()
299 }
300
301 #[must_use]
303 pub fn is_empty(&self) -> bool {
304 self.entries.is_empty()
305 }
306
307 pub fn append(&mut self, input: EntryInput<'_>) -> Result<u64, AppendError> {
313 self.validate_label(Label::Source, input.source)?;
314 if let Some(topic) = input.topic {
315 self.validate_label(Label::Topic, topic)?;
316 }
317 if let Some(previous) = self.last_created_at_secs
318 && input.created_at_secs < previous
319 {
320 return Err(AppendError::TimestampRegressed {
321 previous,
322 current: input.created_at_secs,
323 });
324 }
325 let sequence = self.next_sequence.ok_or(AppendError::SequenceExhausted)?;
326 let entry = Entry {
327 sequence,
328 created_at_secs: input.created_at_secs,
329 level: input.level,
330 source: String::from(input.source),
331 topic: input.topic.map(String::from),
332 message: bounded_message(input.message, self.limits.max_message_bytes),
333 truncated: input.message.len() > self.limits.max_message_bytes,
334 };
335 if self.entries.len() == self.limits.max_entries.get() {
336 self.entries.pop_front();
337 }
338 self.entries.push_back(entry);
339 self.next_sequence = sequence.checked_add(1);
340 self.last_created_at_secs = Some(input.created_at_secs);
341 Ok(sequence)
342 }
343
344 pub fn prune_expired(
347 &mut self,
348 now_secs: u64,
349 max_age_secs: u64,
350 max_remove: usize,
351 ) -> RetentionBatch {
352 let cutoff = now_secs.saturating_sub(max_age_secs);
353 let mut removed = 0;
354 while removed < max_remove && self.expired_before(cutoff) {
355 self.entries.pop_front();
356 removed += 1;
357 }
358 RetentionBatch {
359 removed,
360 more_due: self.expired_before(cutoff),
361 }
362 }
363
364 #[must_use]
366 pub fn next_expiry_secs(&self, max_age_secs: u64) -> Option<u64> {
367 self.entries
368 .front()?
369 .created_at_secs
370 .checked_add(max_age_secs)?
371 .checked_add(1)
372 }
373
374 #[must_use]
377 pub fn page(&self, filter: Filter<'_>, request: PageRequest) -> Page<'_> {
378 let mut cursor = request.before.map_or(self.entries.len(), |sequence| {
379 self.entries
380 .binary_search_by_key(&sequence, |entry| entry.sequence)
381 .unwrap_or_else(|index| index)
382 });
383 let mut entries = Vec::new();
384 let mut scanned = 0;
385 let mut last_scanned = None;
386 while cursor > 0
387 && scanned < request.max_scanned.get()
388 && entries.len() < request.max_results.get()
389 {
390 cursor -= 1;
391 let entry = &self.entries[cursor];
392 scanned += 1;
393 last_scanned = Some(entry.sequence);
394 if filter.matches(entry) {
395 entries.push(entry);
396 }
397 }
398 Page {
399 entries,
400 scanned,
401 next_before: if cursor > 0 { last_scanned } else { None },
402 }
403 }
404
405 const fn validate_label(&self, label: Label, text: &str) -> Result<(), AppendError> {
406 if text.len() > self.limits.max_label_bytes {
407 return Err(AppendError::LabelTooLong {
408 label,
409 actual_bytes: text.len(),
410 max_bytes: self.limits.max_label_bytes,
411 });
412 }
413 Ok(())
414 }
415
416 fn expired_before(&self, cutoff: u64) -> bool {
417 self.entries
418 .front()
419 .is_some_and(|entry| entry.created_at_secs < cutoff)
420 }
421}
422
423fn valid_retained_message(entry: &Entry, max_bytes: usize) -> bool {
424 if entry.message.len() > max_bytes {
425 return false;
426 }
427 if !entry.truncated {
428 return true;
429 }
430 let budget = if max_bytes > TRUNCATION_SUFFIX.len() {
431 if !entry.message.ends_with(TRUNCATION_SUFFIX) {
432 return false;
433 }
434 max_bytes - TRUNCATION_SUFFIX.len()
435 } else {
436 max_bytes
437 };
438 let prefix_len = if max_bytes > TRUNCATION_SUFFIX.len() {
439 entry.message.len() - TRUNCATION_SUFFIX.len()
440 } else {
441 entry.message.len()
442 };
443 budget.saturating_sub(prefix_len) < 4
444}
445
446fn bounded_message(message: &str, max_bytes: usize) -> String {
447 if message.len() <= max_bytes {
448 return String::from(message);
449 }
450 let suffix = if max_bytes > TRUNCATION_SUFFIX.len() {
451 TRUNCATION_SUFFIX
452 } else {
453 ""
454 };
455 let mut end = max_bytes - suffix.len();
456 while !message.is_char_boundary(end) {
457 end -= 1;
458 }
459 let mut retained = String::from(&message[..end]);
460 retained.push_str(suffix);
461 retained
462}
463
464#[cfg(test)]
465mod tests;