log_io/sink.rs
1//! Output sinks.
2//!
3//! A sink takes a [`Record`], serializes it with its [`Format`] and
4//! delivers the bytes somewhere. The built-in sinks are:
5//!
6//! | Sink | Destination | Notes |
7//! |---|---|---|
8//! | [`StdoutSink`], [`StderrSink`] | standard streams | written under the stream's own lock |
9//! | [`FileSink`] | a file | buffered; see [`FileSink`] for durability |
10//! | [`WriterSink`] | any `io::Write` | e.g. a [`RotatingFile`] ([`RotatingFileSink`]) |
11//! | [`NonBlocking`] | any `io::Write`, on a background thread | bounded queue, explicit overflow policy |
12//! | [`NullSink`] | nowhere | benchmarks and tests |
13//!
14//! # Threading
15//!
16//! Every sink is `Send + Sync`. The synchronous sinks serialize writers
17//! with a mutex and perform the write on the calling thread, so in an
18//! async runtime they block the executor thread for the duration of
19//! the write. Use [`NonBlocking`] to move I/O off the calling thread.
20//!
21//! # Allocation
22//!
23//! Records are formatted into a reusable thread-local buffer, so the
24//! synchronous sinks do not allocate in steady state. A buffer that
25//! grew past 64 KiB for an unusually large record is shrunk back to
26//! 64 KiB afterwards, so one huge record does not pin memory on every
27//! thread for the life of the process.
28//!
29//! # Reliability
30//!
31//! The built-in sinks recover from mutex poisoning: if a thread panics
32//! while holding a sink's lock (for example inside a custom writer),
33//! later records are still written. Logging stays available during
34//! incident response.
35
36use core::cell::RefCell;
37use std::io::{self, Write};
38use std::sync::{Mutex, MutexGuard};
39
40use crate::error::{Error, Result};
41use crate::format::Format;
42use crate::record::Record;
43
44/// Output stage of the pipeline.
45///
46/// Implementors must be safe to call from many threads concurrently.
47/// A sink should never panic on bad input; return an error instead so
48/// the logger can report it and keep feeding the other sinks.
49///
50/// # Examples
51///
52#[cfg_attr(all(feature = "json"), doc = "```")]
53#[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
54/// use std::sync::Mutex;
55/// use log_io::format::{Format, JsonFormat};
56/// use log_io::{Level, Logger, Record, Result, Sink};
57///
58/// /// Collects formatted records in memory.
59/// #[derive(Default)]
60/// struct Memory(Mutex<Vec<String>>);
61///
62/// impl Sink for Memory {
63/// fn write_record(&self, record: &Record<'_>) -> Result<()> {
64/// let mut line = String::new();
65/// JsonFormat::new().write_record(record, &mut line)?;
66/// self.0.lock().unwrap_or_else(|p| p.into_inner()).push(line);
67/// Ok(())
68/// }
69/// fn flush(&self) -> Result<()> {
70/// Ok(())
71/// }
72/// }
73///
74/// let sink = std::sync::Arc::new(Memory::default());
75/// let logger = Logger::builder().no_timestamps().with_sink(sink.clone()).build();
76/// logger.log(Level::Info, "hello", &[]);
77/// assert_eq!(sink.0.lock().unwrap().len(), 1);
78/// ```
79pub trait Sink: Send + Sync {
80 /// Serialize and deliver one record.
81 ///
82 /// # Errors
83 ///
84 /// Returns the destination's I/O error, a format error, or a
85 /// sink-specific error.
86 fn write_record(&self, record: &Record<'_>) -> Result<()>;
87
88 /// Push any buffered bytes to the destination (for files, to the
89 /// operating system; see the sink's own docs for `fsync`).
90 ///
91 /// # Errors
92 ///
93 /// Returns the destination's I/O error, or an error deferred from
94 /// an earlier background operation.
95 fn flush(&self) -> Result<()>;
96
97 /// Flush triggered by a record at or above the logger's flush level
98 /// (see [`crate::LoggerBuilder::flush_on`]).
99 ///
100 /// The default implementation calls [`Sink::flush`], so the record
101 /// has reached the destination when the logging call returns. A
102 /// background sink may override it to hand the request to its
103 /// worker without waiting, when waiting would break its own latency
104 /// contract; [`NonBlocking`] does this under
105 /// [`Overflow::DropNewest`]. [`crate::Logger::flush`] and
106 /// [`crate::Logger::shutdown`] call [`Sink::flush`] and
107 /// [`Sink::shutdown`], never this method.
108 ///
109 /// # Errors
110 ///
111 /// Same as [`Sink::flush`]. A sink that does not wait can only
112 /// report errors it already knows about.
113 fn request_flush(&self) -> Result<()> {
114 self.flush()
115 }
116
117 /// Flush and release background resources. Called by
118 /// [`crate::Logger::shutdown`], by a [`crate::FlushGuard`] on drop,
119 /// and when the last clone of a [`crate::Logger`] is dropped.
120 ///
121 /// A sink must keep accepting records after `shutdown` (writing
122 /// them synchronously if its background machinery is gone): late
123 /// records from other threads must not be lost. The default
124 /// implementation calls [`Sink::flush`].
125 ///
126 /// # Errors
127 ///
128 /// Same as [`Sink::flush`].
129 fn shutdown(&self) -> Result<()> {
130 self.flush()
131 }
132}
133
134impl<S: Sink + ?Sized> Sink for std::sync::Arc<S> {
135 fn write_record(&self, record: &Record<'_>) -> Result<()> {
136 (**self).write_record(record)
137 }
138 fn flush(&self) -> Result<()> {
139 (**self).flush()
140 }
141 fn request_flush(&self) -> Result<()> {
142 (**self).request_flush()
143 }
144 fn shutdown(&self) -> Result<()> {
145 (**self).shutdown()
146 }
147}
148
149impl<S: Sink + ?Sized> Sink for Box<S> {
150 fn write_record(&self, record: &Record<'_>) -> Result<()> {
151 (**self).write_record(record)
152 }
153 fn flush(&self) -> Result<()> {
154 (**self).flush()
155 }
156 fn request_flush(&self) -> Result<()> {
157 (**self).request_flush()
158 }
159 fn shutdown(&self) -> Result<()> {
160 (**self).shutdown()
161 }
162}
163
164mod file;
165mod non_blocking;
166mod recent;
167mod rotating;
168mod stdio;
169mod writer;
170
171pub use self::file::{FileSink, FileSinkBuilder};
172pub(crate) use self::non_blocking::on_background_worker;
173pub use self::non_blocking::{NonBlocking, NonBlockingBuilder, Overflow};
174pub use self::recent::{DEFAULT_LINE_LIMIT, RecentLines};
175pub use self::rotating::{RotatingFile, RotatingFileBuilder, Rotation};
176pub use self::stdio::{StderrSink, StdoutSink};
177pub use self::writer::{NullSink, WriterSink};
178
179/// A [`WriterSink`] over a [`RotatingFile`].
180///
181/// # Examples
182///
183#[cfg_attr(all(feature = "json"), doc = "```no_run")]
184#[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
185/// use log_io::format::JsonFormat;
186/// use log_io::sink::{RotatingFile, RotatingFileSink, Rotation};
187///
188/// let file = RotatingFile::builder("logs/app.jsonl")
189/// .max_bytes(10 * 1024 * 1024)
190/// .rotation(Rotation::Daily)
191/// .max_files(7)
192/// .open()?;
193/// let sink: RotatingFileSink<JsonFormat> = RotatingFileSink::new(file, JsonFormat::new());
194/// # Ok::<(), std::io::Error>(())
195/// ```
196pub type RotatingFileSink<F> = WriterSink<RotatingFile, F>;
197
198/// Keeps a sink's lock on its own cache lines.
199///
200/// Every logging thread reads the logger's sink table on each record,
201/// and every thread writes the sink's mutex. If a small sink allocation
202/// shares a cache line with other hot data, each lock and unlock
203/// invalidates that line for all threads. Measured on the reference
204/// machine, padding restored the 4-thread throughput of 1.0.
205#[repr(align(128))]
206pub(crate) struct CachePadded<T>(pub(crate) T);
207
208impl<T> core::ops::Deref for CachePadded<T> {
209 type Target = T;
210 fn deref(&self) -> &T {
211 &self.0
212 }
213}
214
215/// Capacity a thread-local format buffer may keep between records.
216pub(crate) const MAX_RETAINED_CAPACITY: usize = 64 * 1024;
217
218thread_local! {
219 /// Reusable formatting buffer, so steady-state formatting does not
220 /// touch the allocator.
221 static FORMAT_BUF: RefCell<String> = const { RefCell::new(String::new()) };
222}
223
224/// Shrink `buf` back to [`MAX_RETAINED_CAPACITY`] after an oversized
225/// record. Cheap when no shrinking is needed: one comparison.
226#[inline]
227pub(crate) fn trim_buffer(buf: &mut String) {
228 if buf.capacity() > MAX_RETAINED_CAPACITY {
229 buf.clear();
230 buf.shrink_to(MAX_RETAINED_CAPACITY);
231 }
232}
233
234/// Format `record` into the thread-local buffer and pass the text to
235/// `write`.
236///
237/// If the buffer is already in use on this thread (a sink that logs
238/// from inside its own write) or thread-local storage is being torn
239/// down, a temporary buffer is used instead.
240pub(crate) fn with_formatted<F, W>(format: &F, record: &Record<'_>, mut write: W) -> Result<()>
241where
242 F: Format,
243 W: FnMut(&str) -> io::Result<()>,
244{
245 let pooled = FORMAT_BUF.try_with(|cell| {
246 let mut buf = cell.try_borrow_mut().ok()?;
247 Some(format_into(&mut buf, format, record, &mut write))
248 });
249 match pooled {
250 Ok(Some(result)) => result,
251 _ => format_into(&mut String::with_capacity(256), format, record, &mut write),
252 }
253}
254
255fn format_into<F, W>(buf: &mut String, format: &F, record: &Record<'_>, write: &mut W) -> Result<()>
256where
257 F: Format,
258 W: FnMut(&str) -> io::Result<()>,
259{
260 buf.clear();
261 let result = format
262 .write_record_generic(record, buf)
263 .map_err(Error::Format)
264 .and_then(|()| write(buf).map_err(Error::Io));
265 trim_buffer(buf);
266 result
267}
268
269/// Applies a Unix permission mode to files `options` creates. On other
270/// platforms the file inherits its directory's access control list and
271/// `mode` has no effect.
272pub(crate) fn apply_mode(options: &mut std::fs::OpenOptions, mode: Option<u32>) {
273 #[cfg(unix)]
274 if let Some(mode) = mode {
275 use std::os::unix::fs::OpenOptionsExt;
276 let _options = options.mode(mode);
277 }
278 #[cfg(not(unix))]
279 let _unused = (options, mode);
280}
281
282/// Acquire a mutex guard even if the mutex is poisoned.
283///
284/// Poisoning only means a previous holder unwound; the writer inside is
285/// still usable, and logging must keep working after a panic.
286pub(crate) fn lock_recover<T: ?Sized>(m: &Mutex<T>) -> MutexGuard<'_, T> {
287 m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
288}
289
290/// Format `record` and write it to the writer behind `target`.
291pub(crate) fn write_locked<W: Write + ?Sized, F: Format>(
292 target: &Mutex<W>,
293 format: &F,
294 record: &Record<'_>,
295) -> Result<()> {
296 with_formatted(format, record, |text| {
297 lock_recover(target).write_all(text.as_bytes())
298 })
299}
300
301/// Flush the writer behind `target`.
302pub(crate) fn flush_locked<W: Write + ?Sized>(target: &Mutex<W>) -> Result<()> {
303 lock_recover(target).flush().map_err(Error::from)
304}
305
306#[cfg(all(test, feature = "json"))]
307mod tests {
308 use super::*;
309 use crate::format::JsonFormat;
310 use crate::{Level, Metadata};
311
312 #[test]
313 fn test_trim_buffer_caps_retained_capacity() {
314 let mut buf = String::with_capacity(4 * MAX_RETAINED_CAPACITY);
315 buf.push('x');
316 trim_buffer(&mut buf);
317 assert!(buf.capacity() <= MAX_RETAINED_CAPACITY);
318 let mut small = String::with_capacity(128);
319 small.push('y');
320 trim_buffer(&mut small);
321 assert_eq!(small, "y", "small buffers are left alone");
322 }
323
324 #[test]
325 fn test_thread_local_buffer_shrinks_after_oversize_record() {
326 // The audit's LOG-007 reproduction, scaled down: one 1 MiB
327 // record must not leave a 1 MiB+ buffer behind.
328 std::thread::spawn(|| {
329 let big = "x".repeat(1024 * 1024);
330 let record = Record::new(Metadata::new(Level::Info, "t"), &big, &[]);
331 let mut written = 0;
332 with_formatted(&JsonFormat::new(), &record, |s| {
333 written = s.len();
334 Ok(())
335 })
336 .unwrap();
337 assert!(written > big.len());
338 let cap = FORMAT_BUF.with(|b| b.borrow().capacity());
339 assert!(cap <= MAX_RETAINED_CAPACITY, "retained {cap} bytes");
340 })
341 .join()
342 .unwrap();
343 }
344
345 #[test]
346 fn test_with_formatted_reentrant_call_uses_temporary_buffer() {
347 let record = Record::new(Metadata::new(Level::Info, "t"), "outer", &[]);
348 let mut inner_text = String::new();
349 with_formatted(&JsonFormat::new(), &record, |outer| {
350 let inner = Record::new(Metadata::new(Level::Info, "t"), "inner", &[]);
351 with_formatted(&JsonFormat::new(), &inner, |s| {
352 inner_text = s.to_owned();
353 Ok(())
354 })
355 .map_err(|e| io::Error::other(e.to_string()))?;
356 assert!(outer.contains("outer"));
357 Ok(())
358 })
359 .unwrap();
360 assert!(inner_text.contains("inner"));
361 }
362
363 #[test]
364 fn test_with_formatted_surfaces_write_and_format_errors() {
365 let record = Record::new(Metadata::new(Level::Info, "t"), "m", &[]);
366 let err = with_formatted(&JsonFormat::new(), &record, |_| Err(io::Error::other("x")))
367 .unwrap_err();
368 assert!(matches!(err, Error::Io(_)));
369
370 struct Broken;
371 impl core::fmt::Display for Broken {
372 fn fmt(&self, _: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
373 Err(core::fmt::Error)
374 }
375 }
376 let fields = [crate::Field::new("b", crate::Value::Display(&Broken))];
377 let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
378 let err = with_formatted(&JsonFormat::new(), &record, |_| Ok(())).unwrap_err();
379 assert!(matches!(err, Error::Format(_)));
380 }
381}