Skip to main content

aion_store/memory/
assistant.rs

1//! The reference [`AssistantSessionStore`] over [`InMemoryStore`].
2//!
3//! The whole point of the reference implementation is to define the answer the
4//! durable backend must agree with, so the two properties that matter are
5//! written here as plainly as they can be:
6//!
7//! - **the store assigns the index, under one lock.** A transcript is a `Vec`
8//!   and the index is its length at the moment of the push, taken while the map
9//!   is held. Two appenders cannot both read the same length, so they cannot
10//!   both be handed the same index.
11//! - **an append to an unknown session refuses.** The session map is consulted
12//!   before the transcript map is touched, and a missing record is
13//!   [`StoreError::AssistantSessionNotFound`] rather than a transcript with no
14//!   owner.
15
16use std::collections::BTreeMap;
17use std::sync::MutexGuard;
18
19use aion_core::{AssistantSessionId, Payload};
20use async_trait::async_trait;
21use chrono::{DateTime, Utc};
22
23use super::InMemoryStore;
24use crate::StoreError;
25use crate::assistant::{
26    AssistantSessionListing, AssistantSessionRecord, AssistantSessionStore,
27    AssistantTranscriptEvent, UndecodableAssistantSession,
28};
29
30impl InMemoryStore {
31    /// Write assistant-session backend bytes directly for persistence
32    /// conformance testing (poisoned-row listing behaviour), bypassing the
33    /// entity codec.
34    ///
35    /// # Errors
36    ///
37    /// [`StoreError::Serialization`] when `session_id` is empty or whitespace.
38    pub fn write_raw_assistant_session(
39        &self,
40        session_id: &str,
41        bytes: Vec<u8>,
42    ) -> Result<(), StoreError> {
43        if session_id.trim().is_empty() {
44            return Err(StoreError::Serialization(
45                "assistant session id must not be empty".to_owned(),
46            ));
47        }
48        self.lock_assistant_sessions()
49            .insert(session_id.to_owned(), bytes);
50        Ok(())
51    }
52
53    /// Whether a session record exists, without decoding it.
54    ///
55    /// A poisoned row still counts as existing: the session is there, it is
56    /// merely unreadable, and refusing its appends would destroy the very
57    /// transcript an operator would use to work out what happened to it.
58    fn assistant_session_exists(&self, session_id: &AssistantSessionId) -> bool {
59        self.lock_assistant_sessions()
60            .contains_key(&session_id.to_string())
61    }
62}
63
64#[async_trait]
65impl AssistantSessionStore for InMemoryStore {
66    async fn put_assistant_session(
67        &self,
68        record: AssistantSessionRecord,
69    ) -> Result<(), StoreError> {
70        let bytes = record.encode()?;
71        self.lock_assistant_sessions()
72            .insert(record.session_id.to_string(), bytes);
73        Ok(())
74    }
75
76    async fn get_assistant_session(
77        &self,
78        session_id: &AssistantSessionId,
79    ) -> Result<Option<AssistantSessionRecord>, StoreError> {
80        self.lock_assistant_sessions()
81            .get(&session_id.to_string())
82            .map(|bytes| AssistantSessionRecord::decode(bytes))
83            .transpose()
84    }
85
86    async fn list_assistant_sessions(&self) -> Result<AssistantSessionListing, StoreError> {
87        let mut listing = AssistantSessionListing::default();
88        for (session_id, bytes) in self.lock_assistant_sessions().iter() {
89            match AssistantSessionRecord::decode(bytes) {
90                Ok(record) => listing.sessions.push(record),
91                Err(error) => listing.undecodable.push(UndecodableAssistantSession {
92                    session_id: session_id.clone(),
93                    error: error.to_string(),
94                }),
95            }
96        }
97        listing.sort();
98        Ok(listing)
99    }
100
101    async fn append_assistant_transcript_event(
102        &self,
103        session_id: &AssistantSessionId,
104        recorded_at: DateTime<Utc>,
105        payload: Payload,
106    ) -> Result<u64, StoreError> {
107        if !self.assistant_session_exists(session_id) {
108            return Err(StoreError::AssistantSessionNotFound {
109                session_id: session_id.to_string(),
110            });
111        }
112        // ONE lock spans reading the length and pushing at it, which is what
113        // makes the index the store's to give rather than a value two appenders
114        // could both observe.
115        let mut transcripts = self.lock_assistant_transcripts();
116        let events = transcripts.entry(session_id.to_string()).or_default();
117        let index = u64::try_from(events.len()).map_err(|error| {
118            StoreError::Backend(format!(
119                "assistant transcript for {session_id} is longer than a u64 index can name: {error}"
120            ))
121        })?;
122        events.push(AssistantTranscriptEvent {
123            index,
124            recorded_at,
125            payload,
126        });
127        Ok(index)
128    }
129
130    async fn assistant_transcript_head(
131        &self,
132        session_id: &AssistantSessionId,
133    ) -> Result<u64, StoreError> {
134        let head = self
135            .lock_assistant_transcripts()
136            .get(&session_id.to_string())
137            .map_or(0, Vec::len);
138        u64::try_from(head).map_err(|error| {
139            StoreError::Backend(format!(
140                "assistant transcript for {session_id} is longer than a u64 head can name: {error}"
141            ))
142        })
143    }
144
145    async fn put_assistant_default_harness(
146        &self,
147        subject: &str,
148        harness: &str,
149    ) -> Result<(), StoreError> {
150        self.lock_assistant_default_harnesses()
151            .insert(subject.to_owned(), harness.to_owned());
152        Ok(())
153    }
154
155    async fn assistant_default_harness(&self, subject: &str) -> Result<Option<String>, StoreError> {
156        Ok(self
157            .lock_assistant_default_harnesses()
158            .get(subject)
159            .cloned())
160    }
161
162    async fn assistant_transcript(
163        &self,
164        session_id: &AssistantSessionId,
165        after: Option<u64>,
166    ) -> Result<Vec<AssistantTranscriptEvent>, StoreError> {
167        Ok(self
168            .lock_assistant_transcripts()
169            .get(&session_id.to_string())
170            .map(|events| {
171                events
172                    .iter()
173                    .filter(|event| after.is_none_or(|bound| event.index > bound))
174                    .cloned()
175                    .collect()
176            })
177            .unwrap_or_default())
178    }
179}
180
181impl InMemoryStore {
182    /// Poison-tolerant lock over the assistant-session record map.
183    fn lock_assistant_sessions(&self) -> MutexGuard<'_, BTreeMap<String, Vec<u8>>> {
184        self.assistant_sessions
185            .lock()
186            .unwrap_or_else(std::sync::PoisonError::into_inner)
187    }
188
189    /// Poison-tolerant lock over the per-caller last-pick memory.
190    fn lock_assistant_default_harnesses(&self) -> MutexGuard<'_, BTreeMap<String, String>> {
191        self.assistant_default_harnesses
192            .lock()
193            .unwrap_or_else(std::sync::PoisonError::into_inner)
194    }
195
196    /// Poison-tolerant lock over the assistant transcripts.
197    fn lock_assistant_transcripts(
198        &self,
199    ) -> MutexGuard<'_, BTreeMap<String, Vec<AssistantTranscriptEvent>>> {
200        self.assistant_transcripts
201            .lock()
202            .unwrap_or_else(std::sync::PoisonError::into_inner)
203    }
204}