Skip to main content

rlg/
engine.rs

1// engine.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6//! Near-lock-free ingestion engine backed by a bounded ring buffer.
7//!
8//! The global [`ENGINE` static](crate::engine::ENGINE) accepts
9//! [`LogEvent` values](crate::engine::LogEvent) via
10//! [`LockFreeEngine::ingest()`][crate::engine::LockFreeEngine::ingest]
11//! using only atomic operations. A dedicated background thread drains events
12//! in batches of 64 and writes them through [`crate::sink::PlatformSink`].
13//!
14//! **The Mutex is never locked on the hot path.** It exists solely for
15//! `shutdown()` to join the flusher thread.
16
17use crate::log_level::LogLevel;
18use crate::sharded_queue::ShardedQueue;
19#[cfg(not(miri))]
20use crate::sink::PlatformSink;
21use crate::tui::TuiMetrics;
22#[cfg(not(miri))]
23use crate::tui::spawn_tui_thread;
24use std::fmt;
25#[cfg(not(miri))]
26use std::sync::atomic::fence;
27use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
28use std::sync::{Arc, LazyLock, Mutex};
29use std::thread;
30#[cfg(not(miri))]
31use std::time::Duration;
32
33/// Capacity of the lock-free ring buffer (number of log events).
34const RING_BUFFER_CAPACITY: usize = 65_536;
35
36/// Maximum number of events drained per flusher wake-up cycle.
37#[cfg(not(miri))]
38const MAX_DRAIN_BATCH_SIZE: usize = 64;
39
40/// A structured log event passed through the ring buffer.
41///
42/// The caller pays only for a `Log` move (~128-byte memcpy).
43/// Serialization happens on the flusher thread.
44#[derive(Debug, Clone)]
45pub struct LogEvent {
46    /// Severity level of this event.
47    pub level: LogLevel,
48    /// Numeric severity for fast level-gating comparisons.
49    pub level_num: u8,
50    /// Structured log data. Formatted on the flusher thread, not here.
51    pub log: crate::log::Log,
52    /// Call site captured by [`Log::fire`](crate::log::Log::fire). The
53    /// flusher adds it to `log` as the `caller` attribute before
54    /// formatting, so the caller's thread never builds that string.
55    pub caller: Option<&'static std::panic::Location<'static>>,
56}
57
58impl LogEvent {
59    /// Wrap `log` as an event at its own level, with no call site.
60    #[must_use]
61    pub const fn new(log: crate::log::Log) -> Self {
62        Self {
63            level: log.level,
64            level_num: log.level.to_numeric(),
65            log,
66            caller: None,
67        }
68    }
69}
70
71/// The near-lock-free ingestion engine.
72///
73/// Owns the ring buffer, flusher thread, and TUI metrics counters.
74/// Access the global instance via [`ENGINE`].
75pub struct LockFreeEngine {
76    /// Bounded, sharded ring buffer.
77    ///
78    /// - Default build: one shard — semantically identical to the
79    ///   direct `ArrayQueue` use in prior releases.
80    /// - `fast-queue` feature: eight shards — reduces producer-side
81    ///   cache-line contention on the underlying atomic tag when
82    ///   many threads ingest concurrently.
83    ///
84    /// See `docs/adr/0009-sharded-producer-queue.md`.
85    queue: Arc<ShardedQueue>,
86    /// Signals the flusher thread to drain and exit.
87    shutdown_flag: Arc<AtomicBool>,
88    /// Atomic counters consumed by the opt-in TUI dashboard.
89    metrics: Arc<TuiMetrics>,
90    /// Minimum severity level. Events below this are dropped at `ingest()`.
91    filter_level: AtomicU8,
92    /// Flusher thread handle for lock-free `unpark()`. No Mutex involved.
93    flusher_thread_handle: Option<thread::Thread>,
94    /// Set by the flusher just before it parks. Producers only read it,
95    /// so a busy flusher costs them no shared write; the first producer
96    /// to see it set clears it and wakes the flusher.
97    flusher_idle: Arc<AtomicBool>,
98    /// `JoinHandle` for `shutdown()` only. **Never locked on the hot path.**
99    flusher_join: Mutex<Option<thread::JoinHandle<()>>>,
100}
101
102impl fmt::Debug for LockFreeEngine {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.debug_struct("LockFreeEngine")
105            .field("queue", &self.queue)
106            .field("shutdown_flag", &self.shutdown_flag)
107            .field("metrics", &self.metrics)
108            .field("filter_level", &self.filter_level)
109            .field(
110                "flusher_thread_handle",
111                &self
112                    .flusher_thread_handle
113                    .as_ref()
114                    .map(thread::Thread::id),
115            )
116            .finish_non_exhaustive()
117    }
118}
119
120/// Global engine instance, lazily initialized on first access.
121pub static ENGINE: LazyLock<LockFreeEngine> =
122    LazyLock::new(|| LockFreeEngine::new(RING_BUFFER_CAPACITY));
123
124/// Push `event`, evicting from its shard when full (see
125/// [`ShardedQueue::push_evicting`]).
126///
127/// Every event that does not stay in the queue is counted once in
128/// `dropped_events`, and once by [`count_event`] since the flusher will
129/// never see it.
130fn push_evicting(
131    queue: &ShardedQueue,
132    metrics: &TuiMetrics,
133    event: LogEvent,
134) {
135    queue.push_evicting(event, |dropped| {
136        count_dropped(metrics, dropped);
137    });
138}
139
140/// Count an event that left the engine without reaching the sink.
141fn count_dropped(metrics: &TuiMetrics, event: &LogEvent) {
142    count_event(metrics, event);
143    metrics.inc_dropped();
144}
145
146/// Add one event to the totals, level, error and format counters.
147///
148/// Runs where an event leaves the queue (on the flusher, or on the
149/// rare eviction path), never on every `ingest`: the counters then
150/// have one writer in the common case, and producers do not contend
151/// on their cache line.
152fn count_event(metrics: &TuiMetrics, event: &LogEvent) {
153    metrics.inc_events();
154    metrics.inc_level(event.level);
155    if event.level_num >= LogLevel::ERROR.to_numeric() {
156        metrics.inc_errors();
157    }
158    metrics.inc_format(event.log.format);
159}
160
161/// Start the `rlg-flusher` thread over `queue`.
162#[cfg(not(miri))]
163fn spawn_flusher(
164    queue: Arc<ShardedQueue>,
165    shutdown: Arc<AtomicBool>,
166    idle: Arc<AtomicBool>,
167    metrics: Arc<TuiMetrics>,
168) -> thread::JoinHandle<()> {
169    // Lightweight OS thread: runtime agnostic.
170    thread::Builder::new()
171        .name("rlg-flusher".into())
172        .spawn(move || run_flusher(&queue, &shutdown, &idle, &metrics))
173        .expect("Failed to spawn rlg-flusher background thread")
174}
175
176/// The flusher loop: drain a batch, format and emit it, and stop once
177/// shutdown is flagged and the queue is empty.
178#[cfg(not(miri))]
179fn run_flusher(
180    queue: &ShardedQueue,
181    shutdown: &AtomicBool,
182    idle: &AtomicBool,
183    metrics: &TuiMetrics,
184) {
185    let mut sink = PlatformSink::native();
186    let mut fmt_buf = Vec::with_capacity(512);
187    loop {
188        let mut batch: [Option<LogEvent>; MAX_DRAIN_BATCH_SIZE] =
189            std::array::from_fn(|_| None);
190        drain_into(queue, &mut batch);
191        for event in batch.iter_mut().flatten() {
192            attach_caller(event);
193            count_event(metrics, event);
194            emit(&mut sink, &mut fmt_buf, event);
195        }
196        if shutdown.load(Ordering::Relaxed) && queue.is_empty() {
197            break;
198        }
199        park_when_idle(queue, idle);
200    }
201}
202
203/// Park until a producer wakes the flusher, or 5 ms pass. The flag is
204/// raised before the final emptiness check, so a producer that pushed
205/// after that check sees it and wakes the thread; a wake-up lost in the
206/// narrow race between the two is covered by the timeout.
207#[cfg(not(miri))]
208fn park_when_idle(queue: &ShardedQueue, idle: &AtomicBool) {
209    idle.store(true, Ordering::SeqCst);
210    fence(Ordering::SeqCst);
211    if queue.is_empty() {
212        thread::park_timeout(Duration::from_millis(5));
213    }
214    idle.store(false, Ordering::Release);
215}
216
217/// Fill `batch` from the front with queued events until either runs out.
218#[cfg(not(miri))]
219fn drain_into(queue: &ShardedQueue, batch: &mut [Option<LogEvent>]) {
220    for slot in batch {
221        let Some(event) = queue.pop() else { break };
222        *slot = Some(event);
223    }
224}
225
226/// Add the call site `fire()` captured as the `caller` attribute.
227#[cfg(not(miri))]
228fn attach_caller(event: &mut LogEvent) {
229    if let Some(caller) = event.caller.take() {
230        event.log.attributes.insert(
231            "caller".to_string(),
232            serde_json::Value::String(crate::log::caller_string(
233                caller,
234            )),
235        );
236    }
237}
238
239/// Format one event into `fmt_buf` and hand it to the sink.
240#[cfg(not(miri))]
241fn emit(
242    sink: &mut PlatformSink,
243    fmt_buf: &mut Vec<u8>,
244    event: &LogEvent,
245) {
246    use std::io::Write;
247    fmt_buf.clear();
248    let _ = writeln!(fmt_buf, "{}", event.log);
249    sink.emit(event.level.as_str(), fmt_buf);
250}
251
252impl LockFreeEngine {
253    /// Create a new engine with the given buffer capacity and spawn the flusher.
254    ///
255    /// # Panics
256    ///
257    /// Panics if the OS cannot spawn the background flusher thread.
258    #[must_use]
259    pub fn new(capacity: usize) -> Self {
260        let queue = Arc::new(ShardedQueue::new(capacity));
261        let shutdown_flag = Arc::new(AtomicBool::new(false));
262        let metrics = Arc::new(TuiMetrics::default());
263        let filter_level = AtomicU8::new(0); // Default to ALL
264        let flusher_idle = Arc::new(AtomicBool::new(false));
265
266        // Under MIRI, skip spawning background threads to avoid
267        // "main thread terminated without waiting" errors.
268        #[cfg(not(miri))]
269        let flusher_handle = {
270            let handle = spawn_flusher(
271                Arc::clone(&queue),
272                Arc::clone(&shutdown_flag),
273                Arc::clone(&flusher_idle),
274                Arc::clone(&metrics),
275            );
276            // Spawn the TUI dashboard thread if RLG_TUI=1
277            if std::env::var("RLG_TUI").is_ok_and(|v| v == "1") {
278                spawn_tui_thread(
279                    metrics.clone(),
280                    shutdown_flag.clone(),
281                );
282            }
283            Some(handle)
284        };
285
286        #[cfg(miri)]
287        let flusher_handle: Option<thread::JoinHandle<()>> = None;
288
289        let flusher_thread_handle =
290            flusher_handle.as_ref().map(|h| h.thread().clone());
291
292        Self {
293            queue,
294            shutdown_flag,
295            metrics,
296            filter_level,
297            flusher_thread_handle,
298            flusher_idle,
299            flusher_join: Mutex::new(flusher_handle),
300        }
301    }
302
303    /// Appends an event to the ring buffer.
304    ///
305    /// If the buffer is full, the oldest event is evicted to make room.
306    /// Dropped events are tracked via `TuiMetrics::dropped_events`.
307    ///
308    /// The event, level, error and format counters are updated as the
309    /// flusher drains each event (or when one is dropped), so they
310    /// trail `ingest` by at most one flush.
311    pub fn ingest(&self, event: LogEvent) {
312        if event.level_num < self.filter_level.load(Ordering::Acquire) {
313            return;
314        }
315        push_evicting(&self.queue, &self.metrics, event);
316        self.wake_flusher();
317    }
318
319    /// Wake the flusher if it is parked. Only the first producer to see
320    /// it idle pays for the `unpark`; the rest only read the flag, so
321    /// producers do not contend on the flusher's park state.
322    fn wake_flusher(&self) {
323        if self.flusher_idle.load(Ordering::SeqCst)
324            && self.flusher_idle.swap(false, Ordering::AcqRel)
325            && let Some(thread) = &self.flusher_thread_handle
326        {
327            thread.unpark();
328        }
329    }
330
331    /// Sets the global log level filter.
332    pub fn set_filter(&self, level: u8) {
333        self.filter_level.store(level, Ordering::Release);
334    }
335
336    /// Returns the current global log level filter.
337    #[must_use]
338    pub fn filter_level(&self) -> u8 {
339        self.filter_level.load(Ordering::Relaxed)
340    }
341
342    /// Increments the format counter in the TUI metrics.
343    ///
344    /// The engine counts each event's format itself as it drains it;
345    /// call this only for records that bypass [`Self::ingest`].
346    pub fn inc_format(&self, format: crate::log_format::LogFormat) {
347        self.metrics.inc_format(format);
348    }
349
350    /// Increments the active span count in the TUI metrics.
351    pub fn inc_spans(&self) {
352        self.metrics.inc_spans();
353    }
354
355    /// Decrements the active span count in the TUI metrics.
356    pub fn dec_spans(&self) {
357        self.metrics.dec_spans();
358    }
359
360    /// Returns the current number of active spans.
361    #[must_use]
362    pub fn active_spans(&self) -> usize {
363        self.metrics.active_spans.load(Ordering::Relaxed)
364    }
365
366    /// Applies configuration settings to the engine.
367    ///
368    /// Sets the log level filter from the config. File sink construction
369    /// and rotation are handled by the flusher thread at startup via
370    /// [`PlatformSink::from_config`](crate::sink::PlatformSink::from_config).
371    pub fn apply_config(&self, config: &crate::config::Config) {
372        self.set_filter(config.log_level.to_numeric());
373    }
374
375    /// Safely halts the background thread, flushing pending logs.
376    ///
377    /// Signals the flusher thread to stop and waits for it to finish
378    /// draining any remaining events from the queue.
379    pub fn shutdown(&self) {
380        self.shutdown_flag.store(true, Ordering::SeqCst);
381        // Wake the flusher so it can drain and exit.
382        if let Some(thread) = &self.flusher_thread_handle {
383            thread.unpark();
384        }
385        if let Ok(mut guard) = self.flusher_join.lock()
386            && let Some(handle) = guard.take()
387        {
388            let _ = handle.join();
389        }
390    }
391}
392
393/// Zero-Allocation Serializer Helper
394#[derive(Debug, Clone, Copy)]
395pub struct FastSerializer;
396
397impl FastSerializer {
398    /// Appends a u64 integer to a buffer using `itoa` without allocating a String.
399    pub fn append_u64(buf: &mut Vec<u8>, val: u64) {
400        let mut buffer = itoa::Buffer::new();
401        buf.extend_from_slice(buffer.format(val).as_bytes());
402    }
403
404    /// Appends an f64 float to a buffer using `ryu` without allocating a String.
405    pub fn append_f64(buf: &mut Vec<u8>, val: f64) {
406        let mut buffer = ryu::Buffer::new();
407        buf.extend_from_slice(buffer.format(val).as_bytes());
408    }
409}
410
411#[cfg(test)]
412#[cfg_attr(miri, allow(unused_imports))]
413mod tests;