Skip to main content

unifier/
store.rs

1//! In-memory hot store with dirty tracking; flush writes only changed data to disk.
2
3use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use uuid::Uuid;
8
9use crate::constants::{CRON, KEYS, MAILBOX};
10use crate::cron::CronSchedule;
11use crate::error::{Error, Result};
12use crate::fs_text::{read_text, write_text, write_text_atomic};
13use crate::home::UnifierHome;
14use crate::paths::{cron_dir, key_path, mailbox_dir, message_path, parse_message_id};
15use crate::postbox::Message;
16use crate::scope::resolve_under_root;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub(crate) enum KeyState {
20    Present { value: String, dirty: bool },
21    Deleted { dirty: bool },
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25struct MsgState {
26    body: String,
27    dirty: bool,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31enum MessageKind {
32    Mailbox,
33    Cron,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub(crate) struct EventState {
38    pub(crate) body: String,
39    pub(crate) dirty: bool,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub(crate) enum StagingValue {
44    Present(String),
45    Deleted,
46}
47
48/// Active tick: reads frozen snapshot, writes go to staging only.
49#[derive(Debug, Clone)]
50pub(crate) struct ActiveTick {
51    pub number: u64,
52    pub(crate) read_snapshot: BTreeMap<String, String>,
53    pub(crate) staging: BTreeMap<String, StagingValue>,
54    pub(crate) locks: BTreeSet<String>,
55}
56
57/// In-memory mirror of the postbox; disk is touched only on [`HotStore::flush`].
58#[derive(Debug, Default)]
59pub struct HotStore {
60    pub(crate) keys: BTreeMap<String, KeyState>,
61    mailboxes: BTreeMap<String, BTreeMap<Uuid, MsgState>>,
62    cron: BTreeMap<String, BTreeMap<Uuid, MsgState>>,
63    pub(crate) events: BTreeMap<Uuid, EventState>,
64    removed_messages: BTreeSet<(MessageKind, String, Uuid)>,
65    pub(crate) committed_tick: u64,
66    pub(crate) active_tick: Option<ActiveTick>,
67    pub(crate) tick_queue: VecDeque<String>,
68}
69
70impl HotStore {
71    pub fn load(home: &UnifierHome) -> Result<Self> {
72        let mut store = Self::default();
73        store.load_keys(home)?;
74        store.mailboxes = load_message_buckets(home, MAILBOX)?;
75        store.cron = load_message_buckets(home, CRON)?;
76        store.events = crate::tick::load_events(home)?;
77        store.committed_tick = crate::tick::load_committed_tick(home)?;
78        Ok(store)
79    }
80
81    pub fn is_dirty(&self) -> bool {
82        self.keys.values().any(|k| match k {
83            KeyState::Present { dirty, .. } | KeyState::Deleted { dirty } => *dirty,
84        }) || self.has_dirty_messages(&self.mailboxes)
85            || self.has_dirty_messages(&self.cron)
86            || self.events.values().any(|e| e.dirty)
87            || !self.removed_messages.is_empty()
88            || self.active_tick.is_some()
89    }
90
91    pub fn put_key(&mut self, key: &str, value: &str) -> Result<()> {
92        validate_key(key)?;
93        if let Some(tick) = &self.active_tick {
94            if tick.locks.contains(key) {
95                return Err(Error::msg(format!(
96                    "key locked during tick {}: {key}",
97                    tick.number
98                )));
99            }
100        }
101        if let Some(tick) = &mut self.active_tick {
102            tick.staging
103                .insert(key.to_string(), StagingValue::Present(value.to_string()));
104            return Ok(());
105        }
106        self.keys.insert(
107            key.to_string(),
108            KeyState::Present {
109                value: value.to_string(),
110                dirty: true,
111            },
112        );
113        Ok(())
114    }
115
116    pub fn get_key(&self, key: &str) -> Result<Option<String>> {
117        validate_key(key)?;
118        if let Some(tick) = &self.active_tick {
119            if let Some(staged) = tick.staging.get(key) {
120                return Ok(match staged {
121                    StagingValue::Present(v) => Some(v.clone()),
122                    StagingValue::Deleted => None,
123                });
124            }
125            return Ok(tick.read_snapshot.get(key).cloned());
126        }
127        Ok(match self.keys.get(key) {
128            Some(KeyState::Present { value, .. }) => Some(value.clone()),
129            Some(KeyState::Deleted { .. }) | None => None,
130        })
131    }
132
133    pub fn delete_key(&mut self, key: &str) -> Result<bool> {
134        validate_key(key)?;
135        if let Some(tick) = &self.active_tick {
136            if tick.locks.contains(key) {
137                return Err(Error::msg(format!(
138                    "key locked during tick {}: {key}",
139                    tick.number
140                )));
141            }
142        }
143        if let Some(tick) = &mut self.active_tick {
144            let existed = tick.staging.contains_key(key) || tick.read_snapshot.contains_key(key);
145            if !existed {
146                return Ok(false);
147            }
148            tick.staging.insert(key.to_string(), StagingValue::Deleted);
149            return Ok(true);
150        }
151        let existed = matches!(self.keys.get(key), Some(KeyState::Present { .. }));
152        if !existed {
153            return Ok(false);
154        }
155        self.keys
156            .insert(key.to_string(), KeyState::Deleted { dirty: true });
157        Ok(true)
158    }
159
160    pub fn send(&mut self, recipient: &str, body: &str) -> Result<Uuid> {
161        self.send_from(crate::envelope::DEFAULT_SENDER, recipient, body)
162    }
163
164    pub fn send_from(&mut self, from: &str, recipient: &str, body: &str) -> Result<Uuid> {
165        validate_segment(from, "from")?;
166        validate_segment(recipient, "recipient")?;
167        let env = crate::envelope::Envelope::new(
168            from,
169            recipient,
170            crate::envelope::Envelope::parse_payload(body),
171        );
172        let id = env.id;
173        self.mailboxes
174            .entry(recipient.to_string())
175            .or_default()
176            .insert(
177                id,
178                MsgState {
179                    body: env.to_json()?,
180                    dirty: true,
181                },
182            );
183        Ok(id)
184    }
185
186    pub fn post_cron(&mut self, schedule: &str, body: &str) -> Result<Uuid> {
187        CronSchedule::parse(schedule)?;
188        let id = Uuid::new_v4();
189        self.cron.entry(schedule.to_string()).or_default().insert(
190            id,
191            MsgState {
192                body: body.to_string(),
193                dirty: true,
194            },
195        );
196        Ok(id)
197    }
198
199    pub fn poll_mailbox(&self, recipient: &str) -> Result<Vec<Message>> {
200        validate_segment(recipient, "recipient")?;
201        Ok(self.collect_messages(
202            MessageKind::Mailbox,
203            recipient,
204            self.mailboxes.get(recipient),
205        ))
206    }
207
208    pub fn poll_cron(&self) -> Result<Vec<Message>> {
209        let mut out = Vec::new();
210        for (schedule, msgs) in &self.cron {
211            let parsed = CronSchedule::parse(schedule)?;
212            if !parsed.matches_now() {
213                continue;
214            }
215            out.extend(self.collect_messages(MessageKind::Cron, schedule, Some(msgs)));
216        }
217        out.sort_by(|a, b| a.path.cmp(&b.path));
218        Ok(out)
219    }
220
221    pub fn list_dir(&self, home: &UnifierHome, subpath: &str) -> Result<Vec<Message>> {
222        let dir = resolve_under_root(home.path(), subpath)?;
223        let rel = dir
224            .strip_prefix(home.path())
225            .map_err(|_| Error::msg("path escapes store root"))?;
226        let parts: Vec<_> = rel.iter().collect();
227        match parts.as_slice() {
228            [p1, p2] if p1.to_string_lossy() == MAILBOX => self.poll_mailbox(&p2.to_string_lossy()),
229            [p1, p2] if p1.to_string_lossy() == CRON => {
230                let schedule = p2.to_string_lossy();
231                Ok(self.collect_messages(
232                    MessageKind::Cron,
233                    &schedule,
234                    self.cron.get(schedule.as_ref()),
235                ))
236            }
237            _ => Ok(Vec::new()),
238        }
239    }
240
241    pub fn ack(&mut self, home: &UnifierHome, id_or_path: &str) -> Result<bool> {
242        if id_or_path.contains('/') || Path::new(id_or_path).is_absolute() {
243            let path = if Path::new(id_or_path).is_absolute() {
244                PathBuf::from(id_or_path)
245            } else {
246                resolve_under_root(home.path(), id_or_path)?
247            };
248            return self.ack_path(home, &path);
249        }
250        let id = Uuid::parse_str(id_or_path)
251            .map_err(|_| Error::msg(format!("invalid message id or path: {id_or_path}")))?;
252        self.ack_id(home, &id)
253    }
254
255    pub fn flush(&mut self, home: &UnifierHome) -> Result<()> {
256        if self.active_tick.is_some() {
257            return Err(Error::msg(
258                "cannot flush while a tick is active; run tick end first",
259            ));
260        }
261        home.ensure()?;
262        self.flush_keys(home)?;
263        self.flush_mailboxes(home)?;
264        self.flush_cron(home)?;
265        self.flush_events(home)?;
266        self.removed_messages.clear();
267        Ok(())
268    }
269
270    fn load_keys(&mut self, home: &UnifierHome) -> Result<()> {
271        let root = home.path().join(KEYS);
272        if !root.is_dir() {
273            return Ok(());
274        }
275        let mut prefix = Vec::new();
276        self.walk_key_files(&root, &mut prefix)?;
277        Ok(())
278    }
279
280    fn walk_key_files(&mut self, dir: &Path, prefix: &mut Vec<String>) -> Result<()> {
281        for entry in fs::read_dir(dir)? {
282            let entry = entry?;
283            let name = entry.file_name().to_string_lossy().into_owned();
284            if entry.file_type()?.is_dir() {
285                prefix.push(name);
286                self.walk_key_files(&entry.path(), prefix)?;
287                prefix.pop();
288            } else if entry.file_type()?.is_file() {
289                prefix.push(name);
290                let key = prefix.join("/");
291                prefix.pop();
292                let value = read_text(&entry.path())?;
293                self.keys.insert(
294                    key,
295                    KeyState::Present {
296                        value,
297                        dirty: false,
298                    },
299                );
300            }
301        }
302        Ok(())
303    }
304
305    fn flush_keys(&mut self, home: &UnifierHome) -> Result<()> {
306        let mut to_remove = Vec::new();
307        for (key, state) in &mut self.keys {
308            match state {
309                KeyState::Present { value, dirty: true } => {
310                    write_text_atomic(&key_path(home, key), value)?;
311                    *state = KeyState::Present {
312                        value: value.clone(),
313                        dirty: false,
314                    };
315                }
316                KeyState::Deleted { dirty: true } => {
317                    let path = key_path(home, key);
318                    if path.is_file() {
319                        fs::remove_file(&path)?;
320                    }
321                    to_remove.push(key.clone());
322                }
323                _ => {}
324            }
325        }
326        for key in to_remove {
327            self.keys.remove(&key);
328        }
329        Ok(())
330    }
331
332    fn flush_mailboxes(&mut self, home: &UnifierHome) -> Result<()> {
333        flush_message_buckets(
334            home,
335            MessageKind::Mailbox,
336            &mut self.mailboxes,
337            &mut self.removed_messages,
338            mailbox_dir,
339        )
340    }
341
342    fn flush_cron(&mut self, home: &UnifierHome) -> Result<()> {
343        flush_message_buckets(
344            home,
345            MessageKind::Cron,
346            &mut self.cron,
347            &mut self.removed_messages,
348            cron_dir,
349        )
350    }
351
352    fn ack_path(&mut self, home: &UnifierHome, path: &Path) -> Result<bool> {
353        let rel = path.strip_prefix(home.path()).ok();
354        let Some(rel) = rel else {
355            return Ok(false);
356        };
357        let parts: Vec<_> = rel
358            .iter()
359            .map(|p| p.to_string_lossy().into_owned())
360            .collect();
361        if parts.len() != 3 {
362            return Ok(false);
363        }
364        let Some(id) = parse_message_id(&parts[2]) else {
365            return Ok(false);
366        };
367        let existed = match parts[0].as_str() {
368            MAILBOX => {
369                let had = self
370                    .mailboxes
371                    .get(&parts[1])
372                    .is_some_and(|m| m.contains_key(&id))
373                    || path.is_file();
374                if had {
375                    self.removed_messages
376                        .insert((MessageKind::Mailbox, parts[1].clone(), id));
377                    if let Some(msgs) = self.mailboxes.get_mut(&parts[1]) {
378                        msgs.remove(&id);
379                    }
380                }
381                had
382            }
383            CRON => {
384                let had = self
385                    .cron
386                    .get(&parts[1])
387                    .is_some_and(|m| m.contains_key(&id))
388                    || path.is_file();
389                if had {
390                    self.removed_messages
391                        .insert((MessageKind::Cron, parts[1].clone(), id));
392                    if let Some(msgs) = self.cron.get_mut(&parts[1]) {
393                        msgs.remove(&id);
394                    }
395                }
396                had
397            }
398            _ => false,
399        };
400        Ok(existed)
401    }
402
403    fn ack_id(&mut self, home: &UnifierHome, id: &Uuid) -> Result<bool> {
404        if let Some(recipient) = self
405            .mailboxes
406            .iter()
407            .find_map(|(name, msgs)| msgs.contains_key(id).then(|| name.clone()))
408        {
409            self.removed_messages
410                .insert((MessageKind::Mailbox, recipient.clone(), *id));
411            if let Some(m) = self.mailboxes.get_mut(&recipient) {
412                m.remove(id);
413            }
414            return Ok(true);
415        }
416        if let Some(schedule) = self
417            .cron
418            .iter()
419            .find_map(|(name, msgs)| msgs.contains_key(id).then(|| name.clone()))
420        {
421            self.removed_messages
422                .insert((MessageKind::Cron, schedule.clone(), *id));
423            if let Some(m) = self.cron.get_mut(&schedule) {
424                m.remove(id);
425            }
426            return Ok(true);
427        }
428        // Message may exist on disk but not loaded into a bucket (empty dir walk).
429        let filename = format!("{}.txt", id.hyphenated());
430        for sub in [MAILBOX, CRON] {
431            let base = home.path().join(sub);
432            if !base.is_dir() {
433                continue;
434            }
435            for entry in fs::read_dir(&base)? {
436                let entry = entry?;
437                if entry.file_type()?.is_dir() {
438                    let candidate = entry.path().join(&filename);
439                    if candidate.is_file() {
440                        let bucket = entry.file_name().to_string_lossy().into_owned();
441                        let kind = if sub == MAILBOX {
442                            MessageKind::Mailbox
443                        } else {
444                            MessageKind::Cron
445                        };
446                        self.removed_messages.insert((kind, bucket, *id));
447                        return Ok(true);
448                    }
449                }
450            }
451        }
452        Ok(false)
453    }
454
455    fn collect_messages(
456        &self,
457        kind: MessageKind,
458        bucket: &str,
459        msgs: Option<&BTreeMap<Uuid, MsgState>>,
460    ) -> Vec<Message> {
461        let Some(msgs) = msgs else {
462            return Vec::new();
463        };
464        let base = match kind {
465            MessageKind::Mailbox => PathBuf::from(MAILBOX).join(bucket),
466            MessageKind::Cron => PathBuf::from(CRON).join(bucket),
467        };
468        let mut out = Vec::new();
469        for (id, msg) in msgs {
470            out.push(Message {
471                id: *id,
472                path: base.join(format!("{}.txt", id.hyphenated())),
473                body: msg.body.clone(),
474            });
475        }
476        out.sort_by_key(|m| m.id);
477        out
478    }
479
480    fn has_dirty_messages(&self, buckets: &BTreeMap<String, BTreeMap<Uuid, MsgState>>) -> bool {
481        buckets.values().any(|msgs| msgs.values().any(|m| m.dirty))
482    }
483}
484
485fn load_message_buckets(
486    home: &UnifierHome,
487    top: &str,
488) -> Result<BTreeMap<String, BTreeMap<Uuid, MsgState>>> {
489    let mut target = BTreeMap::new();
490    let base = home.path().join(top);
491    if !base.is_dir() {
492        return Ok(target);
493    }
494    for entry in fs::read_dir(&base)? {
495        let entry = entry?;
496        if !entry.file_type()?.is_dir() {
497            continue;
498        }
499        let bucket = entry.file_name().to_string_lossy().into_owned();
500        let mut msgs = BTreeMap::new();
501        for msg_entry in fs::read_dir(entry.path())? {
502            let msg_entry = msg_entry?;
503            if !msg_entry.file_type()?.is_file() {
504                continue;
505            }
506            let name = msg_entry.file_name().to_string_lossy().into_owned();
507            let Some(id) = parse_message_id(&name) else {
508                continue;
509            };
510            msgs.insert(
511                id,
512                MsgState {
513                    body: read_text(&msg_entry.path())?,
514                    dirty: false,
515                },
516            );
517        }
518        if !msgs.is_empty() {
519            target.insert(bucket, msgs);
520        }
521    }
522    Ok(target)
523}
524
525fn flush_message_buckets<F>(
526    home: &UnifierHome,
527    kind: MessageKind,
528    buckets: &mut BTreeMap<String, BTreeMap<Uuid, MsgState>>,
529    removed_messages: &mut BTreeSet<(MessageKind, String, Uuid)>,
530    dir_for: F,
531) -> Result<()>
532where
533    F: Fn(&UnifierHome, &str) -> PathBuf,
534{
535    for (bucket, msgs) in buckets.iter_mut() {
536        for (id, msg) in msgs.iter_mut() {
537            if msg.dirty {
538                write_text(&message_path(&dir_for(home, bucket), id), &msg.body)?;
539                msg.dirty = false;
540            }
541        }
542    }
543    let removed: Vec<_> = removed_messages
544        .iter()
545        .filter(|(k, _, _)| *k == kind)
546        .cloned()
547        .collect();
548    for (_, bucket, id) in removed {
549        let path = message_path(&dir_for(home, &bucket), &id);
550        if path.is_file() {
551            fs::remove_file(path)?;
552        }
553        if let Some(msgs) = buckets.get_mut(&bucket) {
554            msgs.remove(&id);
555        }
556        removed_messages.remove(&(kind, bucket.clone(), id));
557    }
558    buckets.retain(|_, msgs| !msgs.is_empty());
559    Ok(())
560}
561
562pub(crate) fn validate_key(key: &str) -> Result<()> {
563    if key.is_empty() {
564        return Err(Error::msg("key must not be empty"));
565    }
566    if key.contains("..") {
567        return Err(Error::msg("key must not contain '..'"));
568    }
569    Ok(())
570}
571
572pub(crate) fn validate_segment(segment: &str, label: &str) -> Result<()> {
573    if segment.is_empty() || segment.contains('/') || segment.contains("..") {
574        return Err(Error::msg(format!("invalid {label}: {segment}")));
575    }
576    Ok(())
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::home::UnifierHome;
583    use tempfile::tempdir;
584
585    #[test]
586    fn put_get_without_flush_leaves_disk_clean() {
587        let tmp = tempdir().unwrap();
588        let home = UnifierHome::resolve(Some(tmp.path().to_path_buf()), None).unwrap();
589        let mut store = HotStore::load(&home).unwrap();
590
591        store.put_key("app/theme", "dark").unwrap();
592        assert_eq!(store.get_key("app/theme").unwrap(), Some("dark".into()));
593        assert!(store.is_dirty());
594        assert!(!home.path().join("keys/app/theme").exists());
595
596        store.flush(&home).unwrap();
597        assert!(!store.is_dirty());
598        assert!(home.path().join("keys/app/theme").is_file());
599    }
600
601    #[test]
602    fn send_without_flush_then_flush_persists() {
603        let tmp = tempdir().unwrap();
604        let home = UnifierHome::resolve(Some(tmp.path().to_path_buf()), None).unwrap();
605        let mut store = HotStore::load(&home).unwrap();
606
607        store.send("worker", "hello").unwrap();
608        assert!(store.poll_mailbox("worker").unwrap().len() == 1);
609        assert!(fs::read_dir(home.path().join("mailbox")).is_err());
610
611        store.flush(&home).unwrap();
612        assert!(home.path().join("mailbox/worker").is_dir());
613    }
614
615    #[test]
616    fn load_existing_keys_from_disk() {
617        let tmp = tempdir().unwrap();
618        let home = UnifierHome::resolve(Some(tmp.path().to_path_buf()), None).unwrap();
619        put_key_fs(&home, "k", "v").unwrap();
620
621        let store = HotStore::load(&home).unwrap();
622        assert_eq!(store.get_key("k").unwrap(), Some("v".into()));
623    }
624
625    fn put_key_fs(home: &UnifierHome, key: &str, value: &str) -> Result<()> {
626        write_text_atomic(&key_path(home, key), value)
627    }
628}