Skip to main content

lean_ctx_tasks/
store.rs

1// SPDX-License-Identifier: Apache-2.0
2//! One project's tasks on disk: `tasks.json` (the board, written
3//! crash-atomically, 0600) and `journal.jsonl` (every transition, appended
4//! before the board is written, bounded). A write holds an in-process lock
5//! and an exclusive file lock (`tasks.lock`, bounded wait) across
6//! load → change → journal → write, so concurrent writers of any process
7//! queue instead of losing each other's transitions. `notices.json` keeps
8//! the journal sequence the bus has taken every notice through.
9
10use std::collections::HashMap;
11use std::fs::{File, OpenOptions};
12use std::io::Write as _;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex, OnceLock, PoisonError};
15use std::time::Duration;
16
17use chrono::{DateTime, Utc};
18
19use crate::board::{BOARD_SCHEMA, Board, Transition};
20use crate::{Change, TaskError};
21
22const BOARD_FILE: &str = "tasks.json";
23const JOURNAL_FILE: &str = "journal.jsonl";
24const LOCK_FILE: &str = "tasks.lock";
25/// The journal sequence the bus has taken every notice through.
26const NOTICES_FILE: &str = "notices.json";
27/// How long a write waits for another writer.
28const LOCK_WAIT: Duration = Duration::from_secs(10);
29/// The journal is trimmed to its newest [`JOURNAL_KEEP`] lines past this.
30const MAX_JOURNAL_BYTES: u64 = 1024 * 1024;
31const JOURNAL_KEEP: usize = 2000;
32
33/// Notices taken from the store under its locks, posted after them.
34struct Due {
35    /// The journal sequence the cursor moves to once they are posted.
36    through: u64,
37    changes: Vec<Change>,
38}
39
40/// The task store of one project directory.
41#[derive(Debug, Clone)]
42pub struct Store {
43    dir: PathBuf,
44}
45
46fn store_error(what: &str, error: impl std::fmt::Display) -> TaskError {
47    TaskError::Store(format!("local tasks: {what}: {error}"))
48}
49
50/// One in-process lock per store directory (file locks alone would make
51/// threads of one process spin on each other).
52fn process_lock(dir: &Path) -> Arc<Mutex<()>> {
53    static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
54    let mut map = LOCKS
55        .get_or_init(|| Mutex::new(HashMap::new()))
56        .lock()
57        .unwrap_or_else(PoisonError::into_inner);
58    map.entry(dir.to_path_buf()).or_default().clone()
59}
60
61#[cfg(unix)]
62fn private() -> Option<std::fs::Permissions> {
63    use std::os::unix::fs::PermissionsExt as _;
64    Some(std::fs::Permissions::from_mode(0o600))
65}
66
67#[cfg(not(unix))]
68fn private() -> Option<std::fs::Permissions> {
69    None
70}
71
72impl Store {
73    /// The store in `dir` (created on the first write).
74    pub fn new(dir: impl Into<PathBuf>) -> Self {
75        Self { dir: dir.into() }
76    }
77
78    pub fn dir(&self) -> &Path {
79        &self.dir
80    }
81
82    /// The board as written (an empty one before the first write).
83    pub fn load(&self) -> Result<Board, TaskError> {
84        let path = self.dir.join(BOARD_FILE);
85        let text = match std::fs::read_to_string(&path) {
86            Ok(text) => text,
87            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
88                return Ok(Board::default());
89            }
90            Err(error) => return Err(store_error("read", error)),
91        };
92        let board: Board = serde_json::from_str(&text).map_err(|e| store_error("parse", e))?;
93        if board.schema != BOARD_SCHEMA {
94            return Err(store_error("schema", &board.schema));
95        }
96        Ok(board)
97    }
98
99    /// The board as of `now`: claims that ran out are given back first
100    /// (a write), and their changes returned for the caller's notices.
101    pub fn read(&self, now: DateTime<Utc>) -> Result<(Board, Vec<Change>), TaskError> {
102        let board = self.load()?;
103        if !board.has_expired(now) {
104            return Ok((board, Vec::new()));
105        }
106        let mut current = None;
107        let ((), expired) = self.write(now, |board| {
108            current = Some(board.clone());
109            Ok(())
110        })?;
111        Ok((current.unwrap_or_default(), expired))
112    }
113
114    /// Change the board under both locks. Claims that ran out by `now` are
115    /// given back before `change` runs. Returns what `change` returned and
116    /// every change recorded in this write (expiries first), for notices.
117    pub fn write<T>(
118        &self,
119        now: DateTime<Utc>,
120        change: impl FnOnce(&mut Board) -> Result<T, TaskError>,
121    ) -> Result<(T, Vec<Change>), TaskError> {
122        self.locked(|| {
123            let (out, changes, _) = self.write_locked(now, change)?;
124            Ok((out, changes))
125        })
126    }
127
128    /// [`Store::read`], then announce every transition the bus has not
129    /// taken yet (see [`Store::write_announced`]).
130    pub fn read_announced(
131        &self,
132        now: DateTime<Utc>,
133        post: impl FnOnce(&[Change]) -> bool,
134    ) -> Result<(Board, Vec<Change>), TaskError> {
135        let board = self.load()?;
136        if board.has_expired(now) {
137            let mut current = None;
138            let ((), expired) = self.write_announced(
139                now,
140                |board| {
141                    current = Some(board.clone());
142                    Ok(())
143                },
144                post,
145            )?;
146            return Ok((current.unwrap_or_default(), expired));
147        }
148        if self
149            .notice_cursor()
150            .is_some_and(|posted| posted >= board.seq)
151        {
152            return Ok((board, Vec::new()));
153        }
154        let (board, due) = self.locked(|| {
155            let board = self.load()?;
156            self.ensure_cursor(board.seq)?;
157            let due = self.due_locked(board.seq, &[]);
158            Ok((board, due))
159        })?;
160        self.post_due(due, post);
161        Ok((board, Vec::new()))
162    }
163
164    /// [`Store::write`], then `post` the transitions the bus has not taken
165    /// yet: this write's and any an earlier failed post left behind (from
166    /// the journal). `post` runs after both locks are released, so a slow
167    /// or contended bus never holds up the next task writer. It returns
168    /// whether the bus took the notices; only then does the cursor
169    /// (`notices.json`) move past them, so a failed bus write is retried on
170    /// the next access instead of being lost (Hive task #39). Notices are
171    /// keyed by their journal sequence, so a re-post is idempotent.
172    pub fn write_announced<T>(
173        &self,
174        now: DateTime<Utc>,
175        change: impl FnOnce(&mut Board) -> Result<T, TaskError>,
176        post: impl FnOnce(&[Change]) -> bool,
177    ) -> Result<(T, Vec<Change>), TaskError> {
178        let (out, changes, due) = self.locked(|| {
179            // The cursor exists before the transition is journaled: if it
180            // cannot be saved the write is refused, never left unannounced.
181            self.ensure_cursor(self.load()?.seq)?;
182            let (out, changes, seq) = self.write_locked(now, change)?;
183            let due = self.due_locked(seq, &changes);
184            Ok((out, changes, due))
185        })?;
186        self.post_due(due, post);
187        Ok((out, changes))
188    }
189
190    /// Without a cursor (a new store, or one from before the cursor) every
191    /// notice through `seq` counts as posted: saved now, before anything
192    /// newer is journaled, so a later failed post always finds it.
193    fn ensure_cursor(&self, seq: u64) -> Result<(), TaskError> {
194        if self.notice_cursor().is_some() {
195            return Ok(());
196        }
197        self.save_cursor(seq)
198    }
199
200    /// The transitions between the cursor and `seq` (under both locks).
201    /// `fresh` (this write's changes) stands in for the journal when it
202    /// covers the whole span.
203    fn due_locked(&self, seq: u64, fresh: &[Change]) -> Option<Due> {
204        let from = self.notice_cursor()?;
205        if from >= seq {
206            return None;
207        }
208        let changes = if fresh.first().is_some_and(|c| c.seq == from + 1) {
209            fresh.to_vec()
210        } else {
211            self.journal()
212                .iter()
213                .filter(|t| t.seq > from && t.seq <= seq)
214                .map(Change::from)
215                .collect()
216        };
217        Some(Due {
218            through: seq,
219            changes,
220        })
221    }
222
223    /// Post `due` (no lock held), then move the cursor past it if the bus
224    /// took it. The cursor only moves forward: a writer that posted less
225    /// than another never moves it back. A cursor that cannot be saved
226    /// leaves the notices due; the next access re-posts them (idempotent).
227    fn post_due(&self, due: Option<Due>, post: impl FnOnce(&[Change]) -> bool) {
228        let Some(due) = due else { return };
229        if !due.changes.is_empty() && !post(&due.changes) {
230            return;
231        }
232        let _ = self.locked(|| {
233            if self
234                .notice_cursor()
235                .is_some_and(|posted| posted >= due.through)
236            {
237                return Ok(());
238            }
239            self.save_cursor(due.through)
240        });
241    }
242
243    fn save_cursor(&self, through: u64) -> Result<(), TaskError> {
244        let bytes = format!("{{\"posted\":{through}}}\n");
245        lean_ctx_fs::atomic_fs::try_atomic_write(
246            &self.dir.join(NOTICES_FILE),
247            bytes.as_bytes(),
248            private().as_ref(),
249        )
250        .map_err(|e| store_error("notices", e))
251    }
252
253    /// The journal sequence the bus has taken every notice through.
254    fn notice_cursor(&self) -> Option<u64> {
255        let text = std::fs::read_to_string(self.dir.join(NOTICES_FILE)).ok()?;
256        let value: serde_json::Value = serde_json::from_str(&text).ok()?;
257        value.get("posted")?.as_u64()
258    }
259
260    /// Run `f` under the in-process lock and `tasks.lock` (bounded wait).
261    fn locked<R>(&self, f: impl FnOnce() -> Result<R, TaskError>) -> Result<R, TaskError> {
262        let lock = process_lock(&self.dir);
263        let _guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
264        std::fs::create_dir_all(&self.dir).map_err(|e| store_error("create", e))?;
265        let lock_file = File::options()
266            .create(true)
267            .truncate(false)
268            .write(true)
269            .open(self.dir.join(LOCK_FILE))
270            .map_err(|e| store_error("lock", e))?;
271        lean_ctx_fs::file_lock::acquire_exclusive_timeout(&lock_file, LOCK_WAIT)
272            .map_err(|e| store_error("lock", e))?;
273        let result = f();
274        let _ = fs2::FileExt::unlock(&lock_file);
275        result
276    }
277
278    /// One write under both locks; also returns the board's journal
279    /// sequence after it.
280    fn write_locked<T>(
281        &self,
282        now: DateTime<Utc>,
283        change: impl FnOnce(&mut Board) -> Result<T, TaskError>,
284    ) -> Result<(T, Vec<Change>, u64), TaskError> {
285        let mut board = self.load()?;
286        board.expire(now);
287        let out = change(&mut board)?;
288        let pending = std::mem::take(&mut board.pending);
289        let changes = std::mem::take(&mut board.changes);
290        // A renewal changes the board without a transition: always written.
291        self.append_journal(&pending)?;
292        let bytes = serde_json::to_vec(&board).map_err(|e| store_error("encode", e))?;
293        lean_ctx_fs::atomic_fs::try_atomic_write(
294            &self.dir.join(BOARD_FILE),
295            &bytes,
296            private().as_ref(),
297        )
298        .map_err(|e| store_error("write", e))?;
299        Ok((out, changes, board.seq))
300    }
301
302    fn append_journal(&self, transitions: &[Transition]) -> Result<(), TaskError> {
303        if transitions.is_empty() {
304            return Ok(());
305        }
306        let path = self.dir.join(JOURNAL_FILE);
307        let mut lines = String::new();
308        for transition in transitions {
309            let line = serde_json::to_string(transition).map_err(|e| store_error("encode", e))?;
310            lines.push_str(&line);
311            lines.push('\n');
312        }
313        let mut options = OpenOptions::new();
314        options.create(true).append(true);
315        #[cfg(unix)]
316        {
317            use std::os::unix::fs::OpenOptionsExt as _;
318            options.mode(0o600);
319        }
320        let mut file = options.open(&path).map_err(|e| store_error("journal", e))?;
321        file.write_all(lines.as_bytes())
322            .map_err(|e| store_error("journal", e))?;
323        drop(file);
324        let size = std::fs::metadata(&path).map_or(0, |m| m.len());
325        if size > MAX_JOURNAL_BYTES {
326            let text = std::fs::read_to_string(&path).map_err(|e| store_error("journal", e))?;
327            let all: Vec<&str> = text.lines().collect();
328            let kept = all[all.len().saturating_sub(JOURNAL_KEEP)..].join("\n") + "\n";
329            lean_ctx_fs::atomic_fs::try_atomic_write(&path, kept.as_bytes(), private().as_ref())
330                .map_err(|e| store_error("journal", e))?;
331        }
332        Ok(())
333    }
334
335    /// The journal, oldest first (a line that does not parse is skipped).
336    pub fn journal(&self) -> Vec<Transition> {
337        std::fs::read_to_string(self.dir.join(JOURNAL_FILE))
338            .unwrap_or_default()
339            .lines()
340            .filter_map(|line| serde_json::from_str(line).ok())
341            .collect()
342    }
343}