Skip to main content

liminal_server/server/participant/production/
committed_operations.rs

1//! The B7 broker read: one conversation's committed operation rows, read
2//! through the public surface in bounded pages, the flat native prefix first
3//! and then the routed tail.
4//!
5//! Since every production commit became one routed batch on the
6//! conversation's route, a reader of the flat stream alone sees only the
7//! native prefix a store written before routing holds, and nothing a live
8//! commit staged. This read takes the same branch the server's own
9//! `OperationLog::read_committed_at` takes: below the native head a row is a
10//! flat row, at or after it a routed row under the conversation's route, read
11//! only up to the routed stream's validated head.
12//!
13//! It answers each row's stored payload bytes exactly as committed, without
14//! decoding them, so two stores that committed the same operations answer the
15//! same bytes.
16
17use std::sync::Arc;
18
19use liminal::durability::{DurabilityError, DurableStore, StoredEntry};
20
21use super::log::OperationLog;
22use super::restore_checkpoint_locator::checkpoint_conversation_route;
23use super::routed_stream::{self, RoutedStreamError, RoutedStreamRefusal};
24
25/// The most rows one page of [`read_committed_operations`] answers.
26pub const COMMITTED_OPERATION_PAGE_MAX: usize = 256;
27
28/// One committed operation row: its position in the conversation's operation
29/// log and the payload bytes stored for it.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct CommittedOperation {
32    /// The row's sequence in the conversation's operation log.
33    pub sequence: u64,
34    /// The stored payload bytes, exactly as committed.
35    pub payload: Vec<u8>,
36}
37
38/// Why a page of committed operations could not be read.
39#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum CommittedOperationReadError {
42    /// The page limit is zero or above [`COMMITTED_OPERATION_PAGE_MAX`].
43    #[error("committed operation page limit {limit} is outside 1..={max}")]
44    Limit {
45        /// The limit asked for.
46        limit: usize,
47        /// The largest limit a page accepts.
48        max: usize,
49    },
50    /// The durable store refused a read.
51    #[error(transparent)]
52    Store(#[from] DurabilityError),
53    /// The routed tail's head or a routed row was refused.
54    #[error("committed operation routed stream refused: {0}")]
55    RoutedStream(#[source] RoutedStreamRefusal),
56    /// A flat row below the native head was absent.
57    #[error("committed operation flat row {sequence} is absent below native head {native_head}")]
58    MissingFlatRow {
59        /// The first sequence the read asked for.
60        sequence: u64,
61        /// The flat stream's exclusive head.
62        native_head: u64,
63    },
64    /// A row answered out of order.
65    #[error("committed operation read expected sequence {expected}, read {actual}")]
66    Sequence {
67        /// The sequence the page required next.
68        expected: u64,
69        /// The sequence the store answered.
70        actual: u64,
71    },
72    /// A sequence in the page does not fit its encoded width.
73    #[error("committed operation sequence overflowed after {from_sequence}")]
74    SequenceOverflow {
75        /// The page's first sequence.
76        from_sequence: u64,
77    },
78}
79
80impl From<RoutedStreamError> for CommittedOperationReadError {
81    fn from(error: RoutedStreamError) -> Self {
82        match error {
83            RoutedStreamError::Store(original) => Self::Store(original),
84            RoutedStreamError::Refusal(refusal) => Self::RoutedStream(refusal),
85        }
86    }
87}
88
89/// Reads at most `limit` committed operation rows of `conversation_id`,
90/// starting at `from_sequence`: the B7 broker read.
91///
92/// A page never crosses the native head, so a page that ends there can be
93/// short; read the next page from the sequence after the last row answered.
94/// An empty page means no committed row exists at `from_sequence`: the log
95/// ends there. Rows come in sequence order with no gap.
96///
97/// # Errors
98/// Returns [`CommittedOperationReadError::Limit`] for a limit of zero or above
99/// [`COMMITTED_OPERATION_PAGE_MAX`], [`CommittedOperationReadError::Store`]
100/// when the store refuses a read,
101/// [`CommittedOperationReadError::RoutedStream`] for a corrupt routed head, a
102/// routed row missing below that head, or a `from_sequence` past it,
103/// [`CommittedOperationReadError::MissingFlatRow`] when no flat row answers
104/// below the native head, and [`CommittedOperationReadError::Sequence`] or
105/// [`CommittedOperationReadError::SequenceOverflow`] when rows answer out of
106/// order.
107pub async fn read_committed_operations(
108    store: &Arc<dyn DurableStore>,
109    conversation_id: u64,
110    from_sequence: u64,
111    limit: usize,
112) -> Result<Vec<CommittedOperation>, CommittedOperationReadError> {
113    if limit == 0 || limit > COMMITTED_OPERATION_PAGE_MAX {
114        return Err(CommittedOperationReadError::Limit {
115            limit,
116            max: COMMITTED_OPERATION_PAGE_MAX,
117        });
118    }
119    let stream = OperationLog::new(Arc::clone(store), conversation_id)
120        .stream_key()
121        .await?;
122    let native_head = store.read_stream_next_seq(&stream).await?.unwrap_or(0);
123    let entries = if from_sequence < native_head {
124        read_flat(store.as_ref(), &stream, native_head, from_sequence, limit).await?
125    } else {
126        let route = checkpoint_conversation_route(conversation_id);
127        read_routed(store.as_ref(), &route, &stream, from_sequence, limit).await?
128    };
129    contiguous(entries, from_sequence)
130}
131
132/// Reads flat rows below the native head, never past it.
133async fn read_flat(
134    store: &dyn DurableStore,
135    stream: &str,
136    native_head: u64,
137    from_sequence: u64,
138    limit: usize,
139) -> Result<Vec<StoredEntry>, CommittedOperationReadError> {
140    let remaining = native_head.saturating_sub(from_sequence);
141    let bounded = usize::try_from(remaining).map_or(limit, |remaining| remaining.min(limit));
142    let entries = store.read_from(stream, from_sequence, bounded).await?;
143    if entries.is_empty() {
144        return Err(CommittedOperationReadError::MissingFlatRow {
145            sequence: from_sequence,
146            native_head,
147        });
148    }
149    Ok(entries)
150}
151
152/// Reads routed rows at or after the native head, bounded by the routed
153/// stream's validated head; no routed head means no routed row.
154async fn read_routed(
155    store: &dyn DurableStore,
156    route: &str,
157    stream: &str,
158    from_sequence: u64,
159    limit: usize,
160) -> Result<Vec<StoredEntry>, CommittedOperationReadError> {
161    let Some(head) = routed_stream::read_head(store, route, stream).await? else {
162        return Ok(Vec::new());
163    };
164    Ok(routed_stream::read_page(store, route, stream, &head, from_sequence, limit).await?)
165}
166
167/// Keeps each entry's sequence and payload, refusing any gap or reordering.
168fn contiguous(
169    entries: Vec<StoredEntry>,
170    from_sequence: u64,
171) -> Result<Vec<CommittedOperation>, CommittedOperationReadError> {
172    let mut operations = Vec::with_capacity(entries.len());
173    for (offset, entry) in entries.into_iter().enumerate() {
174        let expected = u64::try_from(offset)
175            .ok()
176            .and_then(|offset| from_sequence.checked_add(offset))
177            .ok_or(CommittedOperationReadError::SequenceOverflow { from_sequence })?;
178        if entry.sequence != expected {
179            return Err(CommittedOperationReadError::Sequence {
180                expected,
181                actual: entry.sequence,
182            });
183        }
184        operations.push(CommittedOperation {
185            sequence: entry.sequence,
186            payload: entry.payload,
187        });
188    }
189    Ok(operations)
190}