laser_sdk/watch.rs
1use crate::cursor::Cursor;
2use crate::error::LaserError;
3use crate::laser::Laser;
4use laser_wire::change::ChangeRecord;
5use laser_wire::framing::decode_named;
6
7impl Laser {
8 /// The change-feed accessor: [`ChangeRecord`]s the managed backend publishes after
9 /// each committed projector batch for a binding that opted into `notify`.
10 /// "Query after my data landed" becomes await-then-query instead of
11 /// sleep-and-retry, and it composes with read-your-writes rather than
12 /// replacing it. Gated on the `WATCH` capability: refused typed when the
13 /// deployment does not publish the feed, so a consumer can never wait on
14 /// a topic nothing writes to.
15 pub fn watch(&self) -> Watch<'_> {
16 Watch {
17 laser: self,
18 index: None,
19 }
20 }
21}
22
23/// A fluent change-feed read. Narrow with [`index`](Self::index), then drain
24/// with [`records`](Self::records). Build it with [`Laser::watch`].
25pub struct Watch<'a> {
26 laser: &'a Laser,
27 index: Option<String>,
28}
29
30impl<'a> Watch<'a> {
31 /// Keep only advancements of this materialized index, named as
32 /// [`Laser::resource_name`] names it.
33 #[must_use]
34 pub fn index(mut self, index: impl Into<String>) -> Self {
35 self.index = Some(self.laser.resource_name(&index.into()));
36 self
37 }
38
39 /// Open the feed reader: the existing [`Cursor`] over the changes topic on
40 /// the ops stream, decoding each record and applying the index filter
41 /// client-side. No new consumption machinery, the feed is ordinary records
42 /// consumed by offset.
43 pub fn records(self) -> Result<WatchReader, LaserError> {
44 if !self.laser.current_capabilities().watch {
45 return Err(LaserError::unsupported_feature(
46 "watch",
47 "watch",
48 "the change feed is not published by this deployment",
49 ));
50 }
51 let ops_stream = self.laser.ops_stream();
52 // Under stream tenancy each stream's change feed rides its own ops
53 // topic, so a scoped handle reads its stream's topic.
54 let changes_topic = match self.laser.resource_stream() {
55 Some(stream) if self.laser.current_capabilities().stream_tenancy => {
56 laser_wire::topics::stream_ops_topic(stream, laser_wire::topics::CHANGES_TOPIC)
57 }
58 _ => self.laser.changes_topic(),
59 };
60 let cursor = self.laser.reader_on(&ops_stream, &changes_topic)?;
61 Ok(WatchReader {
62 cursor,
63 index: self.index,
64 })
65 }
66}
67
68/// A resumable change-feed reader. Each [`poll`](Self::poll) drains the
69/// records that landed since the last one. Persist [`offsets`](Self::offsets)
70/// and seed a fresh reader to resume across restarts, exactly like any
71/// [`Cursor`].
72pub struct WatchReader {
73 cursor: Cursor,
74 index: Option<String>,
75}
76
77impl WatchReader {
78 /// Resume from a previous reader's offsets.
79 #[must_use]
80 pub fn from_offsets(mut self, offsets: Vec<u64>) -> Self {
81 self.cursor = self.cursor.from_offsets(offsets);
82 self
83 }
84
85 /// Drain the change records that landed since the last poll, filtered to
86 /// the watched index when one was set. A record that does not decode as a
87 /// [`ChangeRecord`] is skipped: the changes topic defaults to
88 /// `laser_wire::topics::CHANGES_TOPIC` but is overridable
89 /// ([`Laser::with_changes_topic`]), and a misbehaving deployment publishing
90 /// other traffic on it must not wedge the reader either way.
91 pub async fn poll(&mut self) -> Result<Vec<ChangeRecord>, LaserError> {
92 let messages = self.cursor.poll().await?;
93 Ok(messages
94 .into_iter()
95 .filter_map(|message| decode_named::<ChangeRecord>(&message.payload).ok())
96 .filter(|record| {
97 self.index
98 .as_deref()
99 .is_none_or(|index| record.index == index)
100 })
101 .collect())
102 }
103
104 /// The per-partition offsets consumed so far, the resume state.
105 pub fn offsets(&self) -> &[u64] {
106 self.cursor.offsets()
107 }
108
109 /// This reader as a [`Stream`](futures::Stream) of change records, one at a
110 /// time, draining what landed since the last read and ending once caught up
111 /// (the same shape as the async reader in the Python binding). Drive it with
112 /// `futures::StreamExt`.
113 pub fn stream(self) -> impl futures::Stream<Item = Result<ChangeRecord, LaserError>> {
114 futures::stream::unfold(
115 (self, std::collections::VecDeque::new(), false),
116 |(mut reader, mut buffered, stop)| async move {
117 if stop {
118 return None;
119 }
120 loop {
121 if let Some(record) = buffered.pop_front() {
122 return Some((Ok(record), (reader, buffered, false)));
123 }
124 match reader.poll().await {
125 Ok(batch) if batch.is_empty() => return None,
126 Ok(batch) => buffered.extend(batch),
127 Err(error) => return Some((Err(error), (reader, buffered, true))),
128 }
129 }
130 },
131 )
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use crate::laser::Laser;
138
139 #[test]
140 fn given_a_default_stream_when_watching_an_index_then_should_match_the_scoped_name() {
141 let laser = Laser::from_client(crate::iggy::prelude::IggyClient::default())
142 .with_default_stream("acme");
143 let watch = laser.watch().index("readings");
144 assert_eq!(watch.index.as_deref(), Some("stream:acme/readings"));
145 }
146}