Skip to main content

laser_sdk/agent/
session_reads.rs

1use crate::agent::{Session, Sessions};
2use crate::context::ContextMessage;
3use crate::error::LaserError;
4use crate::laser::Laser;
5use crate::types::ConversationId;
6use iggy::prelude::{Consumer, Identifier, MessageClient, PollingStrategy, TopicClient};
7use laser_wire::codes::{
8    AGDX_SESSION_CHANGES_CODE, AGDX_SESSION_EVENTS_CODE, AGDX_SESSION_GET_CODE,
9    AGDX_SESSION_LINKS_CODE, AGDX_SESSION_LIST_CODE, AGDX_SESSION_SOURCES_CODE,
10    AGDX_SESSION_STATE_CODE,
11};
12use laser_wire::graph::SourceRef;
13use laser_wire::session::request;
14use laser_wire::session::{
15    LinkSurface, SessionChanges, SessionEventsPage, SessionInfo, SessionLinksView, SessionOutcome,
16    SessionPage, SessionReply, SessionSources, SessionStateView,
17};
18use std::collections::{BTreeSet, VecDeque};
19use std::time::Duration;
20
21impl Sessions {
22    /// One session's summary from the deployment's session index.
23    pub async fn get(&self, id: ConversationId) -> Result<SessionInfo, LaserError> {
24        let request = request::SessionGet {
25            stream: self.stream()?.to_owned(),
26            id: id.into(),
27        };
28        match self.read(AGDX_SESSION_GET_CODE, &request).await? {
29            SessionOutcome::Info(info) => Ok(*info),
30            _ => Err(unexpected("get")),
31        }
32    }
33
34    /// A page of this stream's sessions, newest first. Chain the filters and
35    /// finish with [`fetch`](SessionListRequest::fetch).
36    pub fn list(&self) -> SessionListRequest {
37        SessionListRequest {
38            sessions: self.clone(),
39            status: None,
40            root: None,
41            label_prefix: None,
42            agent: None,
43            text: None,
44            cursor: None,
45            limit: 0,
46            total: false,
47        }
48    }
49
50    /// A page of one session's events in broker time order. Chain the paging
51    /// and finish with [`fetch`](SessionEventsRequest::fetch).
52    pub fn events(&self, id: ConversationId) -> SessionEventsRequest {
53        SessionEventsRequest {
54            sessions: self.clone(),
55            id,
56            cursor: None,
57            limit: 0,
58            fixed_frontier: false,
59        }
60    }
61
62    /// One session's folded state document with up to `history_limit` history
63    /// rows. Zero leaves the history size to the server.
64    pub async fn state(
65        &self,
66        id: ConversationId,
67        history_limit: u32,
68    ) -> Result<SessionStateView, LaserError> {
69        let request = request::SessionState {
70            stream: self.stream()?.to_owned(),
71            id: id.into(),
72            history_limit,
73        };
74        match self.read(AGDX_SESSION_STATE_CODE, &request).await? {
75            SessionOutcome::State(view) => Ok(view),
76            _ => Err(unexpected("state")),
77        }
78    }
79
80    /// The resources one session wrote, recalled, or touched, narrowed to
81    /// `surface` when given.
82    pub async fn links(
83        &self,
84        id: ConversationId,
85        surface: Option<LinkSurface>,
86    ) -> Result<SessionLinksView, LaserError> {
87        let request = request::SessionLinks {
88            stream: self.stream()?.to_owned(),
89            id: id.into(),
90            surface,
91        };
92        match self.read(AGDX_SESSION_LINKS_CODE, &request).await? {
93            SessionOutcome::Links(view) => Ok(view),
94            _ => Err(unexpected("links")),
95        }
96    }
97
98    /// The source partitions that hold one session's records.
99    pub async fn sources(&self, id: ConversationId) -> Result<SessionSources, LaserError> {
100        let request = request::SessionSources {
101            stream: self.stream()?.to_owned(),
102            id: id.into(),
103            lane_only: false,
104        };
105        match self.read(AGDX_SESSION_SOURCES_CODE, &request).await? {
106            SessionOutcome::Sources(sources) => Ok(sources),
107            _ => Err(unexpected("sources")),
108        }
109    }
110
111    pub(crate) async fn registered_lane(
112        &self,
113        id: ConversationId,
114    ) -> Result<Option<(u32, u64, u32)>, LaserError> {
115        let request = request::SessionSources {
116            stream: self.stream()?.to_owned(),
117            id: id.into(),
118            lane_only: true,
119        };
120        match self.read(AGDX_SESSION_SOURCES_CODE, &request).await? {
121            SessionOutcome::Sources(view) => Ok(view.lane),
122            _ => Err(unexpected("sources")),
123        }
124    }
125
126    /// The change rows of this stream after `after`, up to `limit`. Zero
127    /// leaves the page size to the server.
128    pub async fn changes(&self, after: u64, limit: u32) -> Result<SessionChanges, LaserError> {
129        self.changes_in(after, limit, None).await
130    }
131
132    /// [`changes`](Self::changes) within one change series. A node whose
133    /// series differs answers a resync.
134    async fn changes_in(
135        &self,
136        after: u64,
137        limit: u32,
138        series: Option<u64>,
139    ) -> Result<SessionChanges, LaserError> {
140        let request = request::SessionChanges {
141            stream: self.stream()?.to_owned(),
142            after,
143            limit,
144            series,
145        };
146        match self.read(AGDX_SESSION_CHANGES_CODE, &request).await? {
147            SessionOutcome::Changes(changes) => Ok(changes),
148            _ => Err(unexpected("changes")),
149        }
150    }
151
152    /// Follow this stream's session changes from now, polling every
153    /// `poll_every` and coalescing the changed session ids of each poll. The
154    /// watch starts at the change rows retained when this call returns, so a
155    /// change made after it is never missed.
156    pub async fn watch(&self, poll_every: Duration) -> Result<SessionWatch, LaserError> {
157        let mut after = 0;
158        let mut series = None;
159        loop {
160            let page = self.changes_in(after, 0, series).await?;
161            if page.series != 0 {
162                series = Some(page.series);
163            }
164            if page.resync {
165                after = page.floor;
166                continue;
167            }
168            match page.rows.iter().map(|row| row.seq).max() {
169                Some(seq) => after = seq,
170                None => break,
171            }
172        }
173        Ok(SessionWatch {
174            sessions: self.clone(),
175            poll_every,
176            after,
177            series,
178            ready: VecDeque::new(),
179        })
180    }
181
182    async fn read<T: serde::Serialize>(
183        &self,
184        code: u32,
185        request: &T,
186    ) -> Result<SessionOutcome, LaserError> {
187        let laser = self.laser();
188        if !laser.capabilities().await.sessions {
189            return Err(LaserError::unsupported_feature(
190                "sessions",
191                "sessions",
192                "session reads need a deployment that indexes sessions",
193            ));
194        }
195        let payload = laser_wire::framing::encode_named(request)?;
196        let reply = laser.send_raw_with_response(code, payload).await?;
197        match crate::error::decode_managed_reply::<SessionReply>(&reply)? {
198            SessionReply::Ok(outcome) => Ok(outcome),
199            SessionReply::Err(error) => Err(error.into()),
200            _ => Err(unexpected("reply")),
201        }
202    }
203}
204
205impl Session {
206    /// This session's summary from the deployment's session index: status,
207    /// participants, counters, and the derived idle and over-budget flags.
208    pub async fn status(&self) -> Result<SessionInfo, LaserError> {
209        self.laser.sessions().get(self.conversation()).await
210    }
211}
212
213/// A session list read. Build it with [`Sessions::list`].
214#[must_use = "a session list does nothing until fetch().await"]
215pub struct SessionListRequest {
216    sessions: Sessions,
217    status: Option<laser_wire::agent::SessionStatus>,
218    root: Option<ConversationId>,
219    label_prefix: Option<String>,
220    agent: Option<laser_wire::agent::AgentId>,
221    text: Option<String>,
222    cursor: Option<String>,
223    limit: u32,
224    total: bool,
225}
226
227impl SessionListRequest {
228    /// Only sessions in `status`.
229    pub fn status(mut self, status: laser_wire::agent::SessionStatus) -> Self {
230        self.status = Some(status);
231        self
232    }
233
234    /// Only sessions in the tree rooted at `root`.
235    pub fn root(mut self, root: ConversationId) -> Self {
236        self.root = Some(root);
237        self
238    }
239
240    /// Only sessions whose label starts with `prefix`.
241    pub fn label_prefix(mut self, prefix: impl Into<String>) -> Self {
242        self.label_prefix = Some(prefix.into());
243        self
244    }
245
246    /// Only sessions `agent` took part in.
247    pub fn agent(mut self, agent: impl Into<laser_wire::agent::AgentId>) -> Self {
248        let agent = agent.into();
249        self.agent = Some(agent);
250        self
251    }
252
253    /// Only sessions whose label or id contains `text`.
254    pub fn text(mut self, text: impl Into<String>) -> Self {
255        self.text = Some(text.into());
256        self
257    }
258
259    /// Continue after the page that returned `cursor`.
260    pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
261        self.cursor = Some(cursor.into());
262        self
263    }
264
265    /// The page size. Zero leaves it to the server.
266    pub fn limit(mut self, limit: u32) -> Self {
267        self.limit = limit;
268        self
269    }
270
271    /// Also count every match.
272    pub fn total(mut self) -> Self {
273        self.total = true;
274        self
275    }
276
277    /// Read the page.
278    pub async fn fetch(self) -> Result<SessionPage, LaserError> {
279        let request = request::SessionList {
280            stream: self.sessions.stream()?.to_owned(),
281            status: self.status,
282            root: self.root.map(Into::into),
283            label_prefix: self.label_prefix,
284            agent: self.agent,
285            text: self.text,
286            cursor: self.cursor,
287            limit: self.limit,
288            want_total: self.total,
289        };
290        match self.sessions.read(AGDX_SESSION_LIST_CODE, &request).await? {
291            SessionOutcome::Page(page) => Ok(page),
292            _ => Err(unexpected("list")),
293        }
294    }
295}
296
297/// A session events read. Build it with [`Sessions::events`].
298#[must_use = "a session events read does nothing until fetch().await"]
299pub struct SessionEventsRequest {
300    sessions: Sessions,
301    id: ConversationId,
302    cursor: Option<String>,
303    limit: u32,
304    fixed_frontier: bool,
305}
306
307impl SessionEventsRequest {
308    /// Continue after the page that returned `cursor`.
309    pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
310        self.cursor = Some(cursor.into());
311        self
312    }
313
314    /// The page size. Zero leaves it to the server.
315    pub fn limit(mut self, limit: u32) -> Self {
316        self.limit = limit;
317        self
318    }
319
320    /// Pin the fold frontier of the first page, for a historical walk.
321    pub fn fixed_frontier(mut self) -> Self {
322        self.fixed_frontier = true;
323        self
324    }
325
326    /// Read the page.
327    pub async fn fetch(self) -> Result<SessionEventsPage, LaserError> {
328        let request = request::SessionEvents {
329            stream: self.sessions.stream()?.to_owned(),
330            id: self.id.into(),
331            cursor: self.cursor,
332            limit: self.limit,
333            fixed_frontier: self.fixed_frontier,
334        };
335        match self
336            .sessions
337            .read(AGDX_SESSION_EVENTS_CODE, &request)
338            .await?
339        {
340            SessionOutcome::Events(page) => Ok(page),
341            _ => Err(unexpected("events")),
342        }
343    }
344}
345
346/// What a [`SessionWatch`] reports.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub enum SessionChange {
349    /// These sessions changed, each named once.
350    Changed(Vec<laser_wire::agent::ConversationId>),
351    /// The retained feed cannot identify every changed session. List the
352    /// sessions again to catch up.
353    Resync,
354}
355
356/// A follower of one stream's session changes. Build it with
357/// [`Sessions::watch`].
358pub struct SessionWatch {
359    sessions: Sessions,
360    poll_every: Duration,
361    after: u64,
362    // The change series `after` belongs to. Sequence numbers from another
363    // node or a restored index never continue this cursor.
364    series: Option<u64>,
365    ready: VecDeque<SessionChange>,
366}
367
368impl SessionWatch {
369    /// The next change, waiting until one lands.
370    pub async fn next(&mut self) -> Result<SessionChange, LaserError> {
371        loop {
372            if let Some(change) = self.ready.pop_front() {
373                return Ok(change);
374            }
375            let page = match self.sessions.changes_in(self.after, 0, self.series).await {
376                Ok(page) => page,
377                // A node that has not folded this stream far enough yet,
378                // typically the one a move just reached, answers Stale. The
379                // watch waits for it as it waits for a new change.
380                Err(error) if error.code() == crate::query::ResultCode::Stale => {
381                    tokio::time::sleep(self.poll_every).await;
382                    continue;
383                }
384                Err(error) => return Err(error),
385            };
386            if page.series != 0 {
387                self.series = Some(page.series);
388            }
389            if page.resync {
390                self.after = page.floor;
391                return Ok(SessionChange::Resync);
392            }
393            if page.rows.is_empty() {
394                tokio::time::sleep(self.poll_every).await;
395                continue;
396            }
397            // A truncated row does not name every changed session. The
398            // caller lists again after the resync, which covers this page, so
399            // the cursor moves past it instead of resyncing forever.
400            if page.rows.iter().any(|row| row.truncated) {
401                if let Some(seq) = page.rows.iter().map(|row| row.seq).max() {
402                    self.after = self.after.max(seq);
403                }
404                return Ok(SessionChange::Resync);
405            }
406            let mut seen = BTreeSet::new();
407            let mut changed = Vec::new();
408            for row in &page.rows {
409                self.after = self.after.max(row.seq);
410                for session in &row.sessions {
411                    if seen.insert(*session) {
412                        changed.push(*session);
413                    }
414                }
415            }
416            self.ready.push_back(SessionChange::Changed(changed));
417        }
418    }
419}
420
421impl Laser {
422    /// Read the one record `at` names, checking that the topic still has the
423    /// generation the reference recorded and that the returned record sits at
424    /// the named offset. `None` when the record is gone or the topic was
425    /// recreated. Only a message reference names a log record.
426    pub async fn read_at(&self, at: &SourceRef) -> Result<Option<ContextMessage>, LaserError> {
427        let SourceRef::Message {
428            stream,
429            topic,
430            partition,
431            offset,
432            generation,
433            ..
434        } = at
435        else {
436            return Err(LaserError::Invalid(
437                "only a message reference names a log record".to_owned(),
438            ));
439        };
440        let client = self.client();
441        let stream_id = Identifier::numeric(*stream)?;
442        let topic_id = Identifier::numeric(*topic)?;
443        let consumer = Consumer::new(Identifier::named("laser-read-at")?);
444        let Some((source, message)) = read_at_checked(
445            *topic,
446            *generation,
447            || async {
448                Ok(client
449                    .get_topic(&stream_id, &topic_id)
450                    .await?
451                    .map(|details| ReadAtSource {
452                        id: details.id,
453                        generation: details.created_at.as_micros(),
454                        name: details.name,
455                    }))
456            },
457            || async {
458                let polled = client
459                    .poll_messages(
460                        &stream_id,
461                        &topic_id,
462                        Some(*partition),
463                        &consumer,
464                        &PollingStrategy::offset(*offset),
465                        1,
466                        false,
467                    )
468                    .await?;
469                Ok(polled
470                    .messages
471                    .into_iter()
472                    .find(|message| message.header.offset == *offset))
473            },
474        )
475        .await?
476        else {
477            return Ok(None);
478        };
479        let (provenance, envelope) = crate::agent::provenance_and_envelope(&message)?;
480        Ok(Some(ContextMessage {
481            id: crate::types::MessageId::new(*partition, *offset),
482            provenance,
483            payload: message.payload.to_vec(),
484            envelope,
485            topic: source.name,
486            timestamp_micros: message.header.timestamp,
487            stream_id: *stream,
488            topic_id: *topic,
489        }))
490    }
491}
492
493#[derive(Debug, PartialEq, Eq)]
494struct ReadAtSource {
495    id: u32,
496    generation: u64,
497    name: String,
498}
499
500async fn read_at_checked<T, M, MF, P, PF>(
501    topic: u32,
502    generation: Option<u64>,
503    mut metadata: M,
504    poll: P,
505) -> Result<Option<(ReadAtSource, T)>, LaserError>
506where
507    M: FnMut() -> MF,
508    MF: std::future::Future<Output = Result<Option<ReadAtSource>, LaserError>>,
509    P: FnOnce() -> PF,
510    PF: std::future::Future<Output = Result<Option<T>, LaserError>>,
511{
512    let Some(source) = metadata().await? else {
513        return Ok(None);
514    };
515    if source.id != topic || generation.is_some_and(|generation| generation != source.generation) {
516        return Ok(None);
517    }
518    let Some(message) = poll().await? else {
519        return Ok(None);
520    };
521    if metadata().await?.as_ref() != Some(&source) {
522        return Ok(None);
523    }
524    Ok(Some((source, message)))
525}
526
527fn unexpected(read: &str) -> LaserError {
528    LaserError::Protocol(format!("session {read}: unexpected reply shape"))
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use std::sync::Arc;
535    use std::sync::atomic::{AtomicU64, Ordering};
536
537    #[tokio::test]
538    async fn given_a_source_recreated_during_read_at_when_polled_then_should_discard_replacement_bytes()
539     {
540        for expected in [None, Some(10)] {
541            let generation = Arc::new(AtomicU64::new(10));
542            let result = read_at_checked(
543                2,
544                expected,
545                || {
546                    let generation = generation.load(Ordering::SeqCst);
547                    async move {
548                        Ok(Some(ReadAtSource {
549                            id: 2,
550                            generation,
551                            name: "agent.sessions".to_owned(),
552                        }))
553                    }
554                },
555                || async {
556                    generation.store(11, Ordering::SeqCst);
557                    Ok(Some(b"replacement"))
558                },
559            )
560            .await
561            .expect("read finishes");
562            assert!(
563                result.is_none(),
564                "a reused offset is not the original record"
565            );
566        }
567    }
568
569    #[tokio::test]
570    async fn given_a_source_deleted_during_read_at_when_polled_then_should_discard_the_record() {
571        let generation = AtomicU64::new(10);
572        let result = read_at_checked(
573            2,
574            Some(10),
575            || {
576                let exists = generation.load(Ordering::SeqCst) != 0;
577                async move {
578                    Ok(exists.then(|| ReadAtSource {
579                        id: 2,
580                        generation: 10,
581                        name: "agent.sessions".to_owned(),
582                    }))
583                }
584            },
585            || async {
586                generation.store(0, Ordering::SeqCst);
587                Ok(Some(b"removed"))
588            },
589        )
590        .await
591        .expect("read finishes");
592        assert!(result.is_none(), "a removed source has no retained record");
593    }
594
595    #[tokio::test]
596    async fn given_a_live_source_when_read_at_then_should_return_the_original_record() {
597        for expected in [None, Some(10)] {
598            let result = read_at_checked(
599                2,
600                expected,
601                || async {
602                    Ok(Some(ReadAtSource {
603                        id: 2,
604                        generation: 10,
605                        name: "agent.sessions".to_owned(),
606                    }))
607                },
608                || async { Ok(Some(b"original")) },
609            )
610            .await
611            .expect("read finishes")
612            .expect("the source still exists");
613            assert_eq!(result.1, b"original");
614        }
615    }
616
617    #[tokio::test]
618    async fn given_a_stale_source_when_read_at_then_should_refuse_before_polling() {
619        let result = read_at_checked(
620            2,
621            Some(9),
622            || async {
623                Ok(Some(ReadAtSource {
624                    id: 2,
625                    generation: 10,
626                    name: "agent.sessions".to_owned(),
627                }))
628            },
629            || async { Err::<Option<()>, _>(LaserError::Invalid("unexpected poll".to_owned())) },
630        )
631        .await
632        .expect("read finishes");
633        assert!(result.is_none());
634    }
635}