Skip to main content

log_io/sink/
file.rs

1//! Buffered file sink.
2
3use std::fs::{File, OpenOptions};
4use std::io::{BufWriter, Write};
5use std::path::Path;
6use std::sync::Mutex;
7
8use super::{CachePadded, Sink, lock_recover, write_locked};
9use crate::error::{Error, Result};
10use crate::format::Format;
11use crate::record::Record;
12
13/// Default size of the write buffer in front of the file.
14const DEFAULT_BUFFER: usize = 8 * 1024;
15
16/// Writes records to a file through a write buffer.
17///
18/// # Durability
19///
20/// Records are buffered in memory (8 KiB by default) and reach the
21/// operating system when the buffer fills or the sink is flushed. A
22/// flush happens:
23///
24/// * after every record at or above the logger's flush level (by
25///   default `Level::Error`, see `LoggerBuilder::flush_on`);
26/// * on [`crate::Logger::flush`] and [`crate::Logger::shutdown`];
27/// * when a [`crate::FlushGuard`] is dropped;
28/// * when the last clone of the [`crate::Logger`] is dropped.
29///
30/// Anything still buffered is **lost** if the process ends without
31/// one of those happening: `std::process::abort`, `panic = "abort"`
32/// without [`crate::global::install_panic_hook`], a fatal signal,
33/// `std::process::exit` (which skips destructors), or simply returning
34/// from `main` while the logger lives in a `static` (statics are never
35/// dropped). Keep the [`crate::FlushGuard`] returned by
36/// [`crate::init`] alive in `main`, call [`crate::Logger::shutdown`]
37/// before `process::exit`, or build the sink with
38/// [`FileSinkBuilder::buffer_capacity`]`(0)` to write every record
39/// through immediately.
40///
41/// Flushing hands data to the OS, which survives a crash of this
42/// process but not a power loss or kernel crash. Enable
43/// [`FileSinkBuilder::sync_on_flush`] to also `fsync` (`sync_data`)
44/// on every flush.
45///
46/// # Examples
47///
48#[cfg_attr(all(feature = "json"), doc = "```no_run")]
49#[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
50/// use log_io::format::JsonFormat;
51/// use log_io::sink::FileSink;
52/// use log_io::{Level, Logger};
53///
54/// let sink = FileSink::builder("app.jsonl", JsonFormat::new())
55///     .append(true)
56///     .sync_on_flush(true)
57///     .open()?;
58/// let logger = Logger::builder().level(Level::Info).with_sink(sink).build();
59/// log_io::error!(logger, "disk almost full"); // flushed and synced before returning
60/// # Ok::<(), log_io::Error>(())
61/// ```
62pub struct FileSink<F: Format> {
63    format: F,
64    target: CachePadded<Mutex<BufWriter<File>>>,
65    sync_on_flush: bool,
66}
67
68impl<F: Format> FileSink<F> {
69    /// Open `path` for appending (creating it if needed) with default
70    /// options.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`Error::Io`] if the file cannot be opened or created.
75    pub fn append<P: AsRef<Path>>(path: P, format: F) -> Result<Self> {
76        Self::builder(path, format).append(true).open()
77    }
78
79    /// Open `path` for writing, truncating any existing content.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`Error::Io`] if the file cannot be opened or created.
84    pub fn create<P: AsRef<Path>>(path: P, format: F) -> Result<Self> {
85        Self::builder(path, format).append(false).open()
86    }
87
88    /// Start configuring a file sink. Defaults: append, 8 KiB buffer,
89    /// no `fsync`.
90    pub fn builder<P: AsRef<Path>>(path: P, format: F) -> FileSinkBuilder<F> {
91        FileSinkBuilder {
92            path: path.as_ref().to_path_buf(),
93            format,
94            append: true,
95            buffer_capacity: DEFAULT_BUFFER,
96            sync_on_flush: false,
97        }
98    }
99}
100
101impl<F: Format> Sink for FileSink<F> {
102    fn write_record(&self, record: &Record<'_>) -> Result<()> {
103        write_locked(&self.target, &self.format, record)
104    }
105
106    fn flush(&self) -> Result<()> {
107        let mut file = lock_recover(&self.target);
108        file.flush()?;
109        if self.sync_on_flush {
110            file.get_ref().sync_data().map_err(Error::from)?;
111        }
112        Ok(())
113    }
114}
115
116impl<F: Format + core::fmt::Debug> core::fmt::Debug for FileSink<F> {
117    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
118        f.debug_struct("FileSink")
119            .field("format", &self.format)
120            .field("sync_on_flush", &self.sync_on_flush)
121            .finish_non_exhaustive()
122    }
123}
124
125/// Options for a [`FileSink`]. Create with [`FileSink::builder`].
126#[derive(Debug)]
127#[must_use = "call `open()` to create the sink"]
128pub struct FileSinkBuilder<F> {
129    path: std::path::PathBuf,
130    format: F,
131    append: bool,
132    buffer_capacity: usize,
133    sync_on_flush: bool,
134}
135
136impl<F: Format> FileSinkBuilder<F> {
137    /// Append to an existing file (`true`, the default) or truncate it
138    /// (`false`). The file is created if it does not exist.
139    pub const fn append(mut self, append: bool) -> Self {
140        self.append = append;
141        self
142    }
143
144    /// Size of the in-memory write buffer in bytes. `0` disables
145    /// buffering: every record is written to the OS before
146    /// `write_record` returns, at the cost of one system call per
147    /// record.
148    pub const fn buffer_capacity(mut self, bytes: usize) -> Self {
149        self.buffer_capacity = bytes;
150        self
151    }
152
153    /// Also call `File::sync_data` on every flush, so flushed records
154    /// survive an OS crash or power loss. This makes each flush much
155    /// slower (one disk sync).
156    pub const fn sync_on_flush(mut self, enabled: bool) -> Self {
157        self.sync_on_flush = enabled;
158        self
159    }
160
161    /// Open the file and build the sink.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`Error::Io`] if the file cannot be opened or created.
166    pub fn open(self) -> Result<FileSink<F>> {
167        let file = OpenOptions::new()
168            .create(true)
169            .write(true)
170            .append(self.append)
171            .truncate(!self.append)
172            .open(&self.path)?;
173        Ok(FileSink {
174            format: self.format,
175            target: CachePadded(Mutex::new(BufWriter::with_capacity(
176                self.buffer_capacity,
177                file,
178            ))),
179            sync_on_flush: self.sync_on_flush,
180        })
181    }
182}
183
184#[cfg(all(test, feature = "json"))]
185mod tests {
186    use super::*;
187    use crate::format::JsonFormat;
188    use crate::{Level, Metadata};
189
190    fn temp_path(name: &str) -> std::path::PathBuf {
191        let dir = std::env::temp_dir().join(format!("log-io-file-{}-{name}", std::process::id()));
192        let _ = std::fs::remove_file(&dir);
193        dir
194    }
195
196    fn record(msg: &str) -> Record<'_> {
197        Record::new(Metadata::new(Level::Info, "t"), msg, &[])
198    }
199
200    #[test]
201    fn test_file_sink_append_preserves_existing_content() {
202        let path = temp_path("append");
203        std::fs::write(&path, "existing\n").unwrap();
204        let sink = FileSink::append(&path, JsonFormat::new()).unwrap();
205        sink.write_record(&record("one")).unwrap();
206        sink.flush().unwrap();
207        let text = std::fs::read_to_string(&path).unwrap();
208        assert!(text.starts_with("existing\n"), "{text}");
209        assert!(text.contains("\"message\":\"one\""), "{text}");
210        let _ = std::fs::remove_file(&path);
211    }
212
213    #[test]
214    fn test_file_sink_create_truncates() {
215        let path = temp_path("create");
216        std::fs::write(&path, "old content\n").unwrap();
217        let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
218        sink.write_record(&record("fresh")).unwrap();
219        sink.shutdown().unwrap();
220        let text = std::fs::read_to_string(&path).unwrap();
221        assert!(!text.contains("old content"), "{text}");
222        assert!(text.contains("fresh"), "{text}");
223        let _ = std::fs::remove_file(&path);
224    }
225
226    #[test]
227    fn test_file_sink_buffers_until_flush() {
228        let path = temp_path("buffered");
229        let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
230        sink.write_record(&record("pending")).unwrap();
231        assert_eq!(std::fs::read(&path).unwrap().len(), 0, "still buffered");
232        sink.flush().unwrap();
233        assert!(!std::fs::read(&path).unwrap().is_empty());
234        let _ = std::fs::remove_file(&path);
235    }
236
237    #[test]
238    fn test_file_sink_unbuffered_writes_through() {
239        let path = temp_path("unbuffered");
240        let sink = FileSink::builder(&path, JsonFormat::new())
241            .append(false)
242            .buffer_capacity(0)
243            .sync_on_flush(true)
244            .open()
245            .unwrap();
246        sink.write_record(&record("now")).unwrap();
247        assert!(
248            std::fs::read_to_string(&path).unwrap().contains("now"),
249            "unbuffered sink must write before returning"
250        );
251        sink.flush().unwrap();
252        assert!(format!("{sink:?}").contains("sync_on_flush: true"));
253        let _ = std::fs::remove_file(&path);
254    }
255
256    #[test]
257    fn test_file_sink_open_error_is_reported() {
258        let dir = std::env::temp_dir().join(format!("log-io-missing-{}", std::process::id()));
259        let path = dir.join("nested").join("file.log");
260        let err = FileSink::append(&path, JsonFormat::new()).unwrap_err();
261        assert!(matches!(err, Error::Io(_)));
262    }
263}