Skip to main content

running_process/
output_log.rs

1//! Bounded append-only output retention and independent reader cursors.
2//!
3//! The actor/output pumps can use this small policy object without coupling
4//! retention to a particular transport. Consumers never remove records from
5//! the log; each [`OutputCursor`] owns its own position and receives an
6//! explicit [`CursorRead::Gap`] when retention has moved past it.
7
8use std::collections::VecDeque;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex};
11
12#[cfg(feature = "async-process")]
13use tokio::sync::Notify;
14
15use crate::StreamKind;
16
17/// One sequenced output observation retained by an [`OutputLog`].
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct OutputRecord {
20    /// Monotonic observation sequence assigned by the log.
21    pub sequence: u64,
22    /// Source stream that produced the bytes.
23    pub stream: StreamKind,
24    /// Raw bytes observed from the source stream.
25    pub bytes: Vec<u8>,
26}
27
28/// Result of advancing an independent output cursor.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum CursorRead {
31    /// Records before `to` are no longer retained and were explicitly lost.
32    Gap {
33        /// First sequence that was unavailable to this cursor.
34        from: u64,
35        /// Last sequence known to be unavailable before the retained window.
36        to: u64,
37    },
38    /// One retained output record.
39    Record(OutputRecord),
40    /// No record is currently available.
41    Eof,
42}
43
44/// A byte-bounded append-only output log.
45#[derive(Debug)]
46pub struct OutputLog {
47    capacity_bytes: usize,
48    retained_bytes: usize,
49    next_sequence: u64,
50    records: VecDeque<OutputRecord>,
51}
52
53impl OutputLog {
54    /// Create a log with a fixed aggregate byte capacity.
55    pub fn new(capacity_bytes: usize) -> Self {
56        Self {
57            capacity_bytes,
58            retained_bytes: 0,
59            next_sequence: 0,
60            records: VecDeque::new(),
61        }
62    }
63
64    /// Append bytes and return their assigned sequence number.
65    ///
66    /// Records that do not fit are not retained. Their sequence is still
67    /// consumed, allowing existing cursors to report a precise gap.
68    pub fn append(&mut self, stream: StreamKind, bytes: impl Into<Vec<u8>>) -> u64 {
69        let sequence = self.next_sequence;
70        self.next_sequence = self.next_sequence.saturating_add(1);
71        let bytes = bytes.into();
72        if bytes.len() > self.capacity_bytes {
73            self.records.clear();
74            self.retained_bytes = 0;
75            return sequence;
76        }
77
78        self.retained_bytes = self.retained_bytes.saturating_add(bytes.len());
79        self.records.push_back(OutputRecord {
80            sequence,
81            stream,
82            bytes,
83        });
84        while self.retained_bytes > self.capacity_bytes {
85            if let Some(record) = self.records.pop_front() {
86                self.retained_bytes = self.retained_bytes.saturating_sub(record.bytes.len());
87            }
88        }
89        sequence
90    }
91
92    /// Return the first sequence still retained, or the next sequence when empty.
93    pub fn first_sequence(&self) -> u64 {
94        self.records
95            .front()
96            .map_or(self.next_sequence, |record| record.sequence)
97    }
98
99    /// Return the next sequence that will be assigned.
100    pub fn next_sequence(&self) -> u64 {
101        self.next_sequence
102    }
103
104    /// Return the number of bytes currently retained.
105    pub fn retained_bytes(&self) -> usize {
106        self.retained_bytes
107    }
108
109    /// Return the number of records currently retained.
110    pub fn len(&self) -> usize {
111        self.records.len()
112    }
113
114    /// Whether the log contains no retained records.
115    pub fn is_empty(&self) -> bool {
116        self.records.is_empty()
117    }
118
119    /// Create a cursor positioned at the oldest currently retained record.
120    pub fn cursor(&self) -> OutputCursor {
121        OutputCursor {
122            next_sequence: self.first_sequence(),
123        }
124    }
125
126    /// Create a cursor positioned at an explicit sequence.
127    pub fn cursor_from(&self, sequence: u64) -> OutputCursor {
128        OutputCursor {
129            next_sequence: sequence,
130        }
131    }
132}
133
134/// Independent position in an [`OutputLog`].
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct OutputCursor {
137    next_sequence: u64,
138}
139
140/// Thread-safe output-log handle suitable for sharing with actor consumers.
141#[derive(Debug, Clone)]
142pub struct SharedOutputLog {
143    inner: Arc<SharedOutputState>,
144}
145
146#[derive(Debug)]
147struct SharedOutputState {
148    log: Mutex<OutputLog>,
149    #[cfg(feature = "async-process")]
150    notify: Notify,
151    closed: AtomicBool,
152}
153
154impl SharedOutputLog {
155    /// Create a shared log with a fixed aggregate byte capacity.
156    pub fn new(capacity_bytes: usize) -> Self {
157        Self {
158            inner: Arc::new(SharedOutputState {
159                log: Mutex::new(OutputLog::new(capacity_bytes)),
160                #[cfg(feature = "async-process")]
161                notify: Notify::new(),
162                closed: AtomicBool::new(false),
163            }),
164        }
165    }
166
167    /// Append an observation without exposing the log's synchronization primitive.
168    pub fn append(&self, stream: StreamKind, bytes: impl Into<Vec<u8>>) -> u64 {
169        let sequence = self
170            .inner
171            .log
172            .lock()
173            .expect("output log lock is not poisoned")
174            .append(stream, bytes);
175        #[cfg(feature = "async-process")]
176        self.inner.notify.notify_waiters();
177        sequence
178    }
179
180    /// Mark the producer closed and wake all waiting cursors.
181    pub fn close(&self) {
182        self.inner.closed.store(true, Ordering::Release);
183        #[cfg(feature = "async-process")]
184        self.inner.notify.notify_waiters();
185    }
186
187    /// Create a cursor at the current retention boundary.
188    pub fn cursor(&self) -> SharedOutputCursor {
189        let cursor = self
190            .inner
191            .log
192            .lock()
193            .expect("output log lock is not poisoned")
194            .cursor();
195        SharedOutputCursor {
196            inner: Arc::clone(&self.inner),
197            cursor,
198        }
199    }
200
201    /// Return the number of bytes currently retained.
202    pub fn retained_bytes(&self) -> usize {
203        self.inner
204            .log
205            .lock()
206            .expect("output log lock is not poisoned")
207            .retained_bytes()
208    }
209}
210
211/// Independent cursor over a [`SharedOutputLog`].
212#[derive(Debug, Clone)]
213pub struct SharedOutputCursor {
214    inner: Arc<SharedOutputState>,
215    cursor: OutputCursor,
216}
217
218impl SharedOutputCursor {
219    /// Read the next record or explicit gap from the shared log.
220    pub fn read_next(&mut self) -> CursorRead {
221        self.cursor.read_next(
222            &self
223                .inner
224                .log
225                .lock()
226                .expect("output log lock is not poisoned"),
227        )
228    }
229
230    /// Await the next record, gap, or terminal EOF without polling.
231    #[cfg(feature = "async-process")]
232    pub async fn read_next_async(&mut self) -> CursorRead {
233        self.read_next_async_inner(|| {}).await
234    }
235
236    /// Test seam for the close-vs-wait interleaving.  The production path
237    /// above is intentionally the same implementation with a no-op hook.
238    #[cfg(all(test, feature = "async-process"))]
239    async fn read_next_async_after_empty<F>(&mut self, after_empty: F) -> CursorRead
240    where
241        F: FnMut(),
242    {
243        self.read_next_async_inner(after_empty).await
244    }
245
246    #[cfg(feature = "async-process")]
247    async fn read_next_async_inner<F>(&mut self, mut after_empty: F) -> CursorRead
248    where
249        F: FnMut(),
250    {
251        loop {
252            let state = Arc::clone(&self.inner);
253            // `Notify` is not a broadcast condition variable.  Register the
254            // waiter before checking the log/closed state so a producer (or
255            // the final close) cannot notify in the check-then-wait window.
256            let notified = state.notify.notified();
257            tokio::pin!(notified);
258            notified.as_mut().enable();
259            match self.read_next() {
260                CursorRead::Eof if self.inner.closed.load(Ordering::Acquire) => {
261                    return CursorRead::Eof;
262                }
263                CursorRead::Eof => {
264                    after_empty();
265                    notified.as_mut().await;
266                }
267                result => return result,
268            }
269        }
270    }
271
272    /// Return the next sequence this cursor will request.
273    pub fn position(&self) -> u64 {
274        self.cursor.position()
275    }
276
277    /// Whether the producer has closed the shared log.
278    pub fn is_closed(&self) -> bool {
279        self.inner.closed.load(Ordering::Acquire)
280    }
281}
282
283impl OutputCursor {
284    /// Return the next sequence this cursor will request.
285    pub fn position(&self) -> u64 {
286        self.next_sequence
287    }
288
289    /// Advance this cursor without consuming records for any other cursor.
290    pub fn read_next(&mut self, log: &OutputLog) -> CursorRead {
291        let first = log.first_sequence();
292        if self.next_sequence < first {
293            let from = self.next_sequence;
294            self.next_sequence = first;
295            return CursorRead::Gap {
296                from,
297                to: first.saturating_sub(1),
298            };
299        }
300        let Some(record) = log
301            .records
302            .iter()
303            .find(|record| record.sequence == self.next_sequence)
304        else {
305            return CursorRead::Eof;
306        };
307        self.next_sequence = self.next_sequence.saturating_add(1);
308        CursorRead::Record(record.clone())
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::{CursorRead, OutputLog, SharedOutputLog};
315    use crate::StreamKind;
316
317    #[test]
318    fn bounded_retention_reports_gaps_to_lagging_cursors() {
319        let mut log = OutputLog::new(4);
320        let mut cursor = log.cursor();
321        assert_eq!(log.append(StreamKind::Stdout, b"aa"), 0);
322        assert!(
323            matches!(cursor.read_next(&log), CursorRead::Record(record) if record.sequence == 0)
324        );
325        log.append(StreamKind::Stderr, b"bb");
326        log.append(StreamKind::Stdout, b"cc");
327        log.append(StreamKind::Stderr, b"dd");
328        assert_eq!(cursor.read_next(&log), CursorRead::Gap { from: 1, to: 1 });
329        assert!(
330            matches!(cursor.read_next(&log), CursorRead::Record(record) if record.sequence == 2)
331        );
332    }
333
334    #[test]
335    fn cursors_are_independent_and_oversized_records_are_explicitly_lost() {
336        let mut log = OutputLog::new(3);
337        log.append(StreamKind::Stdout, b"1234");
338        let mut first = log.cursor_from(0);
339        let mut second = log.cursor_from(0);
340        log.append(StreamKind::Stderr, b"ok");
341        assert!(matches!(
342            first.read_next(&log),
343            CursorRead::Gap { from: 0, to: 0 }
344        ));
345        assert!(matches!(
346            second.read_next(&log),
347            CursorRead::Gap { from: 0, to: 0 }
348        ));
349        assert!(
350            matches!(first.read_next(&log), CursorRead::Record(record) if record.bytes == b"ok")
351        );
352        assert!(
353            matches!(second.read_next(&log), CursorRead::Record(record) if record.bytes == b"ok")
354        );
355        assert_eq!(log.retained_bytes(), 2);
356    }
357
358    #[test]
359    fn shared_log_keeps_cursor_positions_independent() {
360        let log = SharedOutputLog::new(8);
361        let mut first = log.cursor();
362        let mut second = log.cursor();
363        log.append(StreamKind::Stdout, b"one");
364        assert!(matches!(first.read_next(), CursorRead::Record(_)));
365        assert!(matches!(second.read_next(), CursorRead::Record(_)));
366        assert_eq!(first.position(), second.position());
367        assert_eq!(log.retained_bytes(), 3);
368    }
369
370    #[cfg(feature = "async-process")]
371    #[tokio::test]
372    async fn async_cursor_returns_terminal_eof_after_close() {
373        let log = SharedOutputLog::new(8);
374        let mut cursor = log.cursor();
375        log.close();
376        assert_eq!(cursor.read_next_async().await, CursorRead::Eof);
377        assert!(cursor.is_closed());
378    }
379
380    /// Closing after the cursor found no record must still wake it.  This
381    /// deliberately hits the historical check-then-register window instead
382    /// of relying on a scheduler race to reproduce it.
383    #[cfg(feature = "async-process")]
384    #[tokio::test]
385    async fn async_cursor_close_between_empty_check_and_wait_is_not_lost() {
386        let log = SharedOutputLog::new(8);
387        let close_log = log.clone();
388        let mut cursor = log.cursor();
389
390        let result = tokio::time::timeout(
391            std::time::Duration::from_secs(1),
392            cursor.read_next_async_after_empty(move || close_log.close()),
393        )
394        .await
395        .expect("close wakes a cursor that has no record");
396
397        assert_eq!(result, CursorRead::Eof);
398        assert!(cursor.is_closed());
399    }
400}