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!("key locked during tick {}: {key}", tick.number)));
96            }
97        }
98        if let Some(tick) = &mut self.active_tick {
99            tick.staging.insert(
100                key.to_string(),
101                StagingValue::Present(value.to_string()),
102            );
103            return Ok(());
104        }
105        self.keys.insert(
106            key.to_string(),
107            KeyState::Present {
108                value: value.to_string(),
109                dirty: true,
110            },
111        );
112        Ok(())
113    }
114
115    pub fn get_key(&self, key: &str) -> Result<Option<String>> {
116        validate_key(key)?;
117        if let Some(tick) = &self.active_tick {
118            if let Some(staged) = tick.staging.get(key) {
119                return Ok(match staged {
120                    StagingValue::Present(v) => Some(v.clone()),
121                    StagingValue::Deleted => None,
122                });
123            }
124            return Ok(tick.read_snapshot.get(key).cloned());
125        }
126        Ok(match self.keys.get(key) {
127            Some(KeyState::Present { value, .. }) => Some(value.clone()),
128            Some(KeyState::Deleted { .. }) | None => None,
129        })
130    }
131
132    pub fn delete_key(&mut self, key: &str) -> Result<bool> {
133        validate_key(key)?;
134        if let Some(tick) = &self.active_tick {
135            if tick.locks.contains(key) {
136                return Err(Error::msg(format!("key locked during tick {}: {key}", tick.number)));
137            }
138        }
139        if let Some(tick) = &mut self.active_tick {
140            let existed = tick.staging.contains_key(key)
141                || tick.read_snapshot.contains_key(key);
142            if !existed {
143                return Ok(false);
144            }
145            tick.staging.insert(key.to_string(), StagingValue::Deleted);
146            return Ok(true);
147        }
148        let existed = matches!(self.keys.get(key), Some(KeyState::Present { .. }));
149        if !existed {
150            return Ok(false);
151        }
152        self.keys
153            .insert(key.to_string(), KeyState::Deleted { dirty: true });
154        Ok(true)
155    }
156
157    pub fn send(&mut self, recipient: &str, body: &str) -> Result<Uuid> {
158        self.send_from(crate::envelope::DEFAULT_SENDER, recipient, body)
159    }
160
161    pub fn send_from(&mut self, from: &str, recipient: &str, body: &str) -> Result<Uuid> {
162        validate_segment(from, "from")?;
163        validate_segment(recipient, "recipient")?;
164        let env = crate::envelope::Envelope::new(
165            from,
166            recipient,
167            crate::envelope::Envelope::parse_payload(body),
168        );
169        let id = env.id;
170        self.mailboxes
171            .entry(recipient.to_string())
172            .or_default()
173            .insert(
174                id,
175                MsgState {
176                    body: env.to_json()?,
177                    dirty: true,
178                },
179            );
180        Ok(id)
181    }
182
183    pub fn post_cron(&mut self, schedule: &str, body: &str) -> Result<Uuid> {
184        CronSchedule::parse(schedule)?;
185        let id = Uuid::new_v4();
186        self.cron
187            .entry(schedule.to_string())
188            .or_default()
189            .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 => {
229                self.poll_mailbox(&p2.to_string_lossy())
230            }
231            [p1, p2] if p1.to_string_lossy() == CRON => {
232                let schedule = p2.to_string_lossy();
233                Ok(self.collect_messages(
234                    MessageKind::Cron,
235                    &schedule,
236                    self.cron.get(schedule.as_ref()),
237                ))
238            }
239            _ => Ok(Vec::new()),
240        }
241    }
242
243    pub fn ack(&mut self, home: &UnifierHome, id_or_path: &str) -> Result<bool> {
244        if id_or_path.contains('/') || Path::new(id_or_path).is_absolute() {
245            let path = if Path::new(id_or_path).is_absolute() {
246                PathBuf::from(id_or_path)
247            } else {
248                resolve_under_root(home.path(), id_or_path)?
249            };
250            return self.ack_path(home, &path);
251        }
252        let id = Uuid::parse_str(id_or_path)
253            .map_err(|_| Error::msg(format!("invalid message id or path: {id_or_path}")))?;
254        self.ack_id(home, &id)
255    }
256
257    pub fn flush(&mut self, home: &UnifierHome) -> Result<()> {
258        if self.active_tick.is_some() {
259            return Err(Error::msg("cannot flush while a tick is active; run tick end first"));
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.iter().map(|p| p.to_string_lossy().into_owned()).collect();
358        if parts.len() != 3 {
359            return Ok(false);
360        }
361        let Some(id) = parse_message_id(&parts[2]) else {
362            return Ok(false);
363        };
364        let existed = match parts[0].as_str() {
365            MAILBOX => {
366                let had = self
367                    .mailboxes
368                    .get(&parts[1])
369                    .is_some_and(|m| m.contains_key(&id))
370                    || path.is_file();
371                if had {
372                    self.removed_messages
373                        .insert((MessageKind::Mailbox, parts[1].clone(), id));
374                    if let Some(msgs) = self.mailboxes.get_mut(&parts[1]) {
375                        msgs.remove(&id);
376                    }
377                }
378                had
379            }
380            CRON => {
381                let had = self
382                    .cron
383                    .get(&parts[1])
384                    .is_some_and(|m| m.contains_key(&id))
385                    || path.is_file();
386                if had {
387                    self.removed_messages
388                        .insert((MessageKind::Cron, parts[1].clone(), id));
389                    if let Some(msgs) = self.cron.get_mut(&parts[1]) {
390                        msgs.remove(&id);
391                    }
392                }
393                had
394            }
395            _ => false,
396        };
397        Ok(existed)
398    }
399
400    fn ack_id(&mut self, home: &UnifierHome, id: &Uuid) -> Result<bool> {
401        if let Some(recipient) = self
402            .mailboxes
403            .iter()
404            .find_map(|(name, msgs)| msgs.contains_key(id).then(|| name.clone()))
405        {
406            self.removed_messages
407                .insert((MessageKind::Mailbox, recipient.clone(), *id));
408            if let Some(m) = self.mailboxes.get_mut(&recipient) {
409                m.remove(id);
410            }
411            return Ok(true);
412        }
413        if let Some(schedule) = self
414            .cron
415            .iter()
416            .find_map(|(name, msgs)| msgs.contains_key(id).then(|| name.clone()))
417        {
418            self.removed_messages
419                .insert((MessageKind::Cron, schedule.clone(), *id));
420            if let Some(m) = self.cron.get_mut(&schedule) {
421                m.remove(id);
422            }
423            return Ok(true);
424        }
425        // Message may exist on disk but not loaded into a bucket (empty dir walk).
426        let filename = format!("{}.txt", id.hyphenated());
427        for sub in [MAILBOX, CRON] {
428            let base = home.path().join(sub);
429            if !base.is_dir() {
430                continue;
431            }
432            for entry in fs::read_dir(&base)? {
433                let entry = entry?;
434                if entry.file_type()?.is_dir() {
435                    let candidate = entry.path().join(&filename);
436                    if candidate.is_file() {
437                        let bucket = entry.file_name().to_string_lossy().into_owned();
438                        let kind = if sub == MAILBOX {
439                            MessageKind::Mailbox
440                        } else {
441                            MessageKind::Cron
442                        };
443                        self.removed_messages
444                            .insert((kind, bucket, *id));
445                        return Ok(true);
446                    }
447                }
448            }
449        }
450        Ok(false)
451    }
452
453    fn collect_messages(
454        &self,
455        kind: MessageKind,
456        bucket: &str,
457        msgs: Option<&BTreeMap<Uuid, MsgState>>,
458    ) -> Vec<Message> {
459        let Some(msgs) = msgs else {
460            return Vec::new();
461        };
462        let base = match kind {
463            MessageKind::Mailbox => PathBuf::from(MAILBOX).join(bucket),
464            MessageKind::Cron => PathBuf::from(CRON).join(bucket),
465        };
466        let mut out = Vec::new();
467        for (id, msg) in msgs {
468            out.push(Message {
469                id: *id,
470                path: base.join(format!("{}.txt", id.hyphenated())),
471                body: msg.body.clone(),
472            });
473        }
474        out.sort_by_key(|m| m.id);
475        out
476    }
477
478    fn has_dirty_messages(&self, buckets: &BTreeMap<String, BTreeMap<Uuid, MsgState>>) -> bool {
479        buckets
480            .values()
481            .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}