1mod chain;
2use chain::OwnedChain;
3
4mod config;
5pub use config::{Config, LogIdentityMode, LogOpenMode, RetentionPolicy, RotationPolicy};
6
7mod helpers;
8mod startup;
9use helpers::*;
10use startup::{ActiveFile, RotationState, build_startup_state};
11
12use crate::{Result, WriterError};
13use itoa::Buffer as ItoaBuffer;
14pub use journal_common::EntryTimestamps;
15use journal_common::{Microseconds, RealtimeClock};
16use journal_core::error::JournalError;
17use journal_core::file::mmap::MmapMut;
18use journal_core::file::{
19 Compression, EntryField, EntryWriteOptions, FieldNamePolicy, JournalFile, JournalFileOptions,
20 JournalWriter, StructuredField,
21};
22use journal_registry::repository;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25#[cfg(test)]
26use std::sync::atomic::{AtomicUsize, Ordering};
27
28const STACK_ENTRY_REF_LIMIT: usize = 128;
29const SOURCE_REALTIME_PREFIX: &[u8] = b"_SOURCE_REALTIME_TIMESTAMP=";
30const DERIVED_ROTATION_FRACTION: u64 = 20;
31const JOURNAL_FILE_SIZE_MIN: u64 = 512 * 1024;
32const PAGE_SIZE: u64 = 4096;
33const JOURNAL_COMPACT_SIZE_MAX: u64 = u32::MAX as u64;
34
35#[cfg(test)]
36static ARCHIVE_SYNC_CALLS: AtomicUsize = AtomicUsize::new(0);
37
38fn sync_archive_journal_file(
39 sync_on_archive: bool,
40 journal_file: &mut JournalFile<MmapMut>,
41) -> Result<()> {
42 if !sync_on_archive {
43 return Ok(());
44 }
45 #[cfg(test)]
46 ARCHIVE_SYNC_CALLS.fetch_add(1, Ordering::Relaxed);
47 journal_file.sync()?;
48 Ok(())
49}
50
51pub struct Log {
53 configured_dir: PathBuf,
54 chain: OwnedChain,
55 config: Config,
56 active_file: Option<ActiveFile>,
57 rotation_state: RotationState,
58 boot_id: uuid::Uuid,
59 seqnum_id: uuid::Uuid,
60 current_seqnum: u64,
61 clock: RealtimeClock,
62 last_monotonic_usec: u64,
63 lifecycle_observer: Option<Arc<dyn LogLifecycleObserver>>,
64 artifact_sizer: Option<Arc<dyn LogArtifactSizer>>,
65 retention_on_open_applied: bool,
66 boot_id_field: Vec<u8>,
67 source_realtime_field: Vec<u8>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum LogLifecycleReason {
72 Append,
73 EagerOpen,
74 Rotation,
75 Retention,
76}
77
78#[derive(Debug, Clone)]
79pub enum LogLifecycleEvent {
80 Created {
81 active: repository::File,
82 reason: LogLifecycleReason,
83 },
84 Rotated {
85 archived: repository::File,
86 active: repository::File,
87 },
88 RetainedDeleted {
89 files: Vec<repository::File>,
90 },
91}
92
93pub trait LogLifecycleObserver: Send + Sync {
94 fn on_event(&self, event: &LogLifecycleEvent);
95}
96
97pub trait LogArtifactSizer: Send + Sync {
98 fn journal_artifact_size(&self, journal_path: &Path) -> Result<u64>;
99}
100
101impl Log {
102 fn duration_to_micros(duration: std::time::Duration) -> u64 {
103 duration.as_micros().try_into().unwrap_or(u64::MAX)
104 }
105
106 fn peek_entry_realtime(&self, timestamps: &EntryTimestamps) -> u64 {
107 let candidate = timestamps
108 .entry_realtime_usec
109 .unwrap_or_else(|| Microseconds::now().get());
110 let last_seen = self.clock.last_seen().get();
111 if candidate > last_seen {
112 candidate
113 } else {
114 last_seen.saturating_add(1)
115 }
116 }
117
118 fn should_rotate_for_realtime(&self, realtime: u64) -> bool {
119 let Some(active_file) = &self.active_file else {
120 return true;
121 };
122 if self.rotation_state.should_rotate() {
123 return true;
124 }
125 let Some(max_duration) = self.config.rotation_policy.duration_of_journal_file else {
126 return false;
127 };
128 let header = active_file.journal_file.journal_header_ref();
129 header.n_entries > 0
130 && header.head_entry_realtime > 0
131 && realtime.saturating_sub(header.head_entry_realtime)
132 >= Self::duration_to_micros(max_duration)
133 }
134
135 fn append_rotation_reason(&self) -> LogLifecycleReason {
136 if self.active_file.is_none() {
137 LogLifecycleReason::Append
138 } else {
139 LogLifecycleReason::Rotation
140 }
141 }
142
143 fn prepare_append_for_realtime(&mut self, entry_realtime: u64) -> Result<()> {
144 self.apply_retention_on_open()?;
145 let opened_first_active = self.active_file.is_none();
146 if self.should_rotate_for_realtime(entry_realtime) {
147 self.rotate(entry_realtime, self.append_rotation_reason())?;
148 if opened_first_active {
149 self.retention_on_open_applied = true;
150 }
151 }
152 self.apply_retention_on_open()
153 }
154
155 fn raw_items_for_policy<'a>(&self, items: &'a [&'a [u8]]) -> Result<Option<Vec<&'a [u8]>>> {
156 if self.config.field_name_policy != FieldNamePolicy::JournalApp {
157 return Ok(None);
158 }
159 let filtered_items = filter_raw_items_for_journal_app(items)?;
160 if filtered_items.is_empty() {
161 return Err(WriterError::EmptyEntry);
162 }
163 Ok(Some(filtered_items))
164 }
165
166 fn structured_fields_for_policy<'a>(
167 &self,
168 fields: &'a [StructuredField<'a>],
169 ) -> Result<Option<Vec<StructuredField<'a>>>> {
170 if self.config.field_name_policy != FieldNamePolicy::JournalApp {
171 return Ok(None);
172 }
173 let filtered_fields = filter_structured_fields_for_journal_app(fields);
174 if filtered_fields.is_empty() {
175 return Err(WriterError::EmptyEntry);
176 }
177 Ok(Some(filtered_fields))
178 }
179
180 fn apply_retention(&mut self, protected_file: Option<&repository::File>) -> Result<()> {
181 if let Some(sizer) = &self.artifact_sizer {
182 self.chain.refresh_retained_sizes(|file| {
183 sizer.journal_artifact_size(Path::new(file.path()))
184 })?;
185 }
186 let retention = self
187 .chain
188 .retain(&self.config.retention_policy, protected_file);
189 let deleted_files = retention.deleted_files;
190 if !deleted_files.is_empty()
191 && let Some(observer) = &self.lifecycle_observer
192 {
193 observer.on_event(&LogLifecycleEvent::RetainedDeleted {
194 files: deleted_files,
195 });
196 }
197 if let Some(error) = retention.error {
198 return Err(error);
199 }
200
201 Ok(())
202 }
203
204 fn apply_retention_on_open(&mut self) -> Result<()> {
205 if self.retention_on_open_applied || self.active_file.is_none() {
206 return Ok(());
207 }
208 self.enforce_retention()?;
209 self.retention_on_open_applied = true;
210 Ok(())
211 }
212
213 fn capture_dual_timestamp(
219 &mut self,
220 timestamp_override: Option<&EntryTimestamps>,
221 ) -> Result<(u64, u64)> {
222 let realtime = match timestamp_override.and_then(|ts| ts.entry_realtime_usec) {
223 Some(ts) => self.clock.observe(Microseconds::new(ts)).get(),
224 None => self.clock.now().get(),
225 };
226
227 let desired_monotonic = timestamp_override
228 .and_then(|ts| ts.entry_monotonic_usec)
229 .ok_or_else(|| {
230 WriterError::InvalidConfig("entry monotonic timestamp is required".to_string())
231 })?;
232
233 let monotonic = if desired_monotonic > self.last_monotonic_usec {
234 desired_monotonic
235 } else {
236 self.last_monotonic_usec.saturating_add(1)
237 };
238 self.last_monotonic_usec = monotonic;
239
240 Ok((realtime, monotonic))
241 }
242
243 fn require_entry_monotonic(timestamps: &EntryTimestamps) -> Result<()> {
244 if timestamps.entry_monotonic_usec.is_none() {
245 return Err(WriterError::InvalidConfig(
246 "entry monotonic timestamp is required".to_string(),
247 ));
248 }
249 Ok(())
250 }
251
252 pub fn new(path: &Path, config: Config) -> Result<Self> {
254 Self::new_inner(path, config, None, None)
255 }
256
257 pub fn new_with_lifecycle_observer(
258 path: &Path,
259 config: Config,
260 observer: Arc<dyn LogLifecycleObserver>,
261 ) -> Result<Self> {
262 Self::new_inner(path, config, Some(observer), None)
263 }
264
265 pub fn new_with_hooks(
266 path: &Path,
267 config: Config,
268 observer: Option<Arc<dyn LogLifecycleObserver>>,
269 artifact_sizer: Option<Arc<dyn LogArtifactSizer>>,
270 ) -> Result<Self> {
271 Self::new_inner(path, config, observer, artifact_sizer)
272 }
273
274 fn new_inner(
275 path: &Path,
276 config: Config,
277 lifecycle_observer: Option<Arc<dyn LogLifecycleObserver>>,
278 artifact_sizer: Option<Arc<dyn LogArtifactSizer>>,
279 ) -> Result<Self> {
280 let startup = build_startup_state(path, config)?;
281
282 let mut log = Log {
283 configured_dir: path.to_path_buf(),
284 chain: startup.chain,
285 config: startup.config,
286 active_file: startup.active_file,
287 rotation_state: startup.rotation_state,
288 boot_id: startup.boot_id,
289 seqnum_id: startup.seqnum_id,
290 current_seqnum: startup.current_seqnum,
291 clock: startup.clock,
292 last_monotonic_usec: startup.last_monotonic_usec,
293 lifecycle_observer,
294 artifact_sizer,
295 retention_on_open_applied: false,
296 boot_id_field: format!("_BOOT_ID={}", startup.boot_id.as_simple()).into_bytes(),
297 source_realtime_field: Vec::with_capacity(SOURCE_REALTIME_PREFIX.len() + 20),
298 };
299 if log.config.open_mode == LogOpenMode::Eager && log.active_file.is_none() {
300 let realtime = log.peek_entry_realtime(&EntryTimestamps::default());
301 log.rotate(realtime, LogLifecycleReason::EagerOpen)?;
302 log.retention_on_open_applied = true;
303 }
304 log.apply_retention_on_open()?;
305 Ok(log)
306 }
307
308 pub fn with_lifecycle_observer(mut self, observer: Arc<dyn LogLifecycleObserver>) -> Self {
309 self.lifecycle_observer = Some(observer);
310 self
311 }
312
313 pub fn with_artifact_sizer(mut self, sizer: Arc<dyn LogArtifactSizer>) -> Self {
314 self.artifact_sizer = Some(sizer);
315 self
316 }
317
318 #[deprecated(
324 since = "0.7.2",
325 note = "use write_entry_with_timestamps and provide an explicit entry monotonic timestamp"
326 )]
327 pub fn write_entry(
328 &mut self,
329 items: &[&[u8]],
330 source_realtime_usec: Option<u64>,
331 ) -> Result<()> {
332 self.write_entry_with_timestamps(
333 items,
334 EntryTimestamps {
335 source_realtime_usec,
336 ..EntryTimestamps::default()
337 },
338 )
339 }
340
341 pub fn write_entry_with_timestamps(
347 &mut self,
348 items: &[&[u8]],
349 timestamps: EntryTimestamps,
350 ) -> Result<()> {
351 if items.is_empty() {
352 return Err(WriterError::EmptyEntry);
353 }
354 Self::require_entry_monotonic(×tamps)?;
355
356 let entry_realtime = self.peek_entry_realtime(×tamps);
357 self.prepare_append_for_realtime(entry_realtime)?;
358 let filtered_items = self.raw_items_for_policy(items)?;
359 let write_items = filtered_items.as_deref().unwrap_or(items);
360
361 let (realtime, monotonic) = self.capture_dual_timestamp(Some(×tamps))?;
362 self.write_raw_entry_fields(
363 write_items,
364 timestamps.source_realtime_usec,
365 realtime,
366 monotonic,
367 self.low_level_entry_options(EntryWriteOptions::default()),
368 )?;
369
370 let active_file = self.active_file.as_ref().unwrap();
371 self.rotation_state.update(&active_file.writer);
372 self.current_seqnum += 1;
373
374 Ok(())
375 }
376
377 #[deprecated(
387 since = "0.7.2",
388 note = "use write_fields_with_timestamps and provide an explicit entry monotonic timestamp"
389 )]
390 pub fn write_fields(
391 &mut self,
392 fields: &[StructuredField<'_>],
393 source_realtime_usec: Option<u64>,
394 ) -> Result<()> {
395 self.write_fields_with_timestamps(
396 fields,
397 EntryTimestamps {
398 source_realtime_usec,
399 ..EntryTimestamps::default()
400 },
401 )
402 }
403
404 pub fn write_fields_with_timestamps(
410 &mut self,
411 fields: &[StructuredField<'_>],
412 timestamps: EntryTimestamps,
413 ) -> Result<()> {
414 self.write_fields_with_options(fields, timestamps, EntryWriteOptions::default())
415 }
416
417 pub fn write_fields_with_options(
423 &mut self,
424 fields: &[StructuredField<'_>],
425 timestamps: EntryTimestamps,
426 options: EntryWriteOptions,
427 ) -> Result<()> {
428 if fields.is_empty() {
429 return Err(WriterError::EmptyEntry);
430 }
431 Self::require_entry_monotonic(×tamps)?;
432
433 let entry_realtime = self.peek_entry_realtime(×tamps);
434 self.prepare_append_for_realtime(entry_realtime)?;
435 let filtered_fields = self.structured_fields_for_policy(fields)?;
436 let write_fields = filtered_fields.as_deref().unwrap_or(fields);
437
438 let (realtime, monotonic) = self.capture_dual_timestamp(Some(×tamps))?;
439 self.write_structured_entry_fields(
440 write_fields,
441 timestamps.source_realtime_usec,
442 realtime,
443 monotonic,
444 self.low_level_entry_options(options),
445 )?;
446
447 let active_file = self.active_file.as_ref().unwrap();
448 self.rotation_state.update(&active_file.writer);
449 self.current_seqnum += 1;
450
451 Ok(())
452 }
453
454 fn write_raw_entry_fields(
455 &mut self,
456 items: &[&[u8]],
457 source_realtime_usec: Option<u64>,
458 realtime: u64,
459 monotonic: u64,
460 options: EntryWriteOptions,
461 ) -> Result<()> {
462 let source_field = if let Some(timestamp_usec) = source_realtime_usec {
463 self.prepare_source_realtime_field(timestamp_usec);
464 Some(self.source_realtime_field.as_slice())
465 } else {
466 None
467 };
468
469 let total_items = items.len() + 1 + usize::from(source_field.is_some());
470 if total_items <= STACK_ENTRY_REF_LIMIT {
471 let mut refs = [EntryField::raw(&[]); STACK_ENTRY_REF_LIMIT];
472 let mut len = 0usize;
473 refs[len] = EntryField::raw(self.boot_id_field.as_slice());
474 len += 1;
475 if let Some(source_field) = source_field {
476 refs[len] = EntryField::raw(source_field);
477 len += 1;
478 }
479 for item in items {
480 refs[len] = EntryField::raw(item);
481 len += 1;
482 }
483 self.active_file.as_mut().unwrap().write_entry_fields(
484 refs[..len].iter().copied(),
485 realtime,
486 monotonic,
487 options,
488 )?;
489 } else {
490 let mut refs = Vec::with_capacity(total_items);
491 refs.push(EntryField::raw(self.boot_id_field.as_slice()));
492 if let Some(source_field) = source_field {
493 refs.push(EntryField::raw(source_field));
494 }
495 refs.extend(items.iter().copied().map(EntryField::raw));
496 self.active_file.as_mut().unwrap().write_entry_fields(
497 refs.iter().copied(),
498 realtime,
499 monotonic,
500 options,
501 )?;
502 }
503
504 Ok(())
505 }
506
507 fn write_structured_entry_fields(
508 &mut self,
509 fields: &[StructuredField<'_>],
510 source_realtime_usec: Option<u64>,
511 realtime: u64,
512 monotonic: u64,
513 options: EntryWriteOptions,
514 ) -> Result<()> {
515 let source_field = if let Some(timestamp_usec) = source_realtime_usec {
516 self.prepare_source_realtime_field(timestamp_usec);
517 Some(self.source_realtime_field.as_slice())
518 } else {
519 None
520 };
521
522 let total_items = fields.len() + 1 + usize::from(source_field.is_some());
523 if total_items <= STACK_ENTRY_REF_LIMIT {
524 let mut refs = [EntryField::raw(&[]); STACK_ENTRY_REF_LIMIT];
525 let mut len = 0usize;
526 refs[len] = EntryField::raw(self.boot_id_field.as_slice());
527 len += 1;
528 if let Some(source_field) = source_field {
529 refs[len] = EntryField::raw(source_field);
530 len += 1;
531 }
532 for field in fields {
533 refs[len] = EntryField::Structured(*field);
534 len += 1;
535 }
536 self.active_file.as_mut().unwrap().write_entry_fields(
537 refs[..len].iter().copied(),
538 realtime,
539 monotonic,
540 options,
541 )?;
542 } else {
543 let mut refs = Vec::with_capacity(total_items);
544 refs.push(EntryField::raw(self.boot_id_field.as_slice()));
545 if let Some(source_field) = source_field {
546 refs.push(EntryField::raw(source_field));
547 }
548 refs.extend(fields.iter().copied().map(EntryField::Structured));
549 self.active_file.as_mut().unwrap().write_entry_fields(
550 refs.iter().copied(),
551 realtime,
552 monotonic,
553 options,
554 )?;
555 }
556
557 Ok(())
558 }
559
560 fn low_level_entry_options(&self, options: EntryWriteOptions) -> EntryWriteOptions {
561 options.field_name_policy(log_writer_field_name_policy(self.config.field_name_policy))
562 }
563
564 fn prepare_source_realtime_field(&mut self, timestamp_usec: u64) {
565 self.source_realtime_field.clear();
566 self.source_realtime_field
567 .extend_from_slice(SOURCE_REALTIME_PREFIX);
568 let mut buffer = ItoaBuffer::new();
569 self.source_realtime_field
570 .extend_from_slice(buffer.format(timestamp_usec).as_bytes());
571 }
572
573 pub fn sync(&mut self) -> Result<()> {
578 if let Some(active_file) = &mut self.active_file {
579 active_file.journal_file.sync()?;
580 }
581 Ok(())
582 }
583
584 pub fn close(mut self) -> Result<()> {
591 use journal_core::file::JournalState;
592
593 let Some(mut active_file) = self.active_file.take() else {
594 return Ok(());
595 };
596
597 let n_entries = active_file.journal_file.journal_header_ref().n_entries;
598 if self.config.strict_systemd_naming && n_entries == 0 {
599 match std::fs::remove_file(active_file.repository_file.path()) {
600 Ok(()) => {}
601 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
602 Err(err) => return Err(err.into()),
603 }
604 self.chain.remove_tracked_file(&active_file.repository_file);
605 return Ok(());
606 }
607
608 self.chain.update_file_size(
609 &active_file.repository_file,
610 active_file.current_file_size(),
611 );
612 active_file.journal_file.journal_header_mut().state = JournalState::Archived as u8;
613 sync_archive_journal_file(self.config.sync_on_archive, &mut active_file.journal_file)?;
614
615 let protected_file = if self.config.strict_systemd_naming {
616 let header = active_file.journal_file.journal_header_ref();
617 self.chain.archive_file(
618 &active_file.repository_file,
619 uuid::Uuid::from_bytes(header.seqnum_id),
620 header.head_entry_seqnum,
621 header.head_entry_realtime,
622 )?
623 } else {
624 active_file.repository_file.clone()
625 };
626
627 self.apply_retention(Some(&protected_file))?;
628
629 Ok(())
630 }
631
632 pub fn active_file(&self) -> Option<&repository::File> {
633 self.active_file
634 .as_ref()
635 .map(|active_file| &active_file.repository_file)
636 }
637
638 pub fn active_path(&self) -> Option<&Path> {
639 self.active_file
640 .as_ref()
641 .map(|active_file| Path::new(active_file.repository_file.path()))
642 }
643
644 pub fn configured_directory(&self) -> &Path {
645 &self.configured_dir
646 }
647
648 pub fn journal_directory(&self) -> &Path {
649 &self.chain.path
650 }
651
652 pub fn machine_id(&self) -> uuid::Uuid {
653 self.chain.machine_id
654 }
655
656 pub fn boot_id(&self) -> uuid::Uuid {
657 self.boot_id
658 }
659
660 pub fn source(&self) -> &journal_registry::Source {
661 &self.chain.source
662 }
663
664 pub fn enforce_retention(&mut self) -> Result<()> {
668 let protected_file = if let Some(active_file) = &self.active_file {
669 self.chain.update_file_size(
670 &active_file.repository_file,
671 active_file.current_file_size(),
672 );
673 Some(active_file.repository_file.clone())
674 } else {
675 None
676 };
677 self.apply_retention(protected_file.as_ref())
678 }
679
680 fn update_active_file_size(&mut self) {
681 if let Some(active_file) = &self.active_file {
682 self.chain.update_file_size(
683 &active_file.repository_file,
684 active_file.current_file_size(),
685 );
686 }
687 }
688
689 fn prepare_initial_rotation(&mut self) -> Result<()> {
690 self.update_active_file_size();
691 if self.active_file.is_none() && self.config.strict_systemd_naming {
692 self.chain.archive_existing_active_file()?;
693 }
694 Ok(())
695 }
696
697 fn archive_rotated_file(&mut self, old_file: &ActiveFile) -> Result<repository::File> {
698 if !self.config.strict_systemd_naming {
699 return Ok(old_file.repository_file.clone());
700 }
701 let old_header = old_file.journal_file.journal_header_ref();
702 self.chain.archive_file(
703 &old_file.repository_file,
704 uuid::Uuid::from_bytes(old_header.seqnum_id),
705 old_header.head_entry_seqnum,
706 old_header.head_entry_realtime,
707 )
708 }
709
710 fn rotate_existing_active_file(
711 &mut self,
712 mut old_file: ActiveFile,
713 max_file_size: Option<u64>,
714 head_realtime: u64,
715 ) -> Result<(ActiveFile, LogLifecycleEvent)> {
716 use journal_core::file::JournalState;
717
718 old_file.journal_file.journal_header_mut().state = JournalState::Archived as u8;
719 sync_archive_journal_file(self.config.sync_on_archive, &mut old_file.journal_file)?;
720 let archived = self.archive_rotated_file(&old_file)?;
721 let new_file = old_file.rotate(
722 &mut self.chain,
723 max_file_size,
724 head_realtime,
725 self.config.compression,
726 self.config.compression_threshold,
727 self.config.strict_systemd_naming,
728 self.config.live_publish_every_entries,
729 self.config.file_mode,
730 )?;
731 let active = new_file.repository_file.clone();
732 Ok((new_file, LogLifecycleEvent::Rotated { archived, active }))
733 }
734
735 fn create_initial_active_file(
736 &mut self,
737 max_file_size: Option<u64>,
738 head_realtime: u64,
739 reason: LogLifecycleReason,
740 ) -> Result<(ActiveFile, LogLifecycleEvent)> {
741 let new_file = ActiveFile::create(
742 &mut self.chain,
743 self.seqnum_id,
744 self.boot_id,
745 self.current_seqnum + 1,
746 max_file_size,
747 head_realtime,
748 self.config.compression,
749 self.config.compression_threshold,
750 self.config.compact,
751 self.config.strict_systemd_naming,
752 self.config.live_publish_every_entries,
753 self.config.file_mode,
754 )?;
755 let active = new_file.repository_file.clone();
756 Ok((new_file, LogLifecycleEvent::Created { active, reason }))
757 }
758
759 fn emit_lifecycle_event(&self, event: &LogLifecycleEvent) {
760 if let Some(observer) = &self.lifecycle_observer {
761 observer.on_event(event);
762 }
763 }
764
765 fn protected_active_file(&self) -> Option<repository::File> {
766 self.active_file
767 .as_ref()
768 .map(|active_file| active_file.repository_file.clone())
769 }
770
771 #[tracing::instrument(skip_all, fields(active_file))]
772 fn rotate(&mut self, head_realtime: u64, reason: LogLifecycleReason) -> Result<()> {
773 self.prepare_initial_rotation()?;
774 let max_file_size = self.config.rotation_policy.size_of_journal_file;
775 let (new_file, lifecycle_event) = if let Some(old_file) = self.active_file.take() {
776 self.rotate_existing_active_file(old_file, max_file_size, head_realtime)?
777 } else {
778 self.create_initial_active_file(max_file_size, head_realtime, reason)?
779 };
780
781 tracing::Span::current().record("new_file", new_file.repository_file.path());
782
783 self.active_file = Some(new_file);
784 self.rotation_state.reset();
785 self.update_active_file_size();
786 self.emit_lifecycle_event(&lifecycle_event);
787
788 let protected_file = self.protected_active_file();
791 self.apply_retention(protected_file.as_ref())?;
792
793 Ok(())
794 }
795
796 #[cfg(feature = "serde-api")]
854 #[deprecated(
855 since = "0.7.2",
856 note = "use write_structured_with_timestamps and provide an explicit entry monotonic timestamp"
857 )]
858 pub fn write_structured<T: serde::Serialize>(&mut self, value: &T) -> Result<()> {
859 self.write_structured_with_timestamps(value, EntryTimestamps::default())
860 }
861
862 #[cfg(feature = "serde-api")]
864 pub fn write_structured_with_timestamps<T: serde::Serialize>(
865 &mut self,
866 value: &T,
867 timestamps: EntryTimestamps,
868 ) -> Result<()> {
869 let json_value = serde_json::to_value(value).map_err(|e| {
871 WriterError::Serialization(format!("failed to serialize to JSON: {}", e))
872 })?;
873
874 let flattened = if let serde_json::Value::Object(map) = json_value {
876 flatten_json_map(&map)
877 } else {
878 return Err(WriterError::Serialization(
880 "value must be a JSON object, not a primitive or array".to_string(),
881 ));
882 };
883
884 let mut fields: Vec<Vec<u8>> = Vec::with_capacity(flattened.len());
886
887 for (key, value) in flattened.iter() {
888 let journal_key = key.to_uppercase().replace('.', "_");
891
892 let field = match value {
894 serde_json::Value::String(s) => {
895 format!("{}={}", journal_key, s)
896 }
897 serde_json::Value::Number(n) => {
898 format!("{}={}", journal_key, n)
899 }
900 serde_json::Value::Bool(b) => {
901 format!("{}={}", journal_key, if *b { "true" } else { "false" })
902 }
903 serde_json::Value::Null => {
904 format!("{}=", journal_key)
905 }
906 _ => {
908 format!("{}={}", journal_key, value)
909 }
910 };
911
912 fields.push(field.into_bytes());
913 }
914
915 let field_refs: Vec<&[u8]> = fields.iter().map(|f| f.as_slice()).collect();
917
918 self.write_entry_with_timestamps(&field_refs, timestamps)
919 }
920}
921
922#[cfg(all(test, feature = "serde-api"))]
923mod serde_api_tests;
924
925impl Drop for Log {
926 fn drop(&mut self) {
927 use journal_core::file::JournalState;
928
929 if let Some(ref mut active_file) = self.active_file {
930 active_file.journal_file.journal_header_mut().state = JournalState::Archived as u8;
934
935 let _ = sync_archive_journal_file(
937 self.config.sync_on_archive,
938 &mut active_file.journal_file,
939 );
940 }
941 }
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947 use journal_registry::Origin;
948 use std::sync::Mutex;
949 use tempfile::TempDir;
950
951 static ARCHIVE_SYNC_TEST_LOCK: Mutex<()> = Mutex::new(());
952
953 fn test_uuid(seed: u8) -> uuid::Uuid {
954 uuid::Uuid::from_bytes([seed; 16])
955 }
956
957 fn test_config() -> Config {
958 Config::new(
959 Origin {
960 machine_id: Some(test_uuid(1)),
961 namespace: None,
962 source: journal_registry::Source::System,
963 },
964 RotationPolicy::default().with_number_of_entries(1),
965 RetentionPolicy::default(),
966 )
967 .with_boot_id(test_uuid(2))
968 }
969
970 fn write_test_entry(log: &mut Log, message: &[u8], realtime: u64) {
971 log.write_entry_with_timestamps(
972 &[message],
973 EntryTimestamps::default()
974 .with_entry_realtime_usec(realtime)
975 .with_entry_monotonic_usec(realtime),
976 )
977 .expect("write entry");
978 }
979
980 fn journal_paths(dir: &TempDir) -> Vec<PathBuf> {
981 let journal_dir = dir.path().join(test_uuid(1).as_simple().to_string());
982 let mut paths: Vec<_> = std::fs::read_dir(journal_dir)
983 .expect("read journal dir")
984 .map(|entry| entry.expect("read entry").path())
985 .filter(|path| path.extension().is_some_and(|ext| ext == "journal"))
986 .collect();
987 paths.sort();
988 paths
989 }
990
991 fn create_online_chain_active(dir: &TempDir) -> PathBuf {
992 let mut log = Log::new(
993 dir.path(),
994 test_config()
995 .with_rotation_policy(RotationPolicy::default().with_number_of_entries(10)),
996 )
997 .expect("create log");
998 write_test_entry(&mut log, b"MESSAGE=stale-online", 1);
999 log.sync().expect("sync stale online active");
1000 let active_path = log.active_path().expect("active path").to_path_buf();
1001 let active_file = log.active_file.take().expect("active file");
1002 drop(active_file);
1003 drop(log);
1004 active_path
1005 }
1006
1007 #[test]
1008 fn default_rotation_syncs_archived_file_on_caller_path() {
1009 let _guard = ARCHIVE_SYNC_TEST_LOCK.lock().expect("lock sync test");
1010 ARCHIVE_SYNC_CALLS.store(0, Ordering::Relaxed);
1011 let dir = tempfile::tempdir().expect("create temp dir");
1012 let mut log = Log::new(dir.path(), test_config()).expect("create log");
1013
1014 write_test_entry(&mut log, b"MESSAGE=first", 1);
1015 write_test_entry(&mut log, b"MESSAGE=second", 2);
1016
1017 assert_eq!(
1018 ARCHIVE_SYNC_CALLS.load(Ordering::Relaxed),
1019 1,
1020 "default rotation should sync the outgoing archived file"
1021 );
1022 }
1023
1024 #[test]
1025 fn sync_on_archive_false_skips_archive_sync_and_keeps_files_readable() {
1026 let _guard = ARCHIVE_SYNC_TEST_LOCK.lock().expect("lock sync test");
1027 ARCHIVE_SYNC_CALLS.store(0, Ordering::Relaxed);
1028 let dir = tempfile::tempdir().expect("create temp dir");
1029 let mut log =
1030 Log::new(dir.path(), test_config().with_sync_on_archive(false)).expect("create log");
1031
1032 write_test_entry(&mut log, b"MESSAGE=first", 1);
1033 write_test_entry(&mut log, b"MESSAGE=second", 2);
1034 log.close().expect("close log");
1035
1036 assert_eq!(
1037 ARCHIVE_SYNC_CALLS.load(Ordering::Relaxed),
1038 0,
1039 "opt-out must not sync archived files on the caller path"
1040 );
1041
1042 let paths = journal_paths(&dir);
1043 assert_eq!(
1044 paths.len(),
1045 2,
1046 "rotation plus close should leave two journals"
1047 );
1048 for path in paths {
1049 let file = JournalFile::<journal_core::file::Mmap>::open_path(&path, 32 * 1024 * 1024)
1050 .expect("open archived journal");
1051 assert_eq!(file.journal_header_ref().n_entries, 1);
1052 }
1053 }
1054
1055 #[test]
1056 fn sync_on_archive_false_skips_drop_archive_sync() {
1057 let _guard = ARCHIVE_SYNC_TEST_LOCK.lock().expect("lock sync test");
1058 ARCHIVE_SYNC_CALLS.store(0, Ordering::Relaxed);
1059 let dir = tempfile::tempdir().expect("create temp dir");
1060
1061 {
1062 let mut log = Log::new(dir.path(), test_config().with_sync_on_archive(false))
1063 .expect("create log");
1064 write_test_entry(&mut log, b"MESSAGE=drop-opt-out", 1);
1065 }
1066
1067 assert_eq!(
1068 ARCHIVE_SYNC_CALLS.load(Ordering::Relaxed),
1069 0,
1070 "opt-out must skip best-effort Drop archive sync"
1071 );
1072 }
1073
1074 #[test]
1075 fn strict_startup_sync_on_archive_policy_applies_to_online_chain_active() {
1076 let _guard = ARCHIVE_SYNC_TEST_LOCK.lock().expect("lock sync test");
1077
1078 let default_dir = tempfile::tempdir().expect("create default temp dir");
1079 let default_active = create_online_chain_active(&default_dir);
1080 ARCHIVE_SYNC_CALLS.store(0, Ordering::Relaxed);
1081 let _default_log = Log::new(
1082 default_dir.path(),
1083 test_config()
1084 .with_rotation_policy(RotationPolicy::default().with_number_of_entries(10))
1085 .with_strict_systemd_naming(true),
1086 )
1087 .expect("open strict log");
1088 assert_eq!(
1089 ARCHIVE_SYNC_CALLS.load(Ordering::Relaxed),
1090 1,
1091 "default strict startup should sync the stale online chain file"
1092 );
1093 let file =
1094 JournalFile::<journal_core::file::Mmap>::open_path(&default_active, 32 * 1024 * 1024)
1095 .expect("open default startup archived journal");
1096 assert_eq!(
1097 file.journal_header_ref().state,
1098 journal_core::file::JournalState::Archived as u8
1099 );
1100
1101 let opt_out_dir = tempfile::tempdir().expect("create opt-out temp dir");
1102 let opt_out_active = create_online_chain_active(&opt_out_dir);
1103 ARCHIVE_SYNC_CALLS.store(0, Ordering::Relaxed);
1104 let _opt_out_log = Log::new(
1105 opt_out_dir.path(),
1106 test_config()
1107 .with_rotation_policy(RotationPolicy::default().with_number_of_entries(10))
1108 .with_strict_systemd_naming(true)
1109 .with_sync_on_archive(false),
1110 )
1111 .expect("open strict opt-out log");
1112 assert_eq!(
1113 ARCHIVE_SYNC_CALLS.load(Ordering::Relaxed),
1114 0,
1115 "opt-out strict startup must not sync the stale online chain file"
1116 );
1117 let file =
1118 JournalFile::<journal_core::file::Mmap>::open_path(&opt_out_active, 32 * 1024 * 1024)
1119 .expect("open opt-out startup archived journal");
1120 assert_eq!(
1121 file.journal_header_ref().state,
1122 journal_core::file::JournalState::Archived as u8
1123 );
1124 }
1125}