use concurrent_queue::ConcurrentQueue;
pub use foundation_core::synca::mpp::Receiver;
use foundation_core::synca::mpp::TrackedBroadcaster;
use foundation_core::valtron::Stream;
use foundation_db::traits::DocumentStore;
use foundation_db::StorageResult;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::types::{SessionId, SessionRecord};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageEvent {
Appended {
id: String,
variant: &'static str,
},
Flushed { count: usize },
Error { msg: String },
}
impl MessageEvent {
fn variant_name(record: &SessionRecord) -> &'static str {
match record {
SessionRecord::Conversation { .. } => "conversation",
SessionRecord::WorkingMemory { .. } => "working_memory",
SessionRecord::Observation { .. } => "observation",
SessionRecord::Reflection { .. } => "reflection",
SessionRecord::FailedAction { .. } => "failed_action",
SessionRecord::Summary { .. } => "summary",
SessionRecord::Retracted { .. } => "retracted",
}
}
}
struct MessageInner<D> {
session_id: SessionId,
doc_store: Arc<D>,
write_buffer: ConcurrentQueue<SessionRecord>,
broadcaster: TrackedBroadcaster<MessageEvent>,
flush_threshold: usize,
flush_pending: std::sync::atomic::AtomicBool,
retry: std::sync::Mutex<Option<SessionRecord>>,
}
impl<D: DocumentStore> MessageInner<D> {
fn flush(&self) -> StorageResult<usize> {
if self
.flush_pending
.swap(true, std::sync::atomic::Ordering::SeqCst)
{
return Ok(0); }
let result = self.flush_buffered();
self.flush_pending
.store(false, std::sync::atomic::Ordering::SeqCst);
result
}
fn flush_buffered(&self) -> StorageResult<usize> {
let retry = self
.retry
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
let mut count = 0;
let mut pending = retry
.into_iter()
.chain(std::iter::from_fn(|| self.write_buffer.pop().ok()));
let outcome = loop {
let Some(record) = pending.next() else {
break Ok(count);
};
if let Err(error) = self.write(&record) {
*self
.retry
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(record);
self.broadcaster.broadcast(MessageEvent::Error {
msg: format!("flushing session {}: {error}", self.session_id),
});
break Err(error);
}
count += 1;
};
if count > 0 {
self.broadcaster.broadcast(MessageEvent::Flushed { count });
}
outcome
}
fn write(&self, record: &SessionRecord) -> StorageResult<()> {
let key = self.session_id.to_string();
match record {
SessionRecord::Conversation { message } => {
self.doc_store
.append_with_id(&key, &message.id().to_string(), record.clone())?;
}
SessionRecord::WorkingMemory { .. }
| SessionRecord::Observation { .. }
| SessionRecord::Reflection { .. } => {
self.doc_store.append_promotable(&key, record.clone())?;
}
_ => {
self.doc_store.append(&key, record.clone())?;
}
}
Ok(())
}
fn unflushed(&self) -> usize {
let held = usize::from(
self.retry
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some(),
);
self.write_buffer.len() + held
}
fn should_flush(&self) -> bool {
self.write_buffer.len() >= self.flush_threshold
}
}
pub struct MessageApi<D> {
inner: Arc<MessageInner<D>>,
}
impl<D> Clone for MessageApi<D> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl<D> MessageApi<D> {
pub fn new(session_id: SessionId, doc_store: D) -> Self {
Self::with_config(session_id, doc_store, 50, 256)
}
pub fn from_shared(session_id: SessionId, doc_store: Arc<D>) -> Self {
Self::shared_with_config(session_id, doc_store, 50, 256)
}
pub fn with_config(
session_id: SessionId,
doc_store: D,
flush_threshold: usize,
subscriber_capacity: usize,
) -> Self {
Self::shared_with_config(
session_id,
Arc::new(doc_store),
flush_threshold,
subscriber_capacity,
)
}
pub fn shared_with_config(
session_id: SessionId,
doc_store: Arc<D>,
flush_threshold: usize,
subscriber_capacity: usize,
) -> Self {
Self {
inner: Arc::new(MessageInner {
session_id,
doc_store,
write_buffer: ConcurrentQueue::unbounded(),
broadcaster: TrackedBroadcaster::new(subscriber_capacity),
flush_threshold,
flush_pending: std::sync::atomic::AtomicBool::new(false),
retry: std::sync::Mutex::new(None),
}),
}
}
#[must_use]
pub fn subscribe(&self) -> Receiver<MessageEvent> {
self.inner.broadcaster.subscribe()
}
}
impl<D: DocumentStore> MessageApi<D> {
#[must_use]
pub fn append(&self, record: SessionRecord) -> String {
let id = match &record {
SessionRecord::Conversation { message } => message.id().to_string(),
_ => foundation_compact::ids::new_scru128_string(),
};
let variant = MessageEvent::variant_name(&record);
self.inner
.write_buffer
.push(record)
.expect("unbounded queue never fails");
self.inner.broadcaster.broadcast(MessageEvent::Appended {
id: id.clone(),
variant,
});
if self.inner.should_flush() {
if let Err(error) = self.inner.flush() {
tracing::warn!(
session = %self.inner.session_id,
%error,
"threshold flush failed; records stay buffered for the next flush"
);
}
}
id
}
pub fn flush(&self) -> StorageResult<usize> {
self.inner.flush()
}
#[must_use]
pub fn unflushed(&self) -> usize {
self.inner.unflushed()
}
pub fn recent(&self, n: usize) -> StorageResult<Vec<SessionRecord>> {
self.inner.flush()?;
let docs = self
.inner
.doc_store
.scan_documents(&self.inner.session_id.to_string(), n)?;
docs.into_iter()
.map(|d| serde_json::from_str::<SessionRecord>(&d.content))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| foundation_db::StorageError::Deserialization(e.to_string()))
}
pub fn all(&self) -> StorageResult<Vec<SessionRecord>> {
self.inner.flush()?;
let stream = self
.inner
.doc_store
.scan_all::<SessionRecord>(&self.inner.session_id.to_string())?;
let mut records = Vec::new();
for item in stream {
if let Stream::Next(result) = item {
records.push(result?);
}
}
Ok(records)
}
pub fn scan_from(&self, from_id: &str, n: usize) -> StorageResult<Vec<SessionRecord>> {
self.inner.flush()?;
let docs = self.inner.doc_store.scan_documents_from(
&self.inner.session_id.to_string(),
from_id,
n,
)?;
docs.into_iter()
.map(|d| serde_json::from_str::<SessionRecord>(&d.content))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| foundation_db::StorageError::Deserialization(e.to_string()))
}
pub fn clear(&self) -> StorageResult<u64> {
self.inner.flush()?;
self.inner
.doc_store
.delete_all(&self.inner.session_id.to_string())
}
}