1use {
15 super::Log,
16 crate::{error::LogError, producer_snapshot, retention, segment::Segment},
17 krabka_ids::Offset,
18 krabka_units::prelude::{ByteSizeExt, TimeExt as _},
19 krabka_verified::retention::{LocalRetentionSegment, local_retention_prefix},
20 std::{
21 collections::HashSet,
22 iter,
23 sync::PoisonError,
24 time::{Instant, SystemTime},
25 },
26 tracing::{field, instrument},
27};
28
29impl Log {
30 #[instrument(
46 level = "debug",
47 skip_all,
48 fields(evicted = field::Empty),
49 err,
50 )]
51 pub fn tick(&mut self, now: SystemTime, high_watermark: Offset) -> Result<(), LogError> {
54 self.rollover_flusher.check()?;
55 self.flush_if_due(Instant::now())?;
56 self.reap_deleted_files(now)?;
57 if self
59 .config
60 .read()
61 .unwrap_or_else(PoisonError::into_inner)
62 .remote_storage_enable
63 {
64 return Ok(());
65 }
66 let visible_floor = self
71 .advance_delivery_watermark(retention::now_ms(now))
72 .watermark;
73
74 let now_ms = retention::now_ms(now);
75 let cfg_guard = self.config.read().unwrap_or_else(PoisonError::into_inner);
76 let retention_applies = cfg_guard.cleanup_policy.contains_delete();
81 let time_cutoff = cfg_guard
86 .retention
87 .filter(|_| retention_applies)
88 .map(|window| now_ms.saturating_sub(window.millis_i64_trunc()));
89 let size_debt = cfg_guard
93 .retention_size
94 .filter(|_| retention_applies)
95 .and_then(|budget| self.size().bytes_u64().checked_sub(budget.bytes_u64()));
96 drop(cfg_guard);
97
98 let log_start = self.log_start_offset();
109 let log_end = self.log_end_offset();
110 let segments: Vec<&Segment> = self.segments.iter().chain(self.active.as_ref()).collect();
111 let next_bases = segments
112 .iter()
113 .skip(1)
114 .map(|segment| Some(segment.base_offset()))
115 .chain(iter::once(None));
116 let facts: Vec<LocalRetentionSegment> = segments
120 .iter()
121 .zip(next_bases)
122 .map(|(segment, next_base)| LocalRetentionSegment {
123 blocked: segment.last_offset() >= visible_floor
126 || next_base.unwrap_or(log_end) > high_watermark,
127 expired: next_base.is_some_and(|next_base| next_base <= log_start)
128 || time_cutoff.is_some_and(|cutoff| self.largest_timestamp(segment) < cutoff),
129 size: segment.size().bytes_u64(),
130 })
131 .collect();
132 let mut evict_len = local_retention_prefix(&facts, size_debt);
133 if self.active.is_none() {
134 evict_len = evict_len.min(self.segments.len().saturating_sub(1));
136 } else if evict_len == facts.len() {
137 self.roll_active_segment()?;
143 }
144 let to_evict: Vec<Offset> = self
145 .segments
146 .iter()
147 .take(evict_len)
148 .map(Segment::base_offset)
149 .collect();
150 if !to_evict.is_empty() {
151 self.rollover_flusher.finish()?;
152 }
153
154 let mut deleted: HashSet<Offset> = HashSet::with_capacity(to_evict.len());
160 let mut failure: Option<LogError> = None;
161 for base in to_evict {
162 if let Err(error) = self.retire_segment(base, now) {
163 failure = Some(error);
164 break;
165 }
166 deleted.insert(base);
167 if let Err(error) = producer_snapshot::remove_at(&self.dir, base) {
170 failure = Some(error);
171 break;
172 }
173 }
174 tracing::Span::current().record("evicted", deleted.len());
175 self.segments
176 .retain(|s| !deleted.contains(&s.base_offset()));
177 self.sealed_txn_indexes
178 .retain(|base, _| !deleted.contains(base));
179 self.stamp_indexes.retain(|base, _| !deleted.contains(base));
180 if let Some(error) = failure {
181 self.follow_retained_segments(Instant::now())?;
184 return Err(error);
185 }
186 self.follow_retained_segments(Instant::now())?;
195 Ok(())
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use {
202 super::*,
203 crate::{
204 config::LogConfig,
205 log::test_support::{
206 TempLog, append_samples, install_stamps, rolled_log, rolling_test_log,
207 sample_batch, test_log, tiny_segments,
208 },
209 },
210 assert2::check,
211 krabka_units::prelude::{ByteSize, kibibytes, millis, secs},
212 std::time::{Duration, UNIX_EPOCH},
213 tempfile::tempdir,
214 };
215
216 #[test]
224 fn retention_never_evicts_the_last_segment() {
225 let dir = tempdir().unwrap();
226 let config = LogConfig {
228 segment_size: kibibytes(1),
229 retention_size: Some(ByteSize::ZERO),
230 ..LogConfig::default()
231 };
232 let mut log = Log::open(dir.path(), config).unwrap();
233 append_samples(&mut log, 40, 4);
234 check!(!log.segments.is_empty(), "the appends should have rolled");
235
236 let end = log.log_end_offset();
237 log.tick(SystemTime::now(), log.log_end_offset())
238 .expect("tick");
239 check!(log.segments.is_empty(), "every sealed segment is gone");
240 check!(
241 log.active.as_ref().map(Segment::base_offset) == Some(end),
242 "an empty active segment stays at the log end"
243 );
244 check!(log.log_start_offset() == end);
245 let mut batch = sample_batch(1);
247 check!(
248 log.append(&mut batch).is_ok(),
249 "the log still accepts appends"
250 );
251 }
252
253 #[test]
258 fn tick_applies_time_and_size_retention_only_when_the_policy_deletes() {
259 use crate::config::CleanupPolicy;
260
261 struct Case {
264 cleanup_policy: CleanupPolicy,
265 retention_size: Option<ByteSize>,
266 log_start: Offset,
267 expected: Vec<Offset>,
268 }
269
270 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
271 let all = vec![Offset(0), Offset(1), Offset(2)];
272 let cases = [
273 Case {
274 cleanup_policy: CleanupPolicy::Delete,
275 retention_size: None,
276 log_start: Offset(0),
277 expected: vec![Offset(1), Offset(2)],
278 },
279 Case {
280 cleanup_policy: CleanupPolicy::Compact,
281 retention_size: None,
282 log_start: Offset(0),
283 expected: all.clone(),
284 },
285 Case {
286 cleanup_policy: CleanupPolicy::CompactAndDelete,
287 retention_size: None,
288 log_start: Offset(0),
289 expected: vec![Offset(1), Offset(2)],
290 },
291 Case {
295 cleanup_policy: CleanupPolicy::Delete,
296 retention_size: Some(ByteSize::ZERO),
297 log_start: Offset(0),
298 expected: vec![],
299 },
300 Case {
301 cleanup_policy: CleanupPolicy::Compact,
302 retention_size: Some(ByteSize::ZERO),
303 log_start: Offset(0),
304 expected: all,
305 },
306 Case {
307 cleanup_policy: CleanupPolicy::CompactAndDelete,
308 retention_size: Some(ByteSize::ZERO),
309 log_start: Offset(0),
310 expected: vec![],
311 },
312 Case {
315 cleanup_policy: CleanupPolicy::Compact,
316 retention_size: None,
317 log_start: Offset(1),
318 expected: vec![Offset(1), Offset(2)],
319 },
320 Case {
321 cleanup_policy: CleanupPolicy::Compact,
322 retention_size: None,
323 log_start: Offset(2),
324 expected: vec![Offset(2)],
325 },
326 ];
327
328 for Case {
329 cleanup_policy,
330 retention_size,
331 log_start,
332 expected,
333 } in cases
334 {
335 let dir = tempdir().unwrap();
336 let mut log = Log::open(dir.path(), LogConfig {
337 retention: Some(secs(10)),
338 retention_size,
339 cleanup_policy,
340 ..tiny_segments()
341 })
342 .unwrap();
343 for timestamp in [1_000, 95_000, 95_000] {
344 let mut batch = sample_batch(1);
345 batch.base_timestamp = timestamp;
346 batch.max_timestamp = timestamp;
347 log.append(&mut batch).unwrap();
348 }
349 log.set_log_start_offset(log_start).unwrap();
350
351 log.tick(now, log.log_end_offset()).unwrap();
352
353 let surviving: Vec<Offset> = log
354 .segments
355 .iter()
356 .chain(log.active.as_ref())
357 .filter(|segment| segment.size() > ByteSize::ZERO)
358 .map(Segment::base_offset)
359 .collect();
360 check!(
361 surviving == expected,
362 "{cleanup_policy:?} {retention_size:?} start={log_start:?}"
363 );
364 }
365 }
366
367 #[test]
368 fn tick_with_no_retention_is_noop() {
369 let TempLog { dir: _dir, mut log } = test_log();
370 let mut b1 = sample_batch(2);
371 let mut b2 = sample_batch(3);
372 log.append(&mut b1).unwrap();
373 log.append(&mut b2).unwrap();
374 let before = log.log_end_offset();
375 log.tick(SystemTime::now(), log.log_end_offset()).unwrap();
376 assert2::assert!(log.log_end_offset() == before);
377 }
378
379 #[test]
385 fn tick_deletes_a_breached_active_segment_behind_a_roll() {
386 use std::time::Duration;
387 let dir = tempdir().unwrap();
388 let config = LogConfig {
389 retention: Some(secs(1)),
390 ..LogConfig::default()
391 };
392 let mut log = Log::open(dir.path(), config).unwrap();
393 log.append(&mut sample_batch(2)).unwrap();
394 let now = SystemTime::now() + Duration::from_hours(30 * 24);
395
396 for _ in 0..2 {
397 log.tick(now, log.log_end_offset()).unwrap();
398 check!(log.segments.is_empty());
399 check!(log.active.as_ref().map(Segment::base_offset) == Some(Offset(2)));
400 check!(log.log_start_offset() == Offset(2));
401 check!(log.log_end_offset() == Offset(2));
402 }
403 }
404
405 #[yare::parameterized(
410 young_file = { "a young file stays", Duration::ZERO, false },
411 old_file = { "an old file goes", Duration::from_hours(2), true },
412 )]
413 fn a_segment_without_record_timestamps_is_aged_by_its_file(
414 case_name: &str,
415 age: Duration,
416 deleted: bool,
417 ) {
418 let dir = tempdir().unwrap();
419 let mut log = Log::open(dir.path(), LogConfig {
420 retention: Some(secs(60 * 60)),
421 ..tiny_segments()
422 })
423 .unwrap();
424 let mut untimed = sample_batch(1);
425 untimed.max_timestamp = -1;
426 log.append(&mut untimed).unwrap();
427 log.append(&mut sample_batch(1)).unwrap();
428 let now = SystemTime::now() + age;
429 let mut fresh = sample_batch(1);
431 fresh.max_timestamp = retention::now_ms(now);
432 log.append(&mut fresh).unwrap();
433
434 log.tick(now, log.log_end_offset()).unwrap();
435
436 let first = log.segments.first().map(Segment::base_offset);
437 check!(
438 (first != Some(Offset(0))) == deleted,
439 "{case_name}: {first:?}"
440 );
441 }
442
443 #[yare::parameterized(
447 nothing_replicated = { "nothing is replicated", Offset(0), Offset(0) },
448 first_segment_replicated = { "the first segment is replicated", Offset(1), Offset(1) },
449 sealed_segments_replicated = { "the sealed segments are replicated", Offset(2), Offset(2) },
450 whole_log_replicated = { "the whole log is replicated", Offset(3), Offset(3) },
451 )]
452 fn retention_stops_at_the_high_watermark(
453 what: &str,
454 high_watermark: Offset,
455 expected_start: Offset,
456 ) {
457 let dir = tempdir().unwrap();
460 let config = LogConfig {
461 retention: Some(secs(1)),
462 ..tiny_segments()
463 };
464 let mut log = Log::open(dir.path(), config).unwrap();
465 for _ in 0..3 {
466 let mut batch = sample_batch(1);
467 batch.base_timestamp = 1_000;
468 batch.max_timestamp = 1_000;
469 log.append(&mut batch).unwrap();
470 }
471
472 log.tick(
473 SystemTime::UNIX_EPOCH + Duration::from_secs(60),
474 high_watermark,
475 )
476 .unwrap();
477
478 assert2::check!(log.log_start_offset() == expected_start, "{what}");
479 }
480
481 #[test]
482 fn tick_removes_only_retained_away_segment_stamp_indexes() {
483 let dir = tempdir().unwrap();
484 let mut log = Log::open(dir.path(), LogConfig {
485 retention: Some(millis(1)),
486 ..tiny_segments()
487 })
488 .unwrap();
489 install_stamps(&mut log, 10, 1);
490 for timestamp in [0, 0, 1_000] {
491 let mut batch = sample_batch(1);
492 batch.max_timestamp = timestamp;
493 log.append(&mut batch).unwrap();
494 }
495
496 log.tick(UNIX_EPOCH + Duration::from_secs(1), log.log_end_offset())
497 .unwrap();
498
499 check!(log.stamp_for_offset(Offset(0)) == None);
500 check!(log.stamp_for_offset(Offset(1)) == None);
501 check!(log.stamp_for_offset(Offset(2)) == Some(12));
502 check!(!log.sealed_txn_indexes.contains_key(&Offset(0)));
503 check!(!log.sealed_txn_indexes.contains_key(&Offset(1)));
504 }
505
506 #[test]
512 fn tick_size_retention_deletes_only_segments_the_debt_covers() {
513 struct DebtCase {
516 name: &'static str,
517 debt_halves: Option<u64>,
518 expected_sealed: usize,
519 }
520 let cases = [
521 DebtCase {
522 name: "under budget",
523 debt_halves: None,
524 expected_sealed: 4,
525 },
526 DebtCase {
527 name: "exactly at budget",
528 debt_halves: Some(0),
529 expected_sealed: 4,
530 },
531 DebtCase {
532 name: "half a segment over",
533 debt_halves: Some(1),
534 expected_sealed: 4,
535 },
536 DebtCase {
537 name: "one segment over",
538 debt_halves: Some(2),
539 expected_sealed: 3,
540 },
541 DebtCase {
542 name: "one and a half segments over",
543 debt_halves: Some(3),
544 expected_sealed: 3,
545 },
546 DebtCase {
547 name: "two segments over",
548 debt_halves: Some(4),
549 expected_sealed: 2,
550 },
551 DebtCase {
552 name: "the whole log over, the active segment behind a roll",
553 debt_halves: Some(10),
554 expected_sealed: 0,
555 },
556 ];
557 for DebtCase {
558 name,
559 debt_halves,
560 expected_sealed,
561 } in cases
562 {
563 let dir = tempdir().unwrap();
564 let mut log = rolling_test_log(dir.path());
565 append_samples(&mut log, 5, 1);
566 let segment = log.segments[0].size().bytes_u64();
567 check!(
568 log.segments
569 .iter()
570 .chain(log.active.as_ref())
571 .all(|s| s.size().bytes_u64() == segment),
572 "{name}: the fixture needs equal segments"
573 );
574 let total = log.size().bytes_u64();
575 let budget = debt_halves.map_or(total + 1, |halves| {
576 total.saturating_sub(halves * segment / 2)
577 });
578 let mut config = log.config_snapshot();
579 config.retention_size = Some(ByteSize::from_bytes(budget));
580 log.set_config(config);
581
582 log.tick(SystemTime::UNIX_EPOCH, log.log_end_offset())
583 .unwrap();
584
585 check!(log.segments.len() == expected_sealed, "{name}");
586 }
587 }
588
589 #[test]
590 fn tick_skips_retention_when_remote_storage_enable_is_true() {
591 use std::time::Duration;
592 let far_future = SystemTime::now() + Duration::from_hours(365 * 24);
593
594 let dir_tiered = tempdir().unwrap();
597 let mut tiered = rolled_log(dir_tiered.path(), &LogConfig {
598 remote_storage_enable: true,
599 retention: Some(millis(1)),
600 ..LogConfig::default()
601 });
602 let sealed_before = tiered.tierable_segments().len();
603 assert2::assert!(sealed_before > 0);
604 tiered.tick(far_future, Offset(i64::MAX)).unwrap();
605 assert2::assert!(tiered.tierable_segments().len() == sealed_before);
606
607 let dir_plain = tempdir().unwrap();
609 let mut plain = rolled_log(dir_plain.path(), &LogConfig {
610 remote_storage_enable: false,
611 retention: Some(millis(1)),
612 ..LogConfig::default()
613 });
614 assert2::assert!(!plain.tierable_segments().is_empty());
615 plain.tick(far_future, Offset(i64::MAX)).unwrap();
616 assert2::assert!(plain.tierable_segments().len() == 0);
619 }
620}