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        loop {
234            let state = Arc::clone(&self.inner);
235            let notified = state.notify.notified();
236            match self.read_next() {
237                CursorRead::Eof if self.inner.closed.load(Ordering::Acquire) => {
238                    return CursorRead::Eof;
239                }
240                CursorRead::Eof => notified.await,
241                result => return result,
242            }
243        }
244    }
245
246    /// Return the next sequence this cursor will request.
247    pub fn position(&self) -> u64 {
248        self.cursor.position()
249    }
250
251    /// Whether the producer has closed the shared log.
252    pub fn is_closed(&self) -> bool {
253        self.inner.closed.load(Ordering::Acquire)
254    }
255}
256
257impl OutputCursor {
258    /// Return the next sequence this cursor will request.
259    pub fn position(&self) -> u64 {
260        self.next_sequence
261    }
262
263    /// Advance this cursor without consuming records for any other cursor.
264    pub fn read_next(&mut self, log: &OutputLog) -> CursorRead {
265        let first = log.first_sequence();
266        if self.next_sequence < first {
267            let from = self.next_sequence;
268            self.next_sequence = first;
269            return CursorRead::Gap {
270                from,
271                to: first.saturating_sub(1),
272            };
273        }
274        let Some(record) = log
275            .records
276            .iter()
277            .find(|record| record.sequence == self.next_sequence)
278        else {
279            return CursorRead::Eof;
280        };
281        self.next_sequence = self.next_sequence.saturating_add(1);
282        CursorRead::Record(record.clone())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::{CursorRead, OutputLog, SharedOutputLog};
289    use crate::StreamKind;
290
291    #[test]
292    fn bounded_retention_reports_gaps_to_lagging_cursors() {
293        let mut log = OutputLog::new(4);
294        let mut cursor = log.cursor();
295        assert_eq!(log.append(StreamKind::Stdout, b"aa"), 0);
296        assert!(
297            matches!(cursor.read_next(&log), CursorRead::Record(record) if record.sequence == 0)
298        );
299        log.append(StreamKind::Stderr, b"bb");
300        log.append(StreamKind::Stdout, b"cc");
301        log.append(StreamKind::Stderr, b"dd");
302        assert_eq!(cursor.read_next(&log), CursorRead::Gap { from: 1, to: 1 });
303        assert!(
304            matches!(cursor.read_next(&log), CursorRead::Record(record) if record.sequence == 2)
305        );
306    }
307
308    #[test]
309    fn cursors_are_independent_and_oversized_records_are_explicitly_lost() {
310        let mut log = OutputLog::new(3);
311        log.append(StreamKind::Stdout, b"1234");
312        let mut first = log.cursor_from(0);
313        let mut second = log.cursor_from(0);
314        log.append(StreamKind::Stderr, b"ok");
315        assert!(matches!(
316            first.read_next(&log),
317            CursorRead::Gap { from: 0, to: 0 }
318        ));
319        assert!(matches!(
320            second.read_next(&log),
321            CursorRead::Gap { from: 0, to: 0 }
322        ));
323        assert!(
324            matches!(first.read_next(&log), CursorRead::Record(record) if record.bytes == b"ok")
325        );
326        assert!(
327            matches!(second.read_next(&log), CursorRead::Record(record) if record.bytes == b"ok")
328        );
329        assert_eq!(log.retained_bytes(), 2);
330    }
331
332    #[test]
333    fn shared_log_keeps_cursor_positions_independent() {
334        let log = SharedOutputLog::new(8);
335        let mut first = log.cursor();
336        let mut second = log.cursor();
337        log.append(StreamKind::Stdout, b"one");
338        assert!(matches!(first.read_next(), CursorRead::Record(_)));
339        assert!(matches!(second.read_next(), CursorRead::Record(_)));
340        assert_eq!(first.position(), second.position());
341        assert_eq!(log.retained_bytes(), 3);
342    }
343
344    #[cfg(feature = "async-process")]
345    #[tokio::test]
346    async fn async_cursor_returns_terminal_eof_after_close() {
347        let log = SharedOutputLog::new(8);
348        let mut cursor = log.cursor();
349        log.close();
350        assert_eq!(cursor.read_next_async().await, CursorRead::Eof);
351        assert!(cursor.is_closed());
352    }
353}