use std::path::Path;
use std::str::FromStr;
use async_trait::async_trait;
use serde_json::Value;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::SqlitePool;
use nexo_tool_meta::admin::processing::{
PendingInbound, ProcessingControlState, ProcessingScope, DEFAULT_PENDING_INBOUNDS_CAP,
};
use super::domains::processing::ProcessingControlStore;
#[derive(Debug, Clone)]
pub struct SqliteProcessingControlStore {
pool: SqlitePool,
pending_cap: usize,
}
impl SqliteProcessingControlStore {
pub async fn open(path: &Path) -> anyhow::Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let path_str = path.display().to_string();
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path_str}"))?
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(2)
.connect_with(opts)
.await?;
sqlx::query("PRAGMA journal_mode=WAL")
.execute(&pool)
.await
.ok();
Self::run_ddl(&pool).await?;
Ok(Self {
pool,
pending_cap: DEFAULT_PENDING_INBOUNDS_CAP,
})
}
pub async fn open_memory() -> anyhow::Result<Self> {
let opts = SqliteConnectOptions::from_str("sqlite::memory:")?;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await?;
Self::run_ddl(&pool).await?;
Ok(Self {
pool,
pending_cap: DEFAULT_PENDING_INBOUNDS_CAP,
})
}
pub fn with_pending_cap(mut self, cap: usize) -> Self {
self.pending_cap = cap;
self
}
async fn run_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS nexo_processing_states (
scope_json TEXT PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS nexo_processing_pending (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_json TEXT NOT NULL,
inbound_json TEXT NOT NULL,
enqueued_at_ms INTEGER NOT NULL
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_nexo_processing_pending_scope
ON nexo_processing_pending(scope_json, id)",
)
.execute(pool)
.await?;
Ok(())
}
}
fn scope_key(scope: &ProcessingScope) -> anyhow::Result<String> {
let v = serde_json::to_value(scope)?;
Ok(canonicalize(&v).to_string())
}
fn canonicalize(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut sorted: Vec<(String, Value)> = map
.iter()
.map(|(k, v)| (k.clone(), canonicalize(v)))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
Value::Object(sorted.into_iter().collect())
}
Value::Array(arr) => Value::Array(arr.iter().map(canonicalize).collect()),
other => other.clone(),
}
}
fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
#[async_trait]
impl ProcessingControlStore for SqliteProcessingControlStore {
async fn get(&self, scope: &ProcessingScope) -> anyhow::Result<ProcessingControlState> {
let key = scope_key(scope)?;
let row: Option<(String,)> =
sqlx::query_as("SELECT state_json FROM nexo_processing_states WHERE scope_json = ?1")
.bind(&key)
.fetch_optional(&self.pool)
.await?;
match row {
None => Ok(ProcessingControlState::AgentActive),
Some((json,)) => Ok(serde_json::from_str(&json)?),
}
}
async fn set(
&self,
scope: ProcessingScope,
state: ProcessingControlState,
) -> anyhow::Result<bool> {
let key = scope_key(&scope)?;
let prev_json: Option<(String,)> =
sqlx::query_as("SELECT state_json FROM nexo_processing_states WHERE scope_json = ?1")
.bind(&key)
.fetch_optional(&self.pool)
.await?;
let prev_state: Option<ProcessingControlState> = match prev_json {
Some((s,)) => Some(serde_json::from_str(&s)?),
None => None,
};
match &state {
ProcessingControlState::AgentActive => {
sqlx::query("DELETE FROM nexo_processing_states WHERE scope_json = ?1")
.bind(&key)
.execute(&self.pool)
.await?;
Ok(prev_state.is_some()
&& prev_state.as_ref() != Some(&ProcessingControlState::AgentActive))
}
other => {
let payload = serde_json::to_string(other)?;
sqlx::query(
"INSERT INTO nexo_processing_states (scope_json, state_json, updated_at_ms) \
VALUES (?1, ?2, ?3) \
ON CONFLICT(scope_json) DO UPDATE SET state_json = excluded.state_json, \
updated_at_ms = excluded.updated_at_ms",
)
.bind(&key)
.bind(&payload)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(prev_state.as_ref() != Some(&state))
}
}
}
async fn clear(&self, scope: &ProcessingScope) -> anyhow::Result<bool> {
let key = scope_key(scope)?;
let res = sqlx::query("DELETE FROM nexo_processing_states WHERE scope_json = ?1")
.bind(&key)
.execute(&self.pool)
.await?;
Ok(res.rows_affected() > 0)
}
async fn push_pending(
&self,
scope: &ProcessingScope,
inbound: PendingInbound,
) -> anyhow::Result<(usize, u32)> {
let key = scope_key(scope)?;
if self.pending_cap == 0 {
return Ok((0, 1));
}
let payload = serde_json::to_string(&inbound)?;
sqlx::query(
"INSERT INTO nexo_processing_pending \
(scope_json, inbound_json, enqueued_at_ms) VALUES (?1, ?2, ?3)",
)
.bind(&key)
.bind(&payload)
.bind(now_ms())
.execute(&self.pool)
.await?;
let mut dropped = 0u32;
loop {
let depth: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM nexo_processing_pending WHERE scope_json = ?1",
)
.bind(&key)
.fetch_one(&self.pool)
.await?;
if (depth as usize) <= self.pending_cap {
return Ok((depth as usize, dropped));
}
sqlx::query(
"DELETE FROM nexo_processing_pending WHERE id = (
SELECT id FROM nexo_processing_pending
WHERE scope_json = ?1
ORDER BY id ASC LIMIT 1
)",
)
.bind(&key)
.execute(&self.pool)
.await?;
dropped = dropped.saturating_add(1);
}
}
async fn drain_pending(&self, scope: &ProcessingScope) -> anyhow::Result<Vec<PendingInbound>> {
let key = scope_key(scope)?;
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT inbound_json FROM nexo_processing_pending \
WHERE scope_json = ?1 ORDER BY id ASC",
)
.bind(&key)
.fetch_all(&self.pool)
.await?;
let mut out: Vec<PendingInbound> = Vec::with_capacity(rows.len());
for (json,) in rows {
match serde_json::from_str::<PendingInbound>(&json) {
Ok(p) => out.push(p),
Err(e) => tracing::warn!(
error = %e,
"processing_sqlite: drain skipped malformed pending row",
),
}
}
sqlx::query("DELETE FROM nexo_processing_pending WHERE scope_json = ?1")
.bind(&key)
.execute(&self.pool)
.await?;
Ok(out)
}
async fn pending_depth(&self, scope: &ProcessingScope) -> anyhow::Result<usize> {
let key = scope_key(scope)?;
let depth: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM nexo_processing_pending WHERE scope_json = ?1",
)
.bind(&key)
.fetch_one(&self.pool)
.await?;
Ok(depth.max(0) as usize)
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn convo() -> ProcessingScope {
ProcessingScope::Conversation {
agent_id: "ana".into(),
channel: "whatsapp".into(),
account_id: "acc".into(),
contact_id: "wa.55".into(),
mcp_channel_source: None,
}
}
fn other_convo() -> ProcessingScope {
ProcessingScope::Conversation {
agent_id: "bob".into(),
channel: "whatsapp".into(),
account_id: "acc".into(),
contact_id: "wa.99".into(),
mcp_channel_source: None,
}
}
fn pending(body: &str) -> PendingInbound {
PendingInbound {
message_id: Some(Uuid::new_v4()),
from_contact_id: "wa.55".into(),
body: body.into(),
timestamp_ms: now_ms() as u64,
source_plugin: "whatsapp".into(),
}
}
#[tokio::test]
async fn missing_scope_defaults_to_agent_active() {
let store = SqliteProcessingControlStore::open_memory().await.unwrap();
let state = store.get(&convo()).await.unwrap();
assert!(matches!(state, ProcessingControlState::AgentActive));
}
#[tokio::test]
async fn set_paused_then_get_round_trips_through_sqlite() {
let store = SqliteProcessingControlStore::open_memory().await.unwrap();
let changed = store
.set(
convo(),
ProcessingControlState::PausedByOperator {
scope: convo(),
reason: Some("smoke".into()),
paused_at_ms: 1_700_000_000_000,
operator_token_hash: "h".into(),
},
)
.await
.unwrap();
assert!(changed);
let state = store.get(&convo()).await.unwrap();
match state {
ProcessingControlState::PausedByOperator { reason, .. } => {
assert_eq!(reason.as_deref(), Some("smoke"));
}
other => panic!("expected paused, got {other:?}"),
}
}
#[tokio::test]
async fn set_idempotent_returns_changed_false_on_second_call() {
let store = SqliteProcessingControlStore::open_memory().await.unwrap();
let s = ProcessingControlState::PausedByOperator {
scope: convo(),
reason: None,
paused_at_ms: 1,
operator_token_hash: "h".into(),
};
assert!(store.set(convo(), s.clone()).await.unwrap());
assert!(
!store.set(convo(), s).await.unwrap(),
"second set must be no-op"
);
}
#[tokio::test]
async fn set_agent_active_removes_row() {
let store = SqliteProcessingControlStore::open_memory().await.unwrap();
store
.set(
convo(),
ProcessingControlState::PausedByOperator {
scope: convo(),
reason: None,
paused_at_ms: 1,
operator_token_hash: "h".into(),
},
)
.await
.unwrap();
let changed = store
.set(convo(), ProcessingControlState::AgentActive)
.await
.unwrap();
assert!(
changed,
"transition Paused → AgentActive must report changed"
);
let state = store.get(&convo()).await.unwrap();
assert!(matches!(state, ProcessingControlState::AgentActive));
}
#[tokio::test]
async fn clear_returns_true_only_when_row_existed() {
let store = SqliteProcessingControlStore::open_memory().await.unwrap();
assert!(!store.clear(&convo()).await.unwrap());
store
.set(
convo(),
ProcessingControlState::PausedByOperator {
scope: convo(),
reason: None,
paused_at_ms: 1,
operator_token_hash: "h".into(),
},
)
.await
.unwrap();
assert!(store.clear(&convo()).await.unwrap());
}
#[tokio::test]
async fn push_pending_caps_at_pending_cap_and_evicts_fifo() {
let store = SqliteProcessingControlStore::open_memory()
.await
.unwrap()
.with_pending_cap(3);
for i in 0..5 {
let (depth, dropped) = store
.push_pending(&convo(), pending(&format!("msg-{i}")))
.await
.unwrap();
if i < 3 {
assert_eq!(dropped, 0);
assert_eq!(depth, (i + 1) as usize);
} else {
assert_eq!(dropped, 1, "msg-{i} must evict one oldest");
assert_eq!(depth, 3);
}
}
let drained = store.drain_pending(&convo()).await.unwrap();
let bodies: Vec<String> = drained.into_iter().map(|p| p.body).collect();
assert_eq!(bodies, vec!["msg-2", "msg-3", "msg-4"]);
}
#[tokio::test]
async fn push_with_zero_cap_reports_drop_per_push() {
let store = SqliteProcessingControlStore::open_memory()
.await
.unwrap()
.with_pending_cap(0);
let (depth, dropped) = store.push_pending(&convo(), pending("x")).await.unwrap();
assert_eq!(depth, 0);
assert_eq!(dropped, 1);
assert!(store.drain_pending(&convo()).await.unwrap().is_empty());
}
#[tokio::test]
async fn drain_clears_queue_atomically() {
let store = SqliteProcessingControlStore::open_memory().await.unwrap();
store.push_pending(&convo(), pending("a")).await.unwrap();
store.push_pending(&convo(), pending("b")).await.unwrap();
let first = store.drain_pending(&convo()).await.unwrap();
assert_eq!(first.len(), 2);
let second = store.drain_pending(&convo()).await.unwrap();
assert!(second.is_empty(), "second drain must be empty");
assert_eq!(store.pending_depth(&convo()).await.unwrap(), 0);
}
#[tokio::test]
async fn pending_queues_are_isolated_per_scope() {
let store = SqliteProcessingControlStore::open_memory().await.unwrap();
store
.push_pending(&convo(), pending("ana-1"))
.await
.unwrap();
store
.push_pending(&other_convo(), pending("bob-1"))
.await
.unwrap();
store
.push_pending(&convo(), pending("ana-2"))
.await
.unwrap();
assert_eq!(store.pending_depth(&convo()).await.unwrap(), 2);
assert_eq!(store.pending_depth(&other_convo()).await.unwrap(), 1);
let drained = store.drain_pending(&convo()).await.unwrap();
assert_eq!(drained.len(), 2);
assert_eq!(store.pending_depth(&other_convo()).await.unwrap(), 1);
}
#[tokio::test]
async fn state_survives_pool_round_trip_to_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("processing.db");
let store = SqliteProcessingControlStore::open(&path).await.unwrap();
store
.set(
convo(),
ProcessingControlState::PausedByOperator {
scope: convo(),
reason: Some("restart-test".into()),
paused_at_ms: 42,
operator_token_hash: "h".into(),
},
)
.await
.unwrap();
store
.push_pending(&convo(), pending("queued"))
.await
.unwrap();
drop(store);
let store2 = SqliteProcessingControlStore::open(&path).await.unwrap();
match store2.get(&convo()).await.unwrap() {
ProcessingControlState::PausedByOperator { reason, .. } => {
assert_eq!(reason.as_deref(), Some("restart-test"));
}
other => panic!("expected paused after restart, got {other:?}"),
}
let drained = store2.drain_pending(&convo()).await.unwrap();
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].body, "queued");
}
#[tokio::test]
async fn ddl_idempotent_on_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("processing.db");
let _w1 = SqliteProcessingControlStore::open(&path).await.unwrap();
let _w2 = SqliteProcessingControlStore::open(&path).await.unwrap();
}
}