Skip to main content

pushkin_daemon/
warm.rs

1//! Warm per-file state + watcher (spec §8.1): verdicts memoized by
2//! (path, content-hash), invalidated by keyed path events, cleared
3//! wholesale on manifest change. The memo NEVER re-decides — a hit
4//! returns the stored envelope, a miss runs the same `check_write` the
5//! cold path runs, so warm and cold are identical by construction.
6
7use notify::Watcher as _;
8use pushkin_core::envelope::CheckResult;
9use pushkin_core::manifest::Manifest;
10use pushkin_core::pipeline::{check_write, WriteRequest};
11use std::collections::HashMap;
12use std::path::Path;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex, RwLock};
15
16/// FNV-1a over the write content — the project's stable-hash convention
17/// (spec §7.4 chose it for nudge arms because std hashers are not
18/// contractually stable; the memo key reuses that decision).
19fn fnv1a(bytes: &[u8]) -> u64 {
20    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
21    for byte in bytes {
22        hash ^= u64::from(*byte);
23        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
24    }
25    hash
26}
27
28type Memo = HashMap<String, (u64, CheckResult)>;
29
30struct Inner {
31    manifest: RwLock<Manifest>,
32    memo: Mutex<Memo>,
33    hits: AtomicU64,
34}
35
36/// Memoized check state shared between the accept loop and the watcher.
37pub struct WarmState {
38    inner: Arc<Inner>,
39}
40
41/// Keeps the filesystem watcher alive; dropping it stops watching.
42pub struct WatchGuard {
43    _watcher: notify::RecommendedWatcher,
44}
45
46impl WarmState {
47    #[must_use]
48    pub fn new(manifest: Manifest) -> Self {
49        Self {
50            inner: Arc::new(Inner {
51                manifest: RwLock::new(manifest),
52                memo: Mutex::new(HashMap::new()),
53                hits: AtomicU64::new(0),
54            }),
55        }
56    }
57
58    /// A handle to the same underlying state (memo, manifest, hit
59    /// counter are shared, not copied) — one per connection task.
60    #[must_use]
61    pub fn share(&self) -> Self {
62        Self {
63            inner: Arc::clone(&self.inner),
64        }
65    }
66
67    /// Warm-or-compute a verdict. A hit returns the stored envelope for
68    /// the same (path, content); a miss computes with the SAME
69    /// `check_write` the cold path uses and stores the result.
70    #[must_use]
71    pub fn check(&self, request: &WriteRequest) -> CheckResult {
72        let content_hash = fnv1a(request.content.as_bytes());
73        if let Ok(memo) = self.inner.memo.lock() {
74            if let Some((stored_hash, stored)) = memo.get(&request.file_path) {
75                if *stored_hash == content_hash {
76                    self.inner.hits.fetch_add(1, Ordering::SeqCst);
77                    return stored.clone();
78                }
79            }
80        }
81        let result = match self.inner.manifest.read() {
82            Ok(manifest) => check_write(&manifest, request),
83            Err(poisoned) => check_write(&poisoned.into_inner(), request),
84        };
85        if let Ok(mut memo) = self.inner.memo.lock() {
86            memo.insert(request.file_path.clone(), (content_hash, result.clone()));
87        }
88        result
89    }
90
91    /// Memo hits since construction.
92    #[must_use]
93    pub fn hits(&self) -> u64 {
94        self.inner.hits.load(Ordering::SeqCst)
95    }
96
97    /// Replace the manifest and drop every memoized verdict (mappings
98    /// shape every verdict; nothing memoized survives a manifest change).
99    pub fn swap_manifest(&self, manifest: Manifest) {
100        match self.inner.manifest.write() {
101            Ok(mut slot) => *slot = manifest,
102            Err(poisoned) => *poisoned.into_inner() = manifest,
103        }
104        if let Ok(mut memo) = self.inner.memo.lock() {
105            memo.clear();
106        }
107    }
108
109    /// Drop memoized verdicts for one path; untouched paths stay warm.
110    pub fn invalidate_path(&self, path: &str) {
111        if let Ok(mut memo) = self.inner.memo.lock() {
112            memo.remove(path);
113        }
114    }
115
116    /// Watch `root` recursively: a `pushkin.toml` change reloads the
117    /// manifest wholesale (parse failures keep the old manifest — a
118    /// half-saved edit must not tear down the gate); any other file
119    /// change invalidates that file's memo entry, keyed by its
120    /// root-relative path.
121    ///
122    /// # Errors
123    /// `std::io::Error` when the watcher cannot be created or attached.
124    pub fn watch(&self, root: &Path) -> std::io::Result<WatchGuard> {
125        let inner = Arc::clone(&self.inner);
126        let root_buf = root.to_path_buf();
127        let mut watcher =
128            notify::recommended_watcher(move |event: Result<notify::Event, notify::Error>| {
129                let Ok(event) = event else { return };
130                for path in event.paths {
131                    handle_change(&inner, &root_buf, &path);
132                }
133            })
134            .map_err(std::io::Error::other)?;
135        watcher
136            .watch(root, notify::RecursiveMode::Recursive)
137            .map_err(std::io::Error::other)?;
138        Ok(WatchGuard { _watcher: watcher })
139    }
140}
141
142fn handle_change(inner: &Inner, root: &Path, changed: &Path) {
143    let is_manifest = changed
144        .file_name()
145        .is_some_and(|name| name == "pushkin.toml");
146    if is_manifest {
147        // Reload wholesale; a manifest that doesn't parse right now keeps
148        // the previous one live (never tear down the gate on a half-save).
149        let Ok(text) = std::fs::read_to_string(changed) else {
150            return;
151        };
152        let Ok(manifest) = Manifest::parse(&text) else {
153            return;
154        };
155        match inner.manifest.write() {
156            Ok(mut slot) => *slot = manifest,
157            Err(poisoned) => *poisoned.into_inner() = manifest,
158        }
159        if let Ok(mut memo) = inner.memo.lock() {
160            memo.clear();
161        }
162        return;
163    }
164    let key = changed
165        .strip_prefix(root)
166        .unwrap_or(changed)
167        .to_string_lossy()
168        .into_owned();
169    if let Ok(mut memo) = inner.memo.lock() {
170        memo.remove(&key);
171    }
172}