1use spatialrust_records::record_storage_bytes;
4
5use crate::{MemoryEpisode, StampedRecord, SyncError, SyncResult};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub struct EpisodeLimits {
13 pub max_records: u64,
15 pub max_points: u64,
17 pub max_bytes: u64,
19}
20
21impl EpisodeLimits {
22 #[must_use]
24 pub const fn new(max_records: u64, max_points: u64, max_bytes: u64) -> Self {
25 Self { max_records, max_points, max_bytes }
26 }
27
28 fn validate(self) -> SyncResult<Self> {
29 for (name, value) in [
30 ("max_records", self.max_records),
31 ("max_points", self.max_points),
32 ("max_bytes", self.max_bytes),
33 ] {
34 if value == 0 {
35 return Err(SyncError::InvalidConfiguration(format!(
36 "episode {name} must be greater than zero"
37 )));
38 }
39 }
40 Ok(self)
41 }
42}
43
44impl Default for EpisodeLimits {
45 fn default() -> Self {
46 Self::new(4_096, 16_777_216, 512 * 1024 * 1024)
47 }
48}
49
50#[derive(Debug)]
56pub struct MemoryEpisodeBuilder {
57 limits: EpisodeLimits,
58 records: Vec<StampedRecord>,
59 points: u64,
60 bytes: u64,
61}
62
63impl MemoryEpisodeBuilder {
64 pub fn try_new(limits: EpisodeLimits) -> SyncResult<Self> {
66 Ok(Self { limits: limits.validate()?, records: Vec::new(), points: 0, bytes: 0 })
67 }
68
69 pub fn push(&mut self, stamped: StampedRecord) -> SyncResult<()> {
71 let record_points = u64::try_from(stamped.record.cloud().len())
72 .map_err(|_| SyncError::InvalidConfiguration("episode point count overflow".into()))?;
73 let record_bytes = record_storage_bytes(&stamped.record)?;
74 let current_records = u64::try_from(self.records.len())
75 .map_err(|_| SyncError::InvalidConfiguration("episode record count overflow".into()))?;
76 let next_points = self.points.checked_add(record_points).ok_or_else(|| {
77 SyncError::InvalidConfiguration("episode point count overflow".into())
78 })?;
79 let next_bytes = self
80 .bytes
81 .checked_add(record_bytes)
82 .ok_or_else(|| SyncError::InvalidConfiguration("episode byte count overflow".into()))?;
83
84 check_limit("records", 1, current_records, self.limits.max_records)?;
85 check_limit("points", record_points, self.points, self.limits.max_points)?;
86 check_limit("bytes", record_bytes, self.bytes, self.limits.max_bytes)?;
87
88 self.records.push(stamped);
89 self.points = next_points;
90 self.bytes = next_bytes;
91 Ok(())
92 }
93
94 #[must_use]
96 pub fn len(&self) -> usize {
97 self.records.len()
98 }
99
100 #[must_use]
102 pub fn is_empty(&self) -> bool {
103 self.records.is_empty()
104 }
105
106 #[must_use]
108 pub const fn points(&self) -> u64 {
109 self.points
110 }
111
112 #[must_use]
114 pub const fn bytes(&self) -> u64 {
115 self.bytes
116 }
117
118 #[must_use]
120 pub const fn limits(&self) -> EpisodeLimits {
121 self.limits
122 }
123
124 #[must_use]
126 pub fn finish(self) -> MemoryEpisode {
127 MemoryEpisode::from_records(self.records)
128 }
129}
130
131fn check_limit(resource: &'static str, requested: u64, current: u64, limit: u64) -> SyncResult<()> {
132 let next = current.checked_add(requested).ok_or(SyncError::EpisodeLimitExceeded {
133 resource,
134 requested,
135 current,
136 limit,
137 })?;
138 if next > limit {
139 return Err(SyncError::EpisodeLimitExceeded { resource, requested, current, limit });
140 }
141 Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146 use super::{EpisodeLimits, MemoryEpisodeBuilder};
147 use crate::{ClockDomain, StampedRecord, StampedTime, TopicId};
148 use spatialrust_core::{
149 PointBuffer, PointBufferSet, PointCloud, SpatialMetadata, StandardSchemas, Timestamp,
150 };
151 use spatialrust_records::{SchemaVersion, SpatialRecord};
152
153 fn sample(topic: &str, timestamp: u64, point_count: usize) -> StampedRecord {
154 let mut buffers = PointBufferSet::new();
155 buffers.insert("x", PointBuffer::from_f32(vec![1.0; point_count]));
156 buffers.insert("y", PointBuffer::from_f32(vec![0.0; point_count]));
157 buffers.insert("z", PointBuffer::from_f32(vec![0.0; point_count]));
158 let cloud = PointCloud::try_from_parts(
159 StandardSchemas::point_xyz(),
160 buffers,
161 SpatialMetadata::default(),
162 )
163 .unwrap();
164 let record =
165 SpatialRecord::try_from_cloud("point", SchemaVersion::new(1, 0), cloud).unwrap();
166 StampedRecord::new(
167 TopicId::new(topic),
168 StampedTime::exact("test", ClockDomain::External, Timestamp::from_nanos(timestamp)),
169 record,
170 )
171 }
172
173 #[test]
174 fn enforces_limits_before_retaining_record() {
175 let mut builder = MemoryEpisodeBuilder::try_new(EpisodeLimits::new(1, 4, 100)).unwrap();
176 builder.push(sample("lidar", 20, 1)).unwrap();
177 let error = builder.push(sample("lidar", 10, 2)).unwrap_err();
178 assert!(error.to_string().contains("episode records limit exceeded"));
179 assert_eq!(builder.len(), 1);
180 assert_eq!(builder.points(), 1);
181 assert_eq!(builder.bytes(), 12);
182 }
183
184 #[test]
185 fn finish_uses_episode_deterministic_order() {
186 let mut builder = MemoryEpisodeBuilder::try_new(EpisodeLimits::new(4, 4, 48)).unwrap();
187 builder.push(sample("lidar", 20, 1)).unwrap();
188 builder.push(sample("camera", 10, 1)).unwrap();
189 let episode = builder.finish();
190 assert_eq!(episode.records()[0].topic.as_str(), "camera");
191 assert_eq!(episode.records()[1].topic.as_str(), "lidar");
192 }
193}