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