Skip to main content

ant_core/node/daemon/forward/
offsets.rs

1//! Persisted tail positions, so a daemon restart resumes where it left off.
2//!
3//! Without this the forwarder would have to choose between re-reading whole files on every start
4//! (duplicating everything) and starting at the end (losing everything written while the daemon was
5//! down). The acceptance criterion for V2-1021 is explicitly neither, so positions are written to
6//! disk and reloaded.
7//!
8//! Positions are keyed by absolute log file path. ant-node rotates daily by *filename*
9//! (`ant-node.2026-08-19.log`), so a new day is a new key rather than a moved cursor, and the
10//! retention limit eventually deletes old ones — hence [`OffsetStore::prune`].
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::config;
18use crate::error::Result;
19
20/// Filename of the persisted offsets within [`config::data_dir`].
21const OFFSETS_FILENAME: &str = "log_forward_offsets.json";
22
23/// Bytes of a log file that have been read and emitted.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25pub struct FileOffset {
26    /// Byte position immediately after the last event handed to the sink.
27    pub offset: u64,
28}
29
30/// Tail positions for every log file being followed.
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct OffsetStore {
33    #[serde(default)]
34    offsets: HashMap<String, FileOffset>,
35
36    #[serde(skip)]
37    path: PathBuf,
38
39    /// Set when an offset has changed since the last successful save, so an idle forwarder does not
40    /// rewrite an identical file every poll.
41    #[serde(skip)]
42    dirty: bool,
43}
44
45impl OffsetStore {
46    /// Path of the persisted offsets for this machine.
47    pub fn default_path() -> Result<PathBuf> {
48        Ok(config::data_dir()?.join(OFFSETS_FILENAME))
49    }
50
51    /// Load offsets from disk, starting empty when the file is absent.
52    ///
53    /// A corrupt file starts empty rather than failing: unlike the opt-in config, losing positions
54    /// degrades to "resume from the current end of file", which is a recoverable inconvenience
55    /// rather than a silent misrepresentation of what the user consented to.
56    pub fn load(path: &Path) -> Self {
57        let mut store = std::fs::read_to_string(path)
58            .ok()
59            .and_then(|contents| serde_json::from_str::<Self>(&contents).ok())
60            .unwrap_or_default();
61        store.path = path.to_path_buf();
62        store
63    }
64
65    /// Position for a file, or `None` if it has never been read.
66    #[must_use]
67    pub fn get(&self, key: &str) -> Option<u64> {
68        self.offsets.get(key).map(|entry| entry.offset)
69    }
70
71    /// Record a new position.
72    pub fn set(&mut self, key: &str, offset: u64) {
73        let entry = self.offsets.entry(key.to_string()).or_default();
74        if entry.offset != offset {
75            entry.offset = offset;
76            self.dirty = true;
77        }
78    }
79
80    /// Forget every file not in `live`, so retention-deleted dailies do not accumulate forever.
81    pub fn prune(&mut self, live: &[String]) {
82        let before = self.offsets.len();
83        self.offsets.retain(|key, _| live.iter().any(|k| k == key));
84        if self.offsets.len() != before {
85            self.dirty = true;
86        }
87    }
88
89    /// Paths of every file with a recorded position.
90    ///
91    /// Used to tell a node being *resumed* — one whose files already have positions — from one
92    /// being adopted for the first time, which must join its log at the end rather than upload the
93    /// retained history.
94    pub fn keys(&self) -> impl Iterator<Item = &str> {
95        self.offsets.keys().map(String::as_str)
96    }
97
98    /// Whether anything has changed since the last successful [`Self::save`].
99    #[must_use]
100    pub fn is_dirty(&self) -> bool {
101        self.dirty
102    }
103
104    #[must_use]
105    pub fn len(&self) -> usize {
106        self.offsets.len()
107    }
108
109    #[must_use]
110    pub fn is_empty(&self) -> bool {
111        self.offsets.is_empty()
112    }
113
114    /// Write offsets to disk atomically, if anything changed.
115    ///
116    /// The temporary-file-then-rename dance matters here: a half-written offsets file that parsed
117    /// as valid JSON with a truncated position would replay a chunk of log on the next start.
118    pub fn save(&mut self) -> Result<()> {
119        if !self.dirty {
120            return Ok(());
121        }
122        if let Some(parent) = self.path.parent() {
123            std::fs::create_dir_all(parent)?;
124        }
125        let contents = serde_json::to_string_pretty(self)?;
126        let tmp_path = self.path.with_extension("tmp");
127        std::fs::write(&tmp_path, &contents)?;
128        std::fs::rename(&tmp_path, &self.path)?;
129        self.dirty = false;
130        Ok(())
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn store_at(dir: &Path) -> OffsetStore {
139        OffsetStore::load(&dir.join(OFFSETS_FILENAME))
140    }
141
142    #[test]
143    fn absent_file_loads_empty() {
144        let tmp = tempfile::tempdir().unwrap();
145        let store = store_at(tmp.path());
146        assert!(store.is_empty());
147        assert_eq!(store.get("anything"), None);
148    }
149
150    #[test]
151    fn positions_survive_a_save_and_reload() {
152        let tmp = tempfile::tempdir().unwrap();
153        let mut store = store_at(tmp.path());
154        store.set("/logs/ant-node.2026-08-19.log", 4096);
155        store.save().unwrap();
156
157        let reloaded = store_at(tmp.path());
158        assert_eq!(reloaded.get("/logs/ant-node.2026-08-19.log"), Some(4096));
159    }
160
161    #[test]
162    fn a_corrupt_offsets_file_starts_empty_rather_than_failing() {
163        let tmp = tempfile::tempdir().unwrap();
164        std::fs::write(tmp.path().join(OFFSETS_FILENAME), "{{{ truncated").unwrap();
165        assert!(store_at(tmp.path()).is_empty());
166    }
167
168    #[test]
169    fn saving_is_skipped_while_nothing_has_changed() {
170        let tmp = tempfile::tempdir().unwrap();
171        let mut store = store_at(tmp.path());
172        assert!(!store.is_dirty());
173
174        store.set("a", 10);
175        assert!(store.is_dirty());
176        store.save().unwrap();
177        assert!(!store.is_dirty());
178
179        // Setting the same value again is not a change.
180        store.set("a", 10);
181        assert!(!store.is_dirty());
182
183        store.set("a", 11);
184        assert!(store.is_dirty());
185    }
186
187    #[test]
188    fn pruning_forgets_files_that_retention_deleted() {
189        let tmp = tempfile::tempdir().unwrap();
190        let mut store = store_at(tmp.path());
191        store.set("old.log", 1);
192        store.set("current.log", 2);
193        store.save().unwrap();
194
195        store.prune(&["current.log".to_string()]);
196
197        assert_eq!(store.get("old.log"), None);
198        assert_eq!(store.get("current.log"), Some(2));
199        assert!(store.is_dirty(), "pruning is a change worth persisting");
200    }
201
202    #[test]
203    fn keys_lists_every_tracked_file() {
204        let tmp = tempfile::tempdir().unwrap();
205        let mut store = store_at(tmp.path());
206        store.set("/logs/node-1/ant-node.2026-08-19.log", 1);
207        store.set("/logs/node-2/ant-node.2026-08-19.log", 2);
208
209        let mut keys: Vec<&str> = store.keys().collect();
210        keys.sort_unstable();
211        assert_eq!(
212            keys,
213            vec![
214                "/logs/node-1/ant-node.2026-08-19.log",
215                "/logs/node-2/ant-node.2026-08-19.log"
216            ]
217        );
218        assert!(store.keys().any(|key| key.starts_with("/logs/node-1")));
219    }
220
221    #[test]
222    fn pruning_nothing_is_not_a_change() {
223        let tmp = tempfile::tempdir().unwrap();
224        let mut store = store_at(tmp.path());
225        store.set("current.log", 2);
226        store.save().unwrap();
227
228        store.prune(&["current.log".to_string()]);
229        assert!(!store.is_dirty());
230    }
231}