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;