Skip to main content

blockworx/storage/
writer.rs

1//! The background writer: a thread that takes documents and puts them on disk.
2//!
3//! It exists because of latency, not throughput. Serializing a document costs
4//! well under a millisecond even at the scale-test extreme; the `fsync` behind a
5//! durable write costs about 11 ms, which is two thirds of a 60 fps frame (see
6//! `todo.md` P0). Autosaving on the UI thread would therefore hitch visibly on
7//! every settled edit, however small.
8//!
9//! Native only. The browser has no threads of this kind — the web writer is a
10//! worker the page talks to by message, which arrives with OPFS.
11
12use std::sync::mpsc::{Receiver, Sender, channel};
13
14use super::container::Container;
15use super::fs::FsStorage;
16use super::history;
17use crate::document::Document;
18
19/// One document to write, and the history entry to record it as.
20struct Job {
21    document: Document,
22    seq: u64,
23    meta: history::Entry,
24}
25
26enum Message {
27    Write(Box<Job>),
28    /// Report back once everything queued ahead of this has been written.
29    Drained(Sender<()>),
30}
31
32pub struct Writer {
33    tx: Sender<Message>,
34    thread: Option<std::thread::JoinHandle<()>>,
35}
36
37impl Writer {
38    /// Start a writer for the container rooted at `root`.
39    pub fn spawn(root: std::path::PathBuf) -> Self {
40        let (tx, rx) = channel();
41        let thread = std::thread::Builder::new()
42            .name("blockworx-writer".to_string())
43            .spawn(move || run(&Container::open(&root), &rx))
44            .ok();
45        Self { tx, thread }
46    }
47
48    /// Queue `document` to be written and recorded as entry `seq`. Returns
49    /// immediately; the write happens on the writer's thread.
50    pub fn write(&self, document: Document, seq: u64, meta: history::Entry) {
51        // A closed channel means the writer thread died, which it only does by
52        // panicking — already reported there.
53        let _ = self.tx.send(Message::Write(Box::new(Job {
54            document,
55            seq,
56            meta,
57        })));
58    }
59
60    /// Block until everything queued so far has been written.
61    ///
62    /// The point of the whole arrangement is that saving does not block, so this
63    /// is for the one moment where it must: the app is going away and whatever
64    /// is still queued would go with it.
65    pub fn drain(&self) {
66        let (tx, rx) = channel();
67        if self.tx.send(Message::Drained(tx)).is_ok() {
68            let _ = rx.recv();
69        }
70    }
71}
72
73impl Drop for Writer {
74    fn drop(&mut self) {
75        self.drain();
76        // Dropping the sender ends the loop; joining then guarantees the last
77        // write completed before the process is allowed to leave.
78        let (dead_tx, _) = channel();
79        let _ = std::mem::replace(&mut self.tx, dead_tx);
80        if let Some(thread) = self.thread.take() {
81            let _ = thread.join();
82        }
83    }
84}
85
86fn run(container: &Container<FsStorage>, rx: &Receiver<Message>) {
87    while let Ok(message) = rx.recv() {
88        match message {
89            Message::Write(job) => {
90                if let Err(e) = container.save_and_record(&job.document, job.seq, &job.meta) {
91                    tracing::error!("Autosave failed:\n{e:?}");
92                }
93            }
94            Message::Drained(reply) => {
95                let _ = reply.send(());
96            }
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::storage::Storage as _;
105    use crate::storage::atomic::tests::TempDir;
106    use crate::storage::container::ROOT;
107
108    fn meta(changed: &[&str]) -> history::Entry {
109        history::Entry {
110            ts: history::now_millis(),
111            command: None,
112            changed: changed.iter().map(|s| (*s).to_string()).collect(),
113        }
114    }
115
116    #[test]
117    fn a_queued_document_reaches_the_disk() {
118        let dir = TempDir::new("writer-writes");
119        let root = dir.join("d.bwx");
120        let writer = Writer::spawn(root.clone());
121
122        writer.write(Document::default(), 0, meta(&["b1"]));
123        writer.drain();
124
125        assert!(root.join(ROOT).is_file());
126        let container = Container::open(&root);
127        assert_eq!(history::records(container.storage()).unwrap().len(), 1);
128    }
129
130    /// Entries queued in a burst all land, in order — the writer is behind the
131    /// UI thread, not instead of it.
132    #[test]
133    fn a_burst_of_writes_all_land_in_order() {
134        let dir = TempDir::new("writer-burst");
135        let root = dir.join("d.bwx");
136        let writer = Writer::spawn(root.clone());
137
138        for seq in 0..8 {
139            writer.write(Document::default(), seq, meta(&[&format!("b{seq}")]));
140        }
141        writer.drain();
142
143        let container = Container::open(&root);
144        let records = history::records(container.storage()).unwrap();
145        let seqs: Vec<u64> = records.iter().map(|r| r.seq).collect();
146        assert_eq!(seqs, (0..8).collect::<Vec<_>>());
147        for (seq, record) in records.iter().enumerate() {
148            let meta = record.meta.as_ref().expect("a sidecar");
149            assert_eq!(meta.changed, vec![format!("b{seq}")]);
150        }
151    }
152
153    /// What `drain` is for: dropping the writer must not abandon queued work.
154    #[test]
155    fn dropping_the_writer_finishes_what_was_queued() {
156        let dir = TempDir::new("writer-drop");
157        let root = dir.join("d.bwx");
158        {
159            let writer = Writer::spawn(root.clone());
160            for seq in 0..4 {
161                writer.write(Document::default(), seq, meta(&[]));
162            }
163            // No explicit drain: the drop has to do it.
164        }
165        let container = Container::open(&root);
166        assert_eq!(history::records(container.storage()).unwrap().len(), 4);
167        assert!(container.storage().exists(ROOT));
168    }
169}