Skip to main content

exoware_server/
engine.rs

1//! Storage callbacks for the store services.
2//!
3//! Implement the capability traits your component serves. String errors are surfaced to clients as
4//! internal RPC failures (message only; keep messages safe to expose if you rely on that). `Ingest`
5//! additionally lets a backend mark a write failure transient via [`IngestError`].
6
7use std::collections::HashMap;
8use std::future::Future;
9
10use buffa::Message;
11use bytes::Bytes;
12use exoware_sdk::common::kv::v1::Entry;
13use exoware_sdk::log::stream::v1::GetResponse as StreamGetResponse;
14use exoware_sdk::prune_policy::PrunePolicyDocument;
15use exoware_sdk::retention::RetentionPolicy;
16
17use crate::stream::{apply_filter, CompiledMatchers};
18
19/// Backend-defined query metadata.
20///
21/// Keep this lightweight: streaming query RPCs may emit detail on every frame.
22pub type QueryExtra = HashMap<String, buffa_types::google::protobuf::Value>;
23
24#[derive(Clone, Debug, Default)]
25pub struct RangeScanBatch {
26    /// Rows read by this cursor pull.
27    pub rows: Vec<(Bytes, Bytes)>,
28    /// Backend-specific query metadata after reading these rows.
29    pub extra: QueryExtra,
30}
31
32/// Owned pull-based range cursor for query RPCs.
33///
34/// Implementations own any state needed to produce batches, allowing query
35/// handlers to pull rows lazily without borrowing the engine.
36pub trait RangeScan: Send {
37    /// Pull up to `max_items` rows. Returning an empty `rows` batch marks EOF.
38    /// EOF may carry non-empty `extra` with final query metadata.
39    /// `extra` is emitted with the response frame built from the same batch.
40    fn next_batch(
41        &mut self,
42        max_items: usize,
43    ) -> impl Future<Output = Result<RangeScanBatch, String>> + Send;
44}
45
46/// Local sequence frontier visible to this process.
47pub trait Sequence: Send + Sync + 'static {
48    /// Highest sequence number this process can currently serve.
49    fn current_sequence(&self) -> u64;
50}
51
52/// Why an ingest write was not accepted.
53///
54/// Backends choose the variant; the Connect layer maps it to the wire code and retry details.
55#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56pub enum IngestError {
57    /// The write cannot currently be accepted; clients may retry with backoff.
58    #[error("unavailable: {message}")]
59    Unavailable { message: String },
60
61    /// The write failed in a way retries will not fix.
62    #[error("internal: {message}")]
63    Internal { message: String },
64}
65
66/// Ingest write capability.
67pub trait Ingest: Send + Sync + 'static {
68    /// Persist key-value pairs atomically and return the global sequence number that includes this
69    /// write. Backends may coalesce concurrent writes and return the same sequence number to each
70    /// coalesced caller.
71    fn put_batch(
72        &self,
73        kvs: Vec<(Bytes, Bytes)>,
74    ) -> impl Future<Output = Result<u64, IngestError>> + Send;
75}
76
77/// Query read capability.
78pub trait Query: Sequence {
79    type RangeScan: RangeScan + 'static;
80
81    /// Fetch the value for a single key plus backend-specific query metadata.
82    /// Returns `None` when the key does not exist.
83    fn get(
84        &self,
85        key: Bytes,
86    ) -> impl Future<Output = Result<(Option<Bytes>, QueryExtra), String>> + Send;
87
88    /// Cursor over keys in `[start, end]` (inclusive) when `end` is non-empty;
89    /// empty `end` means unbounded above. Matches `store.query.v1.RangeRequest`
90    /// / `ReduceRequest` on the wire. `limit` caps rows yielded.
91    fn range_scan(
92        &self,
93        start: Bytes,
94        end: Bytes,
95        limit: usize,
96        forward: bool,
97    ) -> impl Future<Output = Result<Self::RangeScan, String>> + Send;
98
99    /// Batch-get plus backend-specific query metadata. Returns `(key, Option<value>)`
100    /// for each input key, preserving order.
101    fn get_many(
102        &self,
103        keys: Vec<Bytes>,
104    ) -> impl Future<Output = Result<(Vec<(Bytes, Option<Bytes>)>, QueryExtra), String>> + Send;
105}
106
107/// Prune mutation capability.
108pub trait Prune: Send + Sync + 'static {
109    /// Apply a validated prune policy document sequentially.
110    fn apply_prune_policies(
111        &self,
112        document: PrunePolicyDocument,
113    ) -> impl Future<Output = Result<(), String>> + Send;
114}
115
116/// Pre-encoded `log.stream.v1.GetResponse` stored for a retained sequence batch.
117#[derive(Clone, Debug)]
118pub struct LogBatch {
119    sequence_number: u64,
120    response_bytes: Bytes,
121}
122
123impl LogBatch {
124    /// Wrap protobuf bytes already encoded as `log.stream.v1.GetResponse`.
125    pub fn from_response_bytes(sequence_number: u64, response_bytes: impl Into<Bytes>) -> Self {
126        Self {
127            sequence_number,
128            response_bytes: response_bytes.into(),
129        }
130    }
131
132    /// Build a log batch from key-value pairs.
133    pub fn from_entries(sequence_number: u64, kvs: Vec<(Bytes, Bytes)>) -> Self {
134        Self::from_response(StreamGetResponse {
135            sequence_number,
136            entries: kvs
137                .into_iter()
138                .map(|(key, value)| Entry {
139                    key: key.to_vec(),
140                    value,
141                    ..Default::default()
142                })
143                .collect(),
144            ..Default::default()
145        })
146    }
147
148    /// Build a log batch from an owned response.
149    pub fn from_response(response: StreamGetResponse) -> Self {
150        Self {
151            sequence_number: response.sequence_number,
152            response_bytes: Bytes::from(response.encode_to_vec()),
153        }
154    }
155
156    /// Sequence number under which this batch was loaded.
157    pub fn sequence_number(&self) -> u64 {
158        self.sequence_number
159    }
160
161    /// Consume the batch into its pre-encoded response bytes.
162    pub fn into_response_bytes(self) -> Bytes {
163        self.response_bytes
164    }
165
166    /// Decode the stored response for paths that need to inspect entries.
167    pub fn decode_response(&self) -> Result<StreamGetResponse, String> {
168        StreamGetResponse::decode_from_slice(&self.response_bytes)
169            .map_err(|err| format!("failed to decode sequence log value: {err}"))
170    }
171}
172
173/// Filtered and fully resolved entries from a retained sequence batch.
174///
175/// An empty entry list represents a retained batch with no matches. Subscribe
176/// frames are stamped with the sequence number the read was requested under,
177/// so a filtered batch carries no sequence number a backend could misstamp.
178#[derive(Clone, Debug)]
179pub struct FilteredBatch {
180    entries: Vec<Entry>,
181}
182
183impl FilteredBatch {
184    /// Build a filtered batch from owned caller-visible entries.
185    ///
186    /// Values must contain resolved payload bytes.
187    pub fn from_entries(entries: Vec<Entry>) -> Self {
188        Self { entries }
189    }
190
191    /// Build a filtered batch from resolved key-value pairs.
192    pub fn from_kvs(kvs: Vec<(Bytes, Bytes)>) -> Self {
193        Self {
194            entries: kvs
195                .into_iter()
196                .map(|(key, value)| Entry {
197                    key: key.to_vec(),
198                    value,
199                    ..Default::default()
200                })
201                .collect(),
202        }
203    }
204
205    /// Consume the batch into its entries.
206    pub fn into_entries(self) -> Vec<Entry> {
207        self.entries
208    }
209}
210
211/// Retained per-sequence batch-log access for stream replay and lookups.
212pub trait Log: Sequence {
213    /// Return the pre-encoded `GetResponse` for the `put_batch` call that was
214    /// assigned `sequence_number`. Return `Ok(None)` when the batch is not
215    /// available from this log.
216    ///
217    /// Engines that don't retain a log return `Ok(None)` unconditionally,
218    /// which disables `GetBatch` and since-cursored `Subscribe` for that
219    /// deployment.
220    ///
221    /// The stream service maps unavailable batches to `BATCH_NOT_FOUND` when
222    /// they are beyond the visible sequence frontier and `BATCH_EVICTED`
223    /// otherwise.
224    fn get_batch(
225        &self,
226        sequence_number: u64,
227    ) -> impl Future<Output = Result<Option<LogBatch>, String>> + Send;
228
229    /// Return fully resolved matching entries from the batch assigned
230    /// `sequence_number`.
231    ///
232    /// Return `Some` with no entries when the batch is retained but no entries
233    /// match. Return `None` only when the batch is unavailable.
234    ///
235    /// An entry matches only when [`CompiledMatchers::matches_key`] accepts its
236    /// key and [`CompiledMatchers::matches_value`] accepts its resolved value.
237    /// Matching entries must keep their original batch order and multiplicity
238    /// without truncation, so subscribers observe the same frames the default
239    /// implementation would produce. Subscribe frames are stamped with the
240    /// requested `sequence_number`, so entries taken from any other batch would
241    /// be delivered under the wrong resume cursor.
242    ///
243    /// Optimized implementations must call `matches_key` before resolving a
244    /// value and `matches_value` afterward. Failures confined to key-excluded
245    /// values do not fail the filtered read.
246    fn get_batch_filtered(
247        &self,
248        sequence_number: u64,
249        matchers: &CompiledMatchers,
250    ) -> impl Future<Output = Result<Option<FilteredBatch>, String>> + Send {
251        async move {
252            let Some(batch) = self.get_batch(sequence_number).await? else {
253                return Ok(None);
254            };
255            let response = batch.decode_response()?;
256            let entries = apply_filter(matchers, response.entries);
257            Ok(Some(FilteredBatch::from_entries(entries)))
258        }
259    }
260
261    /// Lowest retained batch sequence number, or `None` when the log is
262    /// empty. Surfaced in `BATCH_EVICTED` error details so clients know where
263    /// to resume from.
264    fn oldest_retained_batch(&self) -> impl Future<Output = Result<Option<u64>, String>> + Send;
265}
266
267/// Sequence-log retention capability (`log.stream.v1` SetRetention).
268pub trait Retention: Send + Sync + 'static {
269    /// Persist and apply the retention rule; `None` clears it. Returns the oldest retained
270    /// sequence after one synchronous enforcement (`None` when the log is empty / no floor).
271    fn set_retention(
272        &self,
273        policy: Option<RetentionPolicy>,
274    ) -> impl Future<Output = Result<Option<u64>, String>> + Send;
275}
276
277/// Compatibility facade for backends that serve every store capability.
278pub trait StoreEngine: Ingest + Query + Prune + Log + Retention {}
279
280impl<T: Ingest + Query + Prune + Log + Retention> StoreEngine for T {}
281
282#[cfg(test)]
283mod tests {
284    use std::future::ready;
285
286    use super::*;
287    use exoware_sdk::kv_codec::Utf8;
288    use exoware_sdk::selector::Selector;
289    use exoware_sdk::stream_filter::StreamFilter;
290
291    #[derive(Clone)]
292    struct TestLog {
293        batch: Option<LogBatch>,
294    }
295
296    impl Sequence for TestLog {
297        fn current_sequence(&self) -> u64 {
298            u64::from(self.batch.is_some())
299        }
300    }
301
302    impl Log for TestLog {
303        fn get_batch(
304            &self,
305            sequence_number: u64,
306        ) -> impl Future<Output = Result<Option<LogBatch>, String>> + Send {
307            ready(Ok((sequence_number == 1)
308                .then(|| self.batch.clone())
309                .flatten()))
310        }
311
312        fn oldest_retained_batch(
313            &self,
314        ) -> impl Future<Output = Result<Option<u64>, String>> + Send {
315            ready(Ok(self.batch.as_ref().map(|_| 1)))
316        }
317    }
318
319    fn matchers() -> CompiledMatchers {
320        crate::stream::compile_matchers(&StreamFilter {
321            selectors: vec![Selector {
322                prefix: Bytes::from_static(&[1]),
323                payload_regex: Utf8::from("^keep$"),
324            }],
325            value_filters: vec![],
326        })
327        .unwrap()
328    }
329
330    #[test]
331    fn default_filtered_batch_preserves_retained_and_missing_states() {
332        let matching = (
333            Bytes::from_static(&[1, b'k', b'e', b'e', b'p']),
334            Bytes::from_static(b"one"),
335        );
336        let excluded = (
337            Bytes::from_static(&[2, b'k', b'e', b'e', b'p']),
338            Bytes::from_static(b"two"),
339        );
340        let log = TestLog {
341            batch: Some(LogBatch::from_entries(1, vec![matching, excluded])),
342        };
343        let filtered = futures::executor::block_on(log.get_batch_filtered(1, &matchers()))
344            .unwrap()
345            .unwrap();
346        let entries = filtered.into_entries();
347        assert_eq!(entries.len(), 1);
348        assert_eq!(entries[0].value.as_ref(), b"one");
349
350        let retained_without_matches = TestLog {
351            batch: Some(LogBatch::from_entries(
352                1,
353                vec![(Bytes::from_static(&[2]), Bytes::from_static(b"value"))],
354            )),
355        };
356        let filtered = futures::executor::block_on(
357            retained_without_matches.get_batch_filtered(1, &matchers()),
358        )
359        .unwrap()
360        .unwrap();
361        assert!(filtered.into_entries().is_empty());
362
363        let missing = TestLog { batch: None };
364        let filtered =
365            futures::executor::block_on(missing.get_batch_filtered(1, &matchers())).unwrap();
366        assert!(filtered.is_none());
367    }
368}