1use std::collections::{HashMap, HashSet};
52use std::fs::{self, File, OpenOptions};
53use std::io::Write;
54use std::num::NonZeroU64;
55use std::path::{Path, PathBuf};
56use std::sync::{Arc, Mutex, MutexGuard};
57use std::time::{Duration, Instant};
58
59use fs2::FileExt;
60use serde::{Deserialize, Serialize};
61use serde_json::{Map, Value};
62
63use crate::clock::{duration_nanoseconds, utc_now_rfc3339};
64use crate::configuration::TaskParameters;
65use crate::system_state::{SystemState, SystemStateSchema};
66
67mod error;
68mod json_payload_decoder;
69mod json_state_record_encoder;
70mod jsonl_format;
71mod queued_state_writer;
72mod stored_state_series_reader;
73
74pub use error::StorageError;
75pub use json_payload_decoder::{
76 JsonPayloadDecoder, JsonPayloadDecoderRegistry, JsonStringDecoder, JsonVecF64Decoder,
77};
78pub use stored_state_series_reader::StoredStateSeriesReader;
79
80use json_state_record_encoder::JsonStateRecordEncoder;
81use jsonl_format::{
82 RecordingMetadata, RecordingStatus, StateFieldMetadata, StateStreamMetadata,
83 TimeAxisMetadata as StoredTimeAxis,
84};
85use queued_state_writer::{RecoveredStateStream, StateStreamStorageConfig, StateWriterWorker};
86
87const METADATA_FILE: &str = "metadata.json";
89
90const METADATA_TEMP_FILE: &str = ".metadata.json.tmp";
92
93#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct TimeAxisMetadata {
101 iteration_name: String,
102 iteration_unit: Option<String>,
103 physical_time_name: Option<String>,
104 physical_time_unit: Option<String>,
105}
106
107impl TimeAxisMetadata {
108 pub fn new(iteration_name: impl Into<String>) -> Self {
114 Self {
115 iteration_name: iteration_name.into(),
116 iteration_unit: None,
117 physical_time_name: None,
118 physical_time_unit: None,
119 }
120 }
121
122 #[must_use]
124 pub fn with_iteration_unit(mut self, unit: impl Into<String>) -> Self {
125 self.iteration_unit = Some(unit.into());
126 self
127 }
128
129 #[must_use]
131 pub fn with_physical_time_name(mut self, name: impl Into<String>) -> Self {
132 self.physical_time_name = Some(name.into());
133 self
134 }
135
136 #[must_use]
141 pub fn with_physical_time_unit(mut self, unit: impl Into<String>) -> Self {
142 self.physical_time_unit = Some(unit.into());
143 self
144 }
145
146 #[must_use]
148 pub fn with_physical_axis(mut self, name: impl Into<String>, unit: impl Into<String>) -> Self {
149 self.physical_time_name = Some(name.into());
150 self.physical_time_unit = Some(unit.into());
151 self
152 }
153
154 fn into_stored(self) -> StoredTimeAxis {
156 StoredTimeAxis {
157 iteration_name: self.iteration_name,
158 iteration_unit: self.iteration_unit,
159 physical_time_name: self.physical_time_name,
160 physical_time_unit: self.physical_time_unit,
161 }
162 }
163}
164
165impl Default for TimeAxisMetadata {
166 fn default() -> Self {
169 Self::new("iteration")
170 }
171}
172
173#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct RecordingTiming {
180 created_at_utc: String,
181 finalized_at_utc: String,
182 active_duration_ns: u64,
183 continuation_count: u64,
184}
185
186impl RecordingTiming {
187 fn from_stored(
189 timing: &jsonl_format::RecordingTiming,
190 metadata_path: &Path,
191 ) -> Result<Self, StorageError> {
192 let finalized_at_utc =
193 timing
194 .finalized_at_utc
195 .clone()
196 .ok_or_else(|| StorageError::InvalidMetadata {
197 path: metadata_path.to_path_buf(),
198 reason: "completed recording lacks finalized timestamp".to_owned(),
199 })?;
200 Ok(Self {
201 created_at_utc: timing.created_at_utc.clone(),
202 finalized_at_utc,
203 active_duration_ns: timing.active_duration_ns,
204 continuation_count: timing.continuation_count,
205 })
206 }
207
208 pub fn created_at_utc(&self) -> &str {
210 &self.created_at_utc
211 }
212
213 pub fn finalized_at_utc(&self) -> &str {
215 &self.finalized_at_utc
216 }
217
218 pub fn active_duration_ns(&self) -> u64 {
220 self.active_duration_ns
221 }
222
223 pub fn active_duration(&self) -> Duration {
225 Duration::from_nanos(self.active_duration_ns)
226 }
227
228 pub fn continuation_count(&self) -> u64 {
230 self.continuation_count
231 }
232}
233
234#[derive(Clone, Debug, Eq, PartialEq)]
236pub struct CompletedStreamSummary {
237 name: String,
238 chunk_count: u64,
239 record_count: u64,
240 encoded_bytes: u64,
241 first_iteration: Option<u64>,
242 last_iteration: Option<u64>,
243}
244
245impl CompletedStreamSummary {
246 pub fn name(&self) -> &str {
248 &self.name
249 }
250
251 pub fn chunk_count(&self) -> u64 {
253 self.chunk_count
254 }
255
256 pub fn record_count(&self) -> u64 {
258 self.record_count
259 }
260
261 pub fn encoded_bytes(&self) -> u64 {
263 self.encoded_bytes
264 }
265
266 pub fn first_iteration(&self) -> Option<u64> {
268 self.first_iteration
269 }
270
271 pub fn last_iteration(&self) -> Option<u64> {
273 self.last_iteration
274 }
275}
276
277#[derive(Clone, Debug, Eq, PartialEq)]
282pub struct CompletedRecording {
283 directory: PathBuf,
284 timing: RecordingTiming,
285 terminal_metadata: Map<String, Value>,
286 streams: Vec<CompletedStreamSummary>,
287}
288
289impl CompletedRecording {
290 pub fn directory(&self) -> &Path {
292 &self.directory
293 }
294
295 pub fn timing(&self) -> &RecordingTiming {
297 &self.timing
298 }
299
300 pub fn terminal_metadata(&self) -> &Map<String, Value> {
302 &self.terminal_metadata
303 }
304
305 pub fn stream_summaries(&self) -> &[CompletedStreamSummary] {
307 &self.streams
308 }
309
310 pub fn stream_summary(&self, name: &str) -> Option<&CompletedStreamSummary> {
312 self.streams.iter().find(|stream| stream.name == name)
313 }
314}
315
316#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
323#[serde(rename_all = "snake_case")]
324pub enum SamplingInterval {
325 Iterations(NonZeroU64),
327}
328
329impl SamplingInterval {
330 pub const fn iterations(interval: u64) -> Option<Self> {
332 match NonZeroU64::new(interval) {
333 Some(interval) => Some(Self::Iterations(interval)),
334 None => None,
335 }
336 }
337
338 const fn includes(self, iteration: u64) -> bool {
340 match self {
341 Self::Iterations(interval) => iteration.is_multiple_of(interval.get()),
342 }
343 }
344}
345
346#[derive(Clone, Debug, Eq, PartialEq)]
355pub struct StateStreamConfig {
356 name: String,
357 directory: String,
358 sampling_interval: SamplingInterval,
359 fields: Vec<String>,
360 storage_limits: Option<(NonZeroU64, NonZeroU64)>,
361}
362
363impl StateStreamConfig {
364 pub fn new<I, K>(
372 name: impl Into<String>,
373 fields: I,
374 sampling_interval: SamplingInterval,
375 max_chunk_bytes: NonZeroU64,
376 queue_bytes: NonZeroU64,
377 ) -> Self
378 where
379 I: IntoIterator<Item = K>,
380 K: Into<String>,
381 {
382 let name = name.into();
383 Self {
384 directory: name.clone(),
385 name,
386 sampling_interval,
387 fields: fields.into_iter().map(Into::into).collect(),
388 storage_limits: Some((max_chunk_bytes, queue_bytes)),
389 }
390 }
391
392 fn sampled<I, K>(
394 name: impl Into<String>,
395 fields: I,
396 sampling_interval: SamplingInterval,
397 ) -> Self
398 where
399 I: IntoIterator<Item = K>,
400 K: Into<String>,
401 {
402 let name = name.into();
403 Self {
404 directory: name.clone(),
405 name,
406 sampling_interval,
407 fields: fields.into_iter().map(Into::into).collect(),
408 storage_limits: None,
409 }
410 }
411
412 #[must_use]
417 pub fn with_relative_directory(mut self, directory: impl Into<String>) -> Self {
418 self.directory = directory.into();
419 self
420 }
421}
422
423#[derive(Debug)]
430pub struct SystemStateWriterBuilder {
431 root: PathBuf,
432 spec: SystemStateSchema,
433 time: TimeAxisMetadata,
434 user_metadata: Map<String, Value>,
435 shared_stream_limits: Option<(NonZeroU64, NonZeroU64)>,
436 streams: Vec<StateStreamConfig>,
437}
438
439impl SystemStateWriterBuilder {
440 pub fn new(root: impl Into<PathBuf>, spec: &SystemStateSchema) -> Self {
445 Self {
446 root: root.into(),
447 spec: spec.clone(),
448 time: TimeAxisMetadata::default(),
449 user_metadata: Map::new(),
450 shared_stream_limits: None,
451 streams: Vec::new(),
452 }
453 }
454
455 #[must_use]
457 pub fn with_time_axis_metadata(mut self, time: TimeAxisMetadata) -> Self {
458 self.time = time;
459 self
460 }
461
462 #[must_use]
468 pub fn with_user_metadata(mut self, metadata: Map<String, Value>) -> Self {
469 self.user_metadata = metadata;
470 self
471 }
472
473 #[must_use]
480 pub fn with_shared_stream_limits(
481 mut self,
482 max_chunk_bytes: NonZeroU64,
483 queue_bytes: NonZeroU64,
484 ) -> Self {
485 self.shared_stream_limits = Some((max_chunk_bytes, queue_bytes));
486 self
487 }
488
489 #[must_use]
495 pub fn with_task_parameters(mut self, parameters: &TaskParameters) -> Self {
496 self.user_metadata = parameters
497 .iter()
498 .map(|(key, value)| (key.to_owned(), value.clone()))
499 .collect();
500 self.user_metadata.insert(
501 "task_ordinal".to_owned(),
502 Value::from(parameters.task_ordinal()),
503 );
504 self
505 }
506
507 #[must_use]
512 pub fn add_state_stream(mut self, stream: StateStreamConfig) -> Self {
513 self.streams.push(stream);
514 self
515 }
516
517 #[must_use]
524 pub fn add_sampled_state_stream<I, K>(
525 mut self,
526 name: impl Into<String>,
527 fields: I,
528 sampling_interval: SamplingInterval,
529 ) -> Self
530 where
531 I: IntoIterator<Item = K>,
532 K: Into<String>,
533 {
534 self.streams
535 .push(StateStreamConfig::sampled(name, fields, sampling_interval));
536 self
537 }
538
539 pub fn create_new_recording(self) -> Result<SystemStateWriter, StorageError> {
551 SystemStateWriter::create_new_recording(self)
552 }
553
554 pub fn continue_existing_recording(self) -> Result<SystemStateWriter, StorageError> {
562 SystemStateWriter::continue_recording(self, None).map(|(writer, _)| writer)
563 }
564
565 pub fn continue_recording_from_latest_checkpoint(
573 self,
574 stream: &str,
575 decoders: JsonPayloadDecoderRegistry,
576 ) -> Result<(SystemStateWriter, SystemState), StorageError> {
577 let (writer, state) =
578 SystemStateWriter::continue_recording(self, Some((stream, decoders)))?;
579 Ok((
580 writer,
581 state.expect("checkpoint-aware resume always reconstructs one state"),
582 ))
583 }
584}
585
586pub struct SystemStateWriter {
593 root: PathBuf,
594 stream_order: Vec<String>,
595 manifest: Arc<RecordingManifest>,
596 streams: HashMap<String, ScheduledStateStream>,
597 writer: Option<StateWriterWorker>,
598 session_started: Instant,
599 _lease: RecordingLease,
602}
603
604impl SystemStateWriter {
605 pub fn builder(root: impl Into<PathBuf>, spec: &SystemStateSchema) -> SystemStateWriterBuilder {
607 SystemStateWriterBuilder::new(root, spec)
608 }
609
610 pub fn recording_directory(&self) -> &Path {
612 &self.root
613 }
614
615 pub fn stream_names(&self) -> impl ExactSizeIterator<Item = &str> {
617 self.stream_order.iter().map(String::as_str)
618 }
619
620 pub fn observe_state(&mut self, state: &SystemState) -> Result<(), StorageError> {
634 let iteration = state.simulation_time().iteration();
635 let writer = self
636 .writer
637 .as_ref()
638 .expect("an active recording owns its writer worker");
639 for name in &self.stream_order {
640 let stream = self
641 .streams
642 .get_mut(name)
643 .expect("stream order contains every configured stream");
644 if !stream.sampling_interval.includes(iteration)
645 || stream.last_recorded_iteration == Some(iteration)
646 {
647 continue;
648 }
649 let record = stream.encoder.encode(state)?;
650 writer.submit_record(name, record)?;
651 stream.last_recorded_iteration = Some(iteration);
652 }
653 Ok(())
654 }
655
656 pub fn flush_stream_to_storage(&self, stream: &str) -> Result<(), StorageError> {
663 if !self.streams.contains_key(stream) {
664 return Err(StorageError::UnknownStateStream {
665 stream: stream.to_owned(),
666 });
667 }
668 self.writer
669 .as_ref()
670 .expect("an active recording owns its writer worker")
671 .flush_state_stream(stream)
672 }
673
674 pub fn complete_recording(self) -> Result<CompletedRecording, StorageError> {
682 self.complete_recording_with_terminal_metadata(Map::new())
683 }
684
685 pub fn complete_recording_with_terminal_metadata(
691 mut self,
692 terminal_metadata: Map<String, Value>,
693 ) -> Result<CompletedRecording, StorageError> {
694 if let Err(error) = self.finish_writer() {
695 let _ = self.transition_terminal(
696 RecordingStatus::Failed {
697 message: error.to_string(),
698 },
699 Map::new(),
700 );
701 return Err(error);
702 }
703 self.transition_terminal(RecordingStatus::Complete, terminal_metadata)?;
704 self.completed_recording()
705 }
706
707 pub fn complete_recording_with_final_state(
714 mut self,
715 state: &SystemState,
716 ) -> Result<CompletedRecording, StorageError> {
717 self.record_final_state(state)?;
718 self.complete_recording()
719 }
720
721 pub fn complete_recording_with_final_state_and_terminal_metadata(
724 mut self,
725 state: &SystemState,
726 terminal_metadata: Map<String, Value>,
727 ) -> Result<CompletedRecording, StorageError> {
728 self.record_final_state(state)?;
729 self.complete_recording_with_terminal_metadata(terminal_metadata)
730 }
731
732 fn record_final_state(&mut self, state: &SystemState) -> Result<(), StorageError> {
734 let iteration = state.simulation_time().iteration();
735 let writer = self
736 .writer
737 .as_ref()
738 .expect("an active recording owns its writer worker");
739 for name in &self.stream_order {
740 let stream = self
741 .streams
742 .get_mut(name)
743 .expect("stream order contains every configured stream");
744 if stream.last_recorded_iteration == Some(iteration) {
745 continue;
746 }
747 let record = stream.encoder.encode(state)?;
748 writer.submit_record(name, record)?;
749 stream.last_recorded_iteration = Some(iteration);
750 }
751 Ok(())
752 }
753
754 pub fn mark_recording_failed(self, message: impl Into<String>) -> Result<(), StorageError> {
766 self.mark_recording_failed_with_terminal_metadata(message, Map::new())
767 }
768
769 pub fn mark_recording_failed_with_terminal_metadata(
771 mut self,
772 message: impl Into<String>,
773 terminal_metadata: Map<String, Value>,
774 ) -> Result<(), StorageError> {
775 let message = message.into();
776 if message.trim().is_empty() {
777 return Err(StorageError::InvalidConfiguration {
778 setting: "failure_message",
779 reason: "failed run message must not be empty".to_owned(),
780 });
781 }
782
783 if let Err(error) = self.finish_writer() {
784 let _ = self.transition_terminal(
785 RecordingStatus::Failed {
786 message: error.to_string(),
787 },
788 Map::new(),
789 );
790 return Err(error);
791 }
792 self.transition_terminal(RecordingStatus::Failed { message }, terminal_metadata)
793 }
794
795 fn create_new_recording(builder: SystemStateWriterBuilder) -> Result<Self, StorageError> {
797 ensure_absent(&builder.root)?;
798 let prepared = PreparedRecording::from_builder(builder)?;
799 create_root(&prepared.root)?;
800 let lease = RecordingLease::acquire(&prepared.root)?;
801 for stream in &prepared.streams {
802 stream.writer.create_directory()?;
803 }
804 commit_metadata(&prepared.root, &prepared.metadata_path, &prepared.metadata)?;
805 let manifest = Arc::new(RecordingManifest::new(
806 prepared.root.clone(),
807 prepared.metadata_path.clone(),
808 prepared.metadata,
809 ));
810 Self::start_new_prepared(prepared.root, prepared.streams, manifest, lease)
811 }
812
813 fn continue_recording(
815 builder: SystemStateWriterBuilder,
816 checkpoint: Option<(&str, JsonPayloadDecoderRegistry)>,
817 ) -> Result<(Self, Option<SystemState>), StorageError> {
818 let prepared = PreparedRecording::from_builder(builder)?;
819 let lease = RecordingLease::acquire(&prepared.root)?;
820 remove_stale_metadata_temp(&prepared.root)?;
821 let mut existing = load_metadata(&prepared.metadata_path)?;
822 if !matches!(existing.status, RecordingStatus::Running) {
823 return Err(StorageError::RecordingNotContinuable {
824 path: prepared.metadata_path,
825 });
826 }
827 ensure_resume_match(&prepared.metadata_path, &prepared.metadata, &existing)?;
828
829 let mut recovered = Vec::with_capacity(prepared.streams.len());
830 for stream in prepared.streams {
831 let declaration = existing
832 .stream(&stream.name)
833 .expect("matched metadata contains every prepared stream");
834 let seed = StateWriterWorker::recover_state_stream(&stream.writer, declaration)?;
835 recovered.push((stream, seed));
836 }
837
838 let state = if let Some((checkpoint_stream, decoders)) = checkpoint {
839 let declaration = existing.stream(checkpoint_stream).ok_or_else(|| {
840 StorageError::UnknownStateStream {
841 stream: checkpoint_stream.to_owned(),
842 }
843 })?;
844 let seed = recovered
845 .iter()
846 .find(|(stream, _)| stream.name == checkpoint_stream)
847 .map(|(_, seed)| seed)
848 .expect("matched stream has one recovered seed");
849 Some(stored_state_series_reader::decode_resume_state(
850 &prepared.root,
851 &prepared.metadata_path,
852 declaration,
853 &prepared.spec,
854 &decoders,
855 seed.latest_open_record(),
856 )?)
857 } else {
858 None
859 };
860
861 existing.timing.continuation_count = existing
862 .timing
863 .continuation_count
864 .checked_add(1)
865 .ok_or_else(|| StorageError::InvalidMetadata {
866 path: prepared.metadata_path.clone(),
867 reason: "timing.continuation_count overflowed".to_owned(),
868 })?;
869 commit_metadata(&prepared.root, &prepared.metadata_path, &existing)?;
870 let manifest = Arc::new(RecordingManifest::new(
871 prepared.root.clone(),
872 prepared.metadata_path.clone(),
873 existing,
874 ));
875
876 let output = Self::start_resumed_prepared(prepared.root, recovered, manifest, lease)?;
877 Ok((output, state))
878 }
879
880 fn start_new_prepared(
882 root: PathBuf,
883 streams: Vec<PreparedStateStream>,
884 manifest: Arc<RecordingManifest>,
885 lease: RecordingLease,
886 ) -> Result<Self, StorageError> {
887 let mut scheduled = HashMap::with_capacity(streams.len());
888 let mut configs = Vec::with_capacity(streams.len());
889 let mut stream_order = Vec::with_capacity(streams.len());
890 for prepared in streams {
891 let name = prepared.name;
892 stream_order.push(name.clone());
893 scheduled.insert(
894 name,
895 ScheduledStateStream {
896 encoder: prepared.encoder,
897 sampling_interval: prepared.sampling_interval,
898 last_recorded_iteration: None,
899 },
900 );
901 configs.push(prepared.writer);
902 }
903 let writer = StateWriterWorker::start_new_recording(configs, Arc::clone(&manifest))?;
904 Ok(Self {
905 root,
906 stream_order,
907 manifest,
908 streams: scheduled,
909 writer: Some(writer),
910 session_started: Instant::now(),
911 _lease: lease,
912 })
913 }
914
915 fn start_resumed_prepared(
917 root: PathBuf,
918 streams: Vec<(PreparedStateStream, RecoveredStateStream)>,
919 manifest: Arc<RecordingManifest>,
920 lease: RecordingLease,
921 ) -> Result<Self, StorageError> {
922 let mut scheduled = HashMap::with_capacity(streams.len());
923 let mut recovered_streams = Vec::with_capacity(streams.len());
924 let mut stream_order = Vec::with_capacity(streams.len());
925 for (prepared, seed) in streams {
926 let name = prepared.name;
927 stream_order.push(name.clone());
928 scheduled.insert(
929 name,
930 ScheduledStateStream {
931 encoder: prepared.encoder,
932 sampling_interval: prepared.sampling_interval,
933 last_recorded_iteration: seed.last_iteration(),
934 },
935 );
936 recovered_streams.push((prepared.writer, seed));
937 }
938 let writer = StateWriterWorker::continue_recovered_recording(
939 recovered_streams,
940 Arc::clone(&manifest),
941 )?;
942 Ok(Self {
943 root,
944 stream_order,
945 manifest,
946 streams: scheduled,
947 writer: Some(writer),
948 session_started: Instant::now(),
949 _lease: lease,
950 })
951 }
952
953 fn finish_writer(&mut self) -> Result<(), StorageError> {
955 let Some(writer) = self.writer.take() else {
956 return Ok(());
957 };
958 writer.finish_recording()
959 }
960
961 fn transition_terminal(
963 &self,
964 status: RecordingStatus,
965 terminal_metadata: Map<String, Value>,
966 ) -> Result<(), StorageError> {
967 let finalized_at_utc =
968 utc_now_rfc3339().map_err(|source| StorageError::OperationalTimestamp {
969 operation: "finalize recording",
970 source,
971 })?;
972 let active_duration_ns = duration_nanoseconds(self.session_started.elapsed())
973 .ok_or(StorageError::OperationalDurationOverflow)?;
974 self.manifest.transition_terminal(
975 status,
976 finalized_at_utc,
977 active_duration_ns,
978 terminal_metadata,
979 )
980 }
981
982 fn completed_recording(&self) -> Result<CompletedRecording, StorageError> {
984 let metadata = self.manifest.snapshot();
985 let timing = RecordingTiming::from_stored(&metadata.timing, &self.manifest.path)?;
986 let streams = metadata
987 .streams
988 .iter()
989 .map(completed_stream_summary)
990 .collect::<Result<Vec<_>, _>>()?;
991 Ok(CompletedRecording {
992 directory: self.root.clone(),
993 timing,
994 terminal_metadata: metadata.terminal_metadata,
995 streams,
996 })
997 }
998}
999
1000fn completed_stream_summary(
1002 stream: &StateStreamMetadata,
1003) -> Result<CompletedStreamSummary, StorageError> {
1004 let overflow = || StorageError::ByteCountOverflow {
1005 stream: stream.name.clone(),
1006 };
1007 let chunk_count = u64::try_from(stream.chunks.len()).map_err(|_| overflow())?;
1008 let record_count = stream
1009 .chunks
1010 .iter()
1011 .try_fold(0_u64, |total, chunk| total.checked_add(chunk.records))
1012 .ok_or_else(&overflow)?;
1013 let encoded_bytes = stream
1014 .chunks
1015 .iter()
1016 .try_fold(0_u64, |total, chunk| total.checked_add(chunk.bytes))
1017 .ok_or_else(overflow)?;
1018 Ok(CompletedStreamSummary {
1019 name: stream.name.clone(),
1020 chunk_count,
1021 record_count,
1022 encoded_bytes,
1023 first_iteration: stream.chunks.first().map(|chunk| chunk.first_iteration),
1024 last_iteration: stream.chunks.last().map(|chunk| chunk.last_iteration),
1025 })
1026}
1027
1028struct PreparedRecording {
1030 root: PathBuf,
1031 metadata_path: PathBuf,
1032 spec: SystemStateSchema,
1033 metadata: RecordingMetadata,
1034 streams: Vec<PreparedStateStream>,
1035}
1036
1037impl PreparedRecording {
1038 fn from_builder(builder: SystemStateWriterBuilder) -> Result<Self, StorageError> {
1040 let metadata_path = builder.root.join(METADATA_FILE);
1041 let stored_time = builder.time.into_stored();
1042 let mut names = HashSet::with_capacity(builder.streams.len());
1043 let mut directories = HashSet::with_capacity(builder.streams.len());
1044 let mut streams = Vec::with_capacity(builder.streams.len());
1045 let mut declarations = Vec::with_capacity(builder.streams.len());
1046
1047 for config in builder.streams {
1048 if !names.insert(config.name.clone()) {
1049 return Err(StorageError::DuplicateStateStream {
1050 stream: config.name,
1051 });
1052 }
1053 if !directories.insert(config.directory.clone()) {
1054 return Err(StorageError::InvalidConfiguration {
1055 setting: "stream.directory",
1056 reason: format!(
1057 "multiple streams use relative directory `{}`",
1058 config.directory
1059 ),
1060 });
1061 }
1062
1063 let (max_chunk_bytes, queue_bytes) = config
1064 .storage_limits
1065 .or(builder.shared_stream_limits)
1066 .ok_or_else(|| StorageError::InvalidConfiguration {
1067 setting: "stream.storage_limits",
1068 reason: format!(
1069 "stream `{}` has no explicit limits and the writer has no shared limits",
1070 config.name
1071 ),
1072 })?;
1073 let encoder = JsonStateRecordEncoder::new(&config.name, &builder.spec, &config.fields)?;
1074 let fields = encoder
1075 .fields()
1076 .map(|name| {
1077 let field = builder
1078 .spec
1079 .field_schema(name)
1080 .expect("encoder fields were validated against this specification");
1081 StateFieldMetadata {
1082 name: name.to_owned(),
1083 description: field.description().map(str::to_owned),
1084 }
1085 })
1086 .collect::<Vec<_>>();
1087 declarations.push(StateStreamMetadata {
1088 name: config.name.clone(),
1089 directory: config.directory.clone(),
1090 sampling_interval: config.sampling_interval,
1091 fields,
1092 max_chunk_bytes: max_chunk_bytes.get(),
1093 queue_bytes: queue_bytes.get(),
1094 chunks: Vec::new(),
1095 });
1096 streams.push(PreparedStateStream {
1097 name: config.name.clone(),
1098 encoder,
1099 sampling_interval: config.sampling_interval,
1100 writer: StateStreamStorageConfig::new(
1101 &config.name,
1102 builder.root.join(&config.directory),
1103 max_chunk_bytes,
1104 queue_bytes,
1105 )?,
1106 });
1107 }
1108
1109 let created_at_utc =
1110 utc_now_rfc3339().map_err(|source| StorageError::OperationalTimestamp {
1111 operation: "create recording",
1112 source,
1113 })?;
1114 let metadata = RecordingMetadata::running(
1115 stored_time,
1116 builder.user_metadata,
1117 declarations,
1118 created_at_utc,
1119 );
1120 metadata.validate(&metadata_path)?;
1121 Ok(Self {
1122 root: builder.root,
1123 metadata_path,
1124 spec: builder.spec,
1125 metadata,
1126 streams,
1127 })
1128 }
1129}
1130
1131struct PreparedStateStream {
1133 name: String,
1134 encoder: JsonStateRecordEncoder,
1135 sampling_interval: SamplingInterval,
1136 writer: StateStreamStorageConfig,
1137}
1138
1139struct ScheduledStateStream {
1141 encoder: JsonStateRecordEncoder,
1142 sampling_interval: SamplingInterval,
1143 last_recorded_iteration: Option<u64>,
1144}
1145
1146pub(crate) struct RecordingManifest {
1152 root: PathBuf,
1153 path: PathBuf,
1154 metadata: Mutex<RecordingMetadata>,
1155}
1156
1157impl RecordingManifest {
1158 fn new(root: PathBuf, path: PathBuf, metadata: RecordingMetadata) -> Self {
1160 Self {
1161 root,
1162 path,
1163 metadata: Mutex::new(metadata),
1164 }
1165 }
1166
1167 pub(crate) fn prepare_chunk(
1169 &self,
1170 stream: &str,
1171 descriptor: jsonl_format::ChunkMetadata,
1172 ) -> Result<(), StorageError> {
1173 let mut current = lock_metadata(&self.metadata);
1174 if !matches!(current.status, RecordingStatus::Running) {
1175 return Err(StorageError::RecordingFinished);
1176 }
1177 let mut candidate = current.clone();
1178 let declaration =
1179 candidate
1180 .stream_mut(stream)
1181 .ok_or_else(|| StorageError::UnknownStateStream {
1182 stream: stream.to_owned(),
1183 })?;
1184 let expected = u64::try_from(declaration.chunks.len()).map_err(|_| {
1185 StorageError::ByteCountOverflow {
1186 stream: stream.to_owned(),
1187 }
1188 })?;
1189 if descriptor.ordinal != expected {
1190 return Err(StorageError::InvalidMetadata {
1191 path: self.path.clone(),
1192 reason: format!(
1193 "stream `{stream}` prepared chunk ordinal {}, expected {expected}",
1194 descriptor.ordinal
1195 ),
1196 });
1197 }
1198 declaration.chunks.push(descriptor);
1199 commit_metadata(&self.root, &self.path, &candidate)?;
1200 *current = candidate;
1201 Ok(())
1202 }
1203
1204 fn transition_terminal(
1206 &self,
1207 status: RecordingStatus,
1208 finalized_at_utc: String,
1209 active_duration_ns: u64,
1210 terminal_metadata: Map<String, Value>,
1211 ) -> Result<(), StorageError> {
1212 let mut current = lock_metadata(&self.metadata);
1213 let mut candidate = current.clone();
1214 candidate.status = status;
1215 candidate.timing.finalized_at_utc = Some(finalized_at_utc);
1216 candidate.timing.active_duration_ns = candidate
1217 .timing
1218 .active_duration_ns
1219 .checked_add(active_duration_ns)
1220 .ok_or(StorageError::OperationalDurationOverflow)?;
1221 candidate.terminal_metadata = terminal_metadata;
1222 commit_metadata(&self.root, &self.path, &candidate)?;
1223 *current = candidate;
1224 Ok(())
1225 }
1226
1227 fn snapshot(&self) -> RecordingMetadata {
1229 lock_metadata(&self.metadata).clone()
1230 }
1231}
1232
1233struct RecordingLease {
1238 _directory: File,
1239}
1240
1241impl RecordingLease {
1242 fn acquire(root: &Path) -> Result<Self, StorageError> {
1244 let directory = File::open(root).map_err(|source| StorageError::Io {
1245 operation: "open output root for exclusive ownership",
1246 path: root.to_path_buf(),
1247 source,
1248 })?;
1249 match FileExt::try_lock_exclusive(&directory) {
1250 Ok(()) => Ok(Self {
1251 _directory: directory,
1252 }),
1253 Err(source) if source.kind() == std::io::ErrorKind::WouldBlock => {
1254 Err(StorageError::RecordingDirectoryInUse {
1255 path: root.to_path_buf(),
1256 })
1257 }
1258 Err(source) => Err(StorageError::Io {
1259 operation: "acquire exclusive output ownership",
1260 path: root.to_path_buf(),
1261 source,
1262 }),
1263 }
1264 }
1265}
1266
1267fn load_metadata(path: &Path) -> Result<RecordingMetadata, StorageError> {
1269 let bytes = fs::read(path).map_err(|source| StorageError::Io {
1270 operation: "read metadata for resume",
1271 path: path.to_path_buf(),
1272 source,
1273 })?;
1274 let metadata: RecordingMetadata =
1275 serde_json::from_slice(&bytes).map_err(|source| StorageError::Json {
1276 operation: "parse metadata for resume",
1277 path: path.to_path_buf(),
1278 source,
1279 })?;
1280 metadata.validate(path)?;
1281 Ok(metadata)
1282}
1283
1284fn ensure_resume_match(
1286 path: &Path,
1287 expected: &RecordingMetadata,
1288 existing: &RecordingMetadata,
1289) -> Result<(), StorageError> {
1290 let mut configuration = existing.clone();
1291 for stream in &mut configuration.streams {
1292 stream.chunks.clear();
1293 }
1294 configuration.status = RecordingStatus::Running;
1295 configuration.timing = expected.timing.clone();
1296 configuration.terminal_metadata.clear();
1297 if &configuration != expected {
1298 return Err(StorageError::RecordingConfigurationMismatch {
1299 path: path.to_path_buf(),
1300 reason: "builder time axis, user metadata, or stream declarations differ".to_owned(),
1301 });
1302 }
1303 Ok(())
1304}
1305
1306fn remove_stale_metadata_temp(root: &Path) -> Result<(), StorageError> {
1308 let path = root.join(METADATA_TEMP_FILE);
1309 match fs::remove_file(&path) {
1310 Ok(()) => File::open(root)
1311 .and_then(|directory| directory.sync_all())
1312 .map_err(|source| StorageError::Io {
1313 operation: "synchronize stale metadata cleanup",
1314 path: root.to_path_buf(),
1315 source,
1316 }),
1317 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
1318 Err(source) => Err(StorageError::Io {
1319 operation: "remove stale temporary metadata",
1320 path,
1321 source,
1322 }),
1323 }
1324}
1325
1326fn lock_metadata(metadata: &Mutex<RecordingMetadata>) -> MutexGuard<'_, RecordingMetadata> {
1328 metadata
1329 .lock()
1330 .unwrap_or_else(|poisoned| poisoned.into_inner())
1331}
1332
1333fn ensure_absent(root: &Path) -> Result<(), StorageError> {
1335 match root.try_exists() {
1336 Ok(false) => Ok(()),
1337 Ok(true) => Err(StorageError::RecordingDirectoryExists {
1338 path: root.to_path_buf(),
1339 }),
1340 Err(source) => Err(StorageError::Io {
1341 operation: "inspect output root",
1342 path: root.to_path_buf(),
1343 source,
1344 }),
1345 }
1346}
1347
1348fn create_root(root: &Path) -> Result<(), StorageError> {
1350 match fs::create_dir(root) {
1351 Ok(()) => Ok(()),
1352 Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
1353 Err(StorageError::RecordingDirectoryExists {
1354 path: root.to_path_buf(),
1355 })
1356 }
1357 Err(source) => Err(StorageError::Io {
1358 operation: "create output root",
1359 path: root.to_path_buf(),
1360 source,
1361 }),
1362 }
1363}
1364
1365fn commit_metadata(
1373 root: &Path,
1374 metadata_path: &Path,
1375 metadata: &RecordingMetadata,
1376) -> Result<(), StorageError> {
1377 metadata.validate(metadata_path)?;
1378 let mut bytes = serde_json::to_vec_pretty(metadata).map_err(|source| StorageError::Json {
1379 operation: "serialize metadata",
1380 path: metadata_path.to_path_buf(),
1381 source,
1382 })?;
1383 bytes.push(b'\n');
1384
1385 let temporary_path = root.join(METADATA_TEMP_FILE);
1386 let result = write_and_replace_metadata(root, metadata_path, &temporary_path, &bytes);
1387 if result.is_err() {
1388 let _ = fs::remove_file(&temporary_path);
1389 }
1390 result
1391}
1392
1393fn write_and_replace_metadata(
1395 root: &Path,
1396 metadata_path: &Path,
1397 temporary_path: &Path,
1398 bytes: &[u8],
1399) -> Result<(), StorageError> {
1400 let mut temporary = OpenOptions::new()
1401 .write(true)
1402 .create_new(true)
1403 .open(temporary_path)
1404 .map_err(|source| StorageError::Io {
1405 operation: "create temporary metadata",
1406 path: temporary_path.to_path_buf(),
1407 source,
1408 })?;
1409 temporary
1410 .write_all(bytes)
1411 .map_err(|source| StorageError::Io {
1412 operation: "write temporary metadata",
1413 path: temporary_path.to_path_buf(),
1414 source,
1415 })?;
1416 temporary.sync_all().map_err(|source| StorageError::Io {
1417 operation: "sync temporary metadata",
1418 path: temporary_path.to_path_buf(),
1419 source,
1420 })?;
1421 drop(temporary);
1422
1423 fs::rename(temporary_path, metadata_path).map_err(|source| StorageError::Io {
1424 operation: "publish metadata",
1425 path: metadata_path.to_path_buf(),
1426 source,
1427 })?;
1428
1429 File::open(root)
1430 .and_then(|directory| directory.sync_all())
1431 .map_err(|source| StorageError::Io {
1432 operation: "sync output root",
1433 path: root.to_path_buf(),
1434 source,
1435 })
1436}