zerodds_durability_store_file/
lib.rs1#![forbid(unsafe_code)]
28
29use std::collections::BTreeMap;
30use std::path::{Path, PathBuf};
31use std::sync::Mutex;
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33
34use zerodds_durability_store::{
35 Contract, Cursor, DurabilitySample, DurabilityStore, Page, Result, Selector, StoreError,
36 StoreStats, TopicMeta,
37};
38use zerodds_qos::policies::history::HistoryKind;
39
40const HEADER_LEN: usize = 34;
46const DEFAULT_PAGE: usize = 1024;
47
48const META_FILE: &str = ".meta";
54
55pub struct FileStore {
57 root: PathBuf,
58 contracts: Mutex<BTreeMap<String, Contract>>,
59 default_contract: Contract,
60}
61
62fn backend<E: core::fmt::Display>(ctx: &str, e: E) -> StoreError {
63 StoreError::Backend(format!("file store: {ctx}: {e}"))
64}
65
66fn hex(bytes: &[u8]) -> String {
67 let mut s = String::with_capacity(bytes.len() * 2);
68 for b in bytes {
69 s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
70 s.push(char::from_digit(u32::from(b & 0xF), 16).unwrap_or('0'));
71 }
72 s
73}
74
75fn unhex(s: &str) -> Option<Vec<u8>> {
76 if s.len() % 2 != 0 {
77 return None;
78 }
79 let b = s.as_bytes();
80 let mut out = Vec::with_capacity(s.len() / 2);
81 let mut i = 0;
82 while i < b.len() {
83 let hi = (b[i] as char).to_digit(16)?;
84 let lo = (b[i + 1] as char).to_digit(16)?;
85 out.push(((hi << 4) | lo) as u8);
86 i += 2;
87 }
88 Some(out)
89}
90
91fn nanos_of(t: SystemTime) -> u64 {
92 t.duration_since(UNIX_EPOCH)
93 .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
94 .unwrap_or(0)
95}
96
97fn time_of(nanos: u64) -> SystemTime {
98 UNIX_EPOCH + Duration::from_nanos(nanos)
99}
100
101fn encode_topic_meta(meta: &TopicMeta) -> Vec<u8> {
106 let (
107 history_kind,
108 history_depth,
109 max_samples,
110 max_instances,
111 max_samples_per_instance,
112 secs,
113 nanos,
114 ) = meta.contract.to_row();
115 let name = meta.type_name.as_bytes();
116 let mut buf = Vec::with_capacity(4 + name.len() + 34);
117 buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
118 buf.extend_from_slice(name);
119 buf.push(u8::from(meta.keyed));
120 buf.extend_from_slice(&history_kind.to_le_bytes());
121 buf.extend_from_slice(&history_depth.to_le_bytes());
122 buf.extend_from_slice(&max_samples.to_le_bytes());
123 buf.extend_from_slice(&max_instances.to_le_bytes());
124 buf.extend_from_slice(&max_samples_per_instance.to_le_bytes());
125 buf.extend_from_slice(&secs.to_le_bytes());
126 buf.extend_from_slice(&nanos.to_le_bytes());
127 buf
128}
129
130fn decode_topic_meta(raw: &[u8]) -> Option<TopicMeta> {
135 let name_len = u32::from_le_bytes(raw.get(0..4)?.try_into().ok()?) as usize;
136 let mut pos = 4usize.checked_add(name_len)?;
137 let type_name = String::from_utf8(raw.get(4..pos)?.to_vec()).ok()?;
138 let keyed = *raw.get(pos)? != 0;
139 pos += 1;
140 let history_kind = u32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
141 pos += 4;
142 let history_depth = i32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
143 pos += 4;
144 let max_samples = i32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
145 pos += 4;
146 let max_instances = i32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
147 pos += 4;
148 let max_samples_per_instance = i32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
149 pos += 4;
150 let secs = u64::from_le_bytes(raw.get(pos..pos + 8)?.try_into().ok()?);
151 pos += 8;
152 let nanos = u32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
153 let contract = Contract::from_row(
154 history_kind,
155 history_depth,
156 max_samples,
157 max_instances,
158 max_samples_per_instance,
159 secs,
160 nanos,
161 )?;
162 Some(TopicMeta {
163 type_name,
164 keyed,
165 contract,
166 })
167}
168
169impl FileStore {
170 pub fn open<P: Into<PathBuf>>(root: P, default_contract: Contract) -> Result<Self> {
176 let root = root.into();
177 std::fs::create_dir_all(&root).map_err(|e| backend("create root", e))?;
178 Ok(Self {
179 root,
180 contracts: Mutex::new(BTreeMap::new()),
181 default_contract,
182 })
183 }
184
185 fn contract_for(&self, topic: &str) -> Result<Contract> {
189 if let Some(meta) = self.read_meta(topic)? {
190 return Ok(meta.contract);
191 }
192 Ok(self
193 .contracts
194 .lock()
195 .map_err(|_| StoreError::Poisoned("file store contracts"))?
196 .get(topic)
197 .copied()
198 .unwrap_or(self.default_contract))
199 }
200
201 fn topic_dir(&self, topic: &str) -> PathBuf {
202 self.root.join(hex(topic.as_bytes()))
203 }
204
205 fn meta_path(&self, topic: &str) -> PathBuf {
206 self.topic_dir(topic).join(META_FILE)
207 }
208
209 fn read_meta(&self, topic: &str) -> Result<Option<TopicMeta>> {
216 let path = self.meta_path(topic);
217 let raw = match std::fs::read(&path) {
218 Ok(b) => b,
219 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
220 Err(e) => return Err(backend("read topic meta", e)),
221 };
222 decode_topic_meta(&raw).map(Some).ok_or_else(|| {
223 StoreError::Backend(format!(
224 "file store: corrupt topic meta sidecar: {}",
225 path.display()
226 ))
227 })
228 }
229
230 fn write_meta(&self, topic: &str, meta: &TopicMeta) -> Result<()> {
236 let dir = self.topic_dir(topic);
237 std::fs::create_dir_all(&dir).map_err(|e| backend("mkdir topic", e))?;
238 let buf = encode_topic_meta(meta);
239 let tmp = dir.join(format!(
240 ".meta.tmp-{}-{:?}",
241 std::process::id(),
242 std::thread::current().id()
243 ));
244 std::fs::write(&tmp, &buf).map_err(|e| backend("write topic meta tmp", e))?;
245 std::fs::rename(&tmp, dir.join(META_FILE)).map_err(|e| backend("rename topic meta", e))?;
246 Ok(())
247 }
248
249 fn instance_dir(&self, topic: &str, key: &[u8; 16]) -> PathBuf {
250 self.topic_dir(topic).join(hex(key))
251 }
252
253 fn list_samples(dir: &Path) -> Vec<(u64, PathBuf)> {
256 let mut out = Vec::new();
257 if let Ok(rd) = std::fs::read_dir(dir) {
258 for e in rd.flatten() {
259 let name = e.file_name().to_string_lossy().into_owned();
260 if let Some(stem) = name.strip_suffix(".bin") {
261 if let Ok(seq) = stem.parse::<u64>() {
262 out.push((seq, e.path()));
263 }
264 }
265 }
266 }
267 out.sort_by_key(|(s, _)| *s);
268 out
269 }
270
271 fn list_instances(&self, topic: &str) -> Vec<[u8; 16]> {
272 let mut out = Vec::new();
273 if let Ok(rd) = std::fs::read_dir(self.topic_dir(topic)) {
274 for e in rd.flatten() {
275 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
276 if let Some(bytes) = unhex(&e.file_name().to_string_lossy()) {
277 if let Ok(k) = <[u8; 16]>::try_from(bytes.as_slice()) {
278 out.push(k);
279 }
280 }
281 }
282 }
283 }
284 out.sort_unstable();
285 out
286 }
287
288 fn read_sample(topic: &str, key: [u8; 16], seq: u64, path: &Path) -> Option<DurabilitySample> {
289 let raw = std::fs::read(path).ok()?;
290 if raw.len() < HEADER_LEN {
291 return None;
292 }
293 let nanos = u64::from_le_bytes(raw[..8].try_into().ok()?);
294 let mut source_guid = [0u8; 16];
295 source_guid.copy_from_slice(&raw[10..26]);
296 let source_sequence = i64::from_le_bytes(raw[26..34].try_into().ok()?);
297 Some(DurabilitySample {
298 topic: topic.to_string(),
299 instance_key: key,
300 sequence: seq,
301 payload: raw[HEADER_LEN..].to_vec(),
302 representation: raw[8],
303 big_endian: raw[9] != 0,
304 created_at: time_of(nanos),
305 source_guid,
306 source_sequence,
307 })
308 }
309
310 fn topic_sample_count(&self, topic: &str) -> usize {
311 self.list_instances(topic)
312 .iter()
313 .map(|k| Self::list_samples(&self.instance_dir(topic, k)).len())
314 .sum()
315 }
316}
317
318impl DurabilityStore for FileStore {
319 fn set_contract(&self, topic: &str, contract: Contract) -> Result<()> {
320 self.contracts
321 .lock()
322 .map_err(|_| StoreError::Poisoned("file store contracts"))?
323 .insert(topic.to_string(), contract);
324 Ok(())
325 }
326
327 fn set_topic_meta(&self, topic: &str, meta: TopicMeta) -> Result<()> {
328 self.write_meta(topic, &meta)
329 }
330
331 fn topic_meta(&self, topic: &str) -> Result<Option<TopicMeta>> {
332 self.read_meta(topic)
333 }
334
335 fn store(&self, sample: DurabilitySample) -> Result<()> {
336 let contract = self.contract_for(&sample.topic)?;
337 let inst_dir = self.instance_dir(&sample.topic, &sample.instance_key);
338 let new_instance = !inst_dir.exists();
339
340 let is_resend = inst_dir.join(format!("{}.bin", sample.sequence)).exists();
343
344 if !is_resend
345 && contract.samples_bounded()
346 && matches!(contract.history_kind, HistoryKind::KeepAll)
347 && self.topic_sample_count(&sample.topic) >= contract.max_samples as usize
348 {
349 return Err(StoreError::OutOfResources("max_samples"));
350 }
351 if new_instance
352 && contract.instances_bounded()
353 && self.list_instances(&sample.topic).len() >= contract.max_instances as usize
354 {
355 return Err(StoreError::OutOfResources("max_instances"));
356 }
357 std::fs::create_dir_all(&inst_dir).map_err(|e| backend("mkdir instance", e))?;
358
359 match contract.history_kind {
360 HistoryKind::KeepLast => {
361 let depth = contract.effective_depth();
362 let existing = Self::list_samples(&inst_dir);
363 let mut to_remove = existing.len() as isize + 1 - depth as isize;
365 for (_, p) in existing {
366 if to_remove <= 0 {
367 break;
368 }
369 let _ = std::fs::remove_file(&p);
370 to_remove -= 1;
371 }
372 }
373 HistoryKind::KeepAll => {
374 if !is_resend
375 && contract.per_instance_bounded()
376 && Self::list_samples(&inst_dir).len()
377 >= contract.max_samples_per_instance as usize
378 {
379 return Err(StoreError::OutOfResources("max_samples_per_instance"));
380 }
381 }
382 }
383
384 let mut buf = Vec::with_capacity(HEADER_LEN + sample.payload.len());
385 buf.extend_from_slice(&nanos_of(sample.created_at).to_le_bytes());
386 buf.push(sample.representation);
387 buf.push(u8::from(sample.big_endian));
388 buf.extend_from_slice(&sample.source_guid);
389 buf.extend_from_slice(&sample.source_sequence.to_le_bytes());
390 buf.extend_from_slice(&sample.payload);
391 let path = inst_dir.join(format!("{}.bin", sample.sequence));
392 std::fs::write(&path, &buf).map_err(|e| backend("write sample", e))?;
393 Ok(())
394 }
395
396 fn query(&self, topic: &str, selector: &Selector) -> Result<Page> {
397 let mut matched: Vec<DurabilitySample> = Vec::new();
398 let instances = match selector.instance_key {
399 Some(k) => vec![k],
400 None => self.list_instances(topic),
401 };
402 for key in instances {
403 let dir = self.instance_dir(topic, &key);
404 for (seq, path) in Self::list_samples(&dir) {
405 if let Some(s) = Self::read_sample(topic, key, seq, &path) {
406 if selector.matches(&s) {
407 matched.push(s);
408 }
409 }
410 }
411 }
412 matched.sort_by_key(|s| (s.instance_key, s.sequence));
413 let limit = selector.limit.unwrap_or(DEFAULT_PAGE);
414 let exhausted = matched.len() <= limit;
415 matched.truncate(limit);
416 let next: Option<Cursor> = if exhausted {
417 None
418 } else {
419 matched.last().map(|s| (s.instance_key, s.sequence))
420 };
421 Ok(Page {
422 samples: matched,
423 next,
424 })
425 }
426
427 fn unregister(&self, topic: &str, instance_key: &[u8; 16], now: SystemTime) -> Result<()> {
428 let dir = self.instance_dir(topic, instance_key);
429 if dir.exists() {
430 let marker = dir.join(".unregistered");
431 std::fs::write(&marker, nanos_of(now).to_string())
432 .map_err(|e| backend("write unregister marker", e))?;
433 }
434 Ok(())
435 }
436
437 fn cleanup(&self, now: SystemTime) -> Result<usize> {
438 let mut removed = 0usize;
439 let topic_dirs = match std::fs::read_dir(&self.root) {
440 Ok(d) => d,
441 Err(_) => return Ok(0),
442 };
443 for topic_entry in topic_dirs.flatten() {
444 let Some(topic_bytes) = unhex(&topic_entry.file_name().to_string_lossy()) else {
445 continue;
446 };
447 let topic = String::from_utf8_lossy(&topic_bytes).into_owned();
448 let delay = self.contract_for(&topic)?.cleanup_delay;
449 let Ok(inst_dirs) = std::fs::read_dir(topic_entry.path()) else {
450 continue;
451 };
452 for inst_entry in inst_dirs.flatten() {
453 let marker = inst_entry.path().join(".unregistered");
454 let Ok(content) = std::fs::read_to_string(&marker) else {
455 continue;
456 };
457 let Ok(nanos) = content.trim().parse::<u64>() else {
458 continue;
459 };
460 if let Some(deadline) = time_of(nanos).checked_add(delay) {
461 if now >= deadline {
462 let _ = std::fs::remove_dir_all(inst_entry.path());
463 removed += 1;
464 }
465 }
466 }
467 }
468 Ok(removed)
469 }
470
471 fn stats(&self, topic: &str) -> Result<StoreStats> {
472 let mut stats = StoreStats::default();
473 for key in self.list_instances(topic) {
474 stats.instances += 1;
475 for (seq, path) in Self::list_samples(&self.instance_dir(topic, &key)) {
476 if let Some(s) = Self::read_sample(topic, key, seq, &path) {
477 stats.samples += 1;
478 stats.bytes += s.payload.len() as u64;
479 }
480 }
481 }
482 Ok(stats)
483 }
484}