| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| use crate::{EventLog, message_admissions::PendingExecution}; | |
| use crate::{StoreError, message_admissions::PendingMessageAdmission}; | |
| use maka_agent_facts::{ | |
| event::Invocation, | |
| input::DeliveredMessage, | |
| message::{MessageDisposition, Placement, RootSourceMessage}, | |
| }; | |
| use std::sync::{Arc, atomic::Ordering}; | |
| use std::{sync::atomic::AtomicUsize, time::Duration}; | |
| use tokio::sync::oneshot; | |
| pub(super) fn admission(session: &str) -> PendingMessageAdmission { | |
| PendingMessageAdmission { | |
| execution: PendingExecution { | |
| owner: "host".into(), | |
| basis: serde_json::Value::Null, | |
| }, | |
| invocation: Invocation { | |
| session_id: session.into(), | |
| turn_id: format!("turn-{session}"), | |
| run_id: format!("run-{session}"), | |
| invocation_id: format!("invocation-{session}"), | |
| }, | |
| steering_invocation: None, | |
| required_tools: Default::default(), | |
| admitted_at: 1, | |
| source: RootSourceMessage { | |
| unprepared_content: "hello".into(), | |
| message: DeliveredMessage { | |
| origin: None, | |
| message_id: format!("message-{session}"), | |
| content: "hello".into(), | |
| submitted_content_digest: format!("sha256:{}", "a".repeat(64)), | |
| }, | |
| submitted_placement: Placement::CurrentTurn, | |
| disposition: MessageDisposition::TurnStarted, | |
| submitted_intent: None, | |
| }, | |
| } | |
| } | |
| async fn open(path: &std::path::Path) -> Arc<EventLog> { | |
| let log = Arc::new(EventLog::open(path).await.unwrap()); | |
| for session in ["a", "b"] { | |
| log.create_session(session, session, &serde_json::json!({}), 0) | |
| .await | |
| .unwrap(); | |
| } | |
| log | |
| } | |
| pub(super) async fn gate( | |
| log: &Arc<EventLog>, | |
| ) -> (oneshot::Sender<()>, tokio::task::JoinHandle<()>) { | |
| let (entered, reached) = oneshot::channel(); | |
| let (release, wait) = oneshot::channel(); | |
| let log = log.clone(); | |
| let task = tokio::spawn(async move { | |
| log.connection | |
| .run(move |_| { | |
| Box::pin(async move { | |
| entered.send(()).unwrap(); | |
| wait.await.unwrap(); | |
| Ok(()) | |
| }) | |
| }) | |
| .await | |
| .unwrap(); | |
| }); | |
| tokio::time::timeout(Duration::from_secs(5), reached) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| (release, task) | |
| } | |
| pub(super) async fn hook(log: &EventLog, callback: impl FnMut() -> bool + Send + 'static) { | |
| log.connection | |
| .run(move |db| { | |
| Box::pin(async move { | |
| db.lock_handle().await?.set_commit_hook(callback); | |
| Ok(()) | |
| }) | |
| }) | |
| .await | |
| .unwrap(); | |
| } | |
| fn stored(path: &std::path::Path) -> Vec<String> { | |
| rusqlite::Connection::open(path) | |
| .unwrap() | |
| .prepare("SELECT session_id FROM message_admissions ORDER BY session_id") | |
| .unwrap() | |
| .query_map([], |row| row.get(0)) | |
| .unwrap() | |
| .collect::<Result<_, _>>() | |
| .unwrap() | |
| } | |
| #[tokio::test] | |
| async fn neighbors_share_commit_preserving_replay_and_independent_rejection() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let path = dir.path().join("events.sqlite"); | |
| let log = open(&path).await; | |
| let count = Arc::new(AtomicUsize::new(0)); | |
| let seen = count.clone(); | |
| hook(&log, move || { | |
| seen.fetch_add(1, Ordering::SeqCst); | |
| true | |
| }) | |
| .await; | |
| let failures = log.connection.writer().failures(); | |
| let (release, gate) = gate(&log).await; | |
| let mut changed = admission("a"); | |
| changed.admitted_at = 2; | |
| let mut a = Box::pin(log.admit_message(admission("a"))); | |
| let mut missing = Box::pin(log.admit_message(admission("missing"))); | |
| let mut replay = Box::pin(log.admit_message(admission("a"))); | |
| let mut conflict = Box::pin(log.admit_message(changed)); | |
| let mut b = Box::pin(log.admit_message(admission("b"))); | |
| assert!(futures_util::poll!(&mut a).is_pending()); | |
| assert!(futures_util::poll!(&mut missing).is_pending()); | |
| assert!(futures_util::poll!(&mut replay).is_pending()); | |
| assert!(futures_util::poll!(&mut conflict).is_pending()); | |
| assert!(futures_util::poll!(&mut b).is_pending()); | |
| release.send(()).unwrap(); | |
| gate.await.unwrap(); | |
| assert_eq!(a.await.unwrap(), admission("a")); | |
| assert!(matches!(missing.await, Err(StoreError::SessionNotFound))); | |
| assert_eq!(replay.await.unwrap(), admission("a")); | |
| assert!(matches!( | |
| conflict.await, | |
| Err(StoreError::InvalidTransition(_)) | |
| )); | |
| assert_eq!(b.await.unwrap(), admission("b")); | |
| assert_eq!(count.load(Ordering::SeqCst), 1); | |
| assert_eq!(log.connection.writer().failures() - failures, 2); | |
| assert_eq!(stored(&path), ["a", "b"]); | |
| assert_eq!(log.message_queue_revision("a").await.unwrap(), 1); | |
| let mut commits = log.subscribe_commits(); | |
| commits.borrow_and_update(); | |
| log.admit_message(admission("a")).await.unwrap(); | |
| assert!(!commits.has_changed().unwrap()); | |
| assert_eq!(count.load(Ordering::SeqCst), 1); | |
| log.connection.shutdown().await.unwrap(); | |
| let reopened = EventLog::open(&path).await.unwrap(); | |
| assert_eq!( | |
| reopened.pending_messages("a").await.unwrap(), | |
| [admission("a")] | |
| ); | |
| assert_eq!( | |
| reopened.pending_messages("b").await.unwrap(), | |
| [admission("b")] | |
| ); | |
| reopened.close().await.unwrap(); | |
| } | |
| #[tokio::test] | |
| async fn late_domain_rejection_rolls_back_pending_and_retention_only_for_that_request() { | |
| use maka_agent_facts::{ | |
| event::{EventWrite, Fact, InvocationInput, InvocationOutcome, RuntimeEvent}, | |
| session::{CopyPurpose, CopyState}, | |
| }; | |
| let dir = tempfile::tempdir().unwrap(); | |
| let path = dir.path().join("events.sqlite"); | |
| let log = open(&path).await; | |
| let seed = admission("a"); | |
| for fact in [ | |
| Fact::InvocationOpened { | |
| configuration: None, | |
| input: InvocationInput::Message { | |
| content: seed.source.message.content.clone(), | |
| source_messages: vec![seed.source.clone()], | |
| request_fingerprint: None, | |
| }, | |
| }, | |
| Fact::InvocationEnded { | |
| outcome: InvocationOutcome::Completed, | |
| }, | |
| ] { | |
| log.append(&EventWrite::plain(RuntimeEvent::new(seed.invocation.clone(), fact)).unwrap()) | |
| .await | |
| .unwrap(); | |
| } | |
| let source = log | |
| .get_session::<serde_json::Value>("a") | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| let copied = log | |
| .copy_session( | |
| crate::sessions::SessionCopy { | |
| source_session_id: "a".into(), | |
| target_session_id: "c".into(), | |
| expected_source_revision: source.revision, | |
| purpose: CopyPurpose::Revision { | |
| turn_id: seed.invocation.turn_id, | |
| }, | |
| }, | |
| &serde_json::json!({}), | |
| 0, | |
| ) | |
| .await | |
| .unwrap(); | |
| let crate::sessions::SessionCopyResult::Committed(copied) = copied else { | |
| panic!("copy conflict") | |
| }; | |
| assert_eq!(copied.copy_state, Some(CopyState::Preparing)); | |
| let exhausted = maka_agent_facts::configuration::validation::MAX_SAFE_INTEGER; | |
| log.connection | |
| .run(move |db| { | |
| Box::pin(async move { | |
| sqlx::query( | |
| "INSERT INTO message_queue_state(session_id, revision) VALUES ('c', ?)", | |
| ) | |
| .bind(exhausted as i64) | |
| .execute(db) | |
| .await?; | |
| Ok(()) | |
| }) | |
| }) | |
| .await | |
| .unwrap(); | |
| let count = Arc::new(AtomicUsize::new(0)); | |
| let seen = count.clone(); | |
| hook(&log, move || { | |
| seen.fetch_add(1, Ordering::SeqCst); | |
| true | |
| }) | |
| .await; | |
| let (release, gate) = gate(&log).await; | |
| let mut next = admission("a"); | |
| next.source.message.message_id = "next-message-a".into(); | |
| next.invocation.turn_id = "next-turn-a".into(); | |
| next.invocation.run_id = "next-run-a".into(); | |
| next.invocation.invocation_id = "next-invocation-a".into(); | |
| let mut a = Box::pin(log.admit_message(next.clone())); | |
| let mut c = Box::pin(log.admit_message(admission("c"))); | |
| let mut b = Box::pin(log.admit_message(admission("b"))); | |
| assert!(futures_util::poll!(&mut a).is_pending()); | |
| assert!(futures_util::poll!(&mut c).is_pending()); | |
| assert!(futures_util::poll!(&mut b).is_pending()); | |
| release.send(()).unwrap(); | |
| gate.await.unwrap(); | |
| assert_eq!(a.await.unwrap(), next); | |
| assert!( | |
| matches!(c.await, Err(StoreError::InvalidTransition(message)) if message == "message queue revision exhausted") | |
| ); | |
| assert_eq!(b.await.unwrap(), admission("b")); | |
| assert_eq!(count.load(Ordering::SeqCst), 1); | |
| assert_eq!(stored(&path), ["a", "b"]); | |
| assert_eq!(log.message_queue_revision("c").await.unwrap(), exhausted); | |
| let rejected = log | |
| .get_session::<serde_json::Value>("c") | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| assert_eq!(rejected.copy_state, Some(CopyState::Preparing)); | |
| assert_eq!(rejected.revision, copied.revision); | |
| log.connection.shutdown().await.unwrap(); | |
| } | |
| #[tokio::test] | |
| async fn failed_commit_never_acknowledges_or_executes_following_control() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let path = dir.path().join("events.sqlite"); | |
| let log = open(&path).await; | |
| hook(&log, || false).await; | |
| let (release, gate) = gate(&log).await; | |
| let mut a = Box::pin(log.admit_message(admission("a"))); | |
| let mut missing = Box::pin(log.admit_message(admission("missing"))); | |
| let mut b = Box::pin(log.admit_message(admission("b"))); | |
| assert!(futures_util::poll!(&mut a).is_pending()); | |
| assert!(futures_util::poll!(&mut missing).is_pending()); | |
| assert!(futures_util::poll!(&mut b).is_pending()); | |
| let ran = Arc::new(AtomicUsize::new(0)); | |
| let called = ran.clone(); | |
| let mut control = Box::pin(log.connection.run(move |_| { | |
| Box::pin(async move { | |
| called.fetch_add(1, Ordering::SeqCst); | |
| Ok(()) | |
| }) | |
| })); | |
| assert!(futures_util::poll!(&mut control).is_pending()); | |
| release.send(()).unwrap(); | |
| gate.await.unwrap(); | |
| for error in [a.await.unwrap_err(), b.await.unwrap_err()] { | |
| let StoreError::CommitUnknown(error) = error else { | |
| panic!("{error:?}") | |
| }; | |
| let database = error.as_database_error().unwrap(); | |
| assert_eq!( | |
| database.code().unwrap(), | |
| rusqlite::ffi::SQLITE_CONSTRAINT_COMMITHOOK.to_string() | |
| ); | |
| assert!(database.as_error().source().is_some()); | |
| } | |
| assert!(matches!(missing.await, Err(StoreError::SessionNotFound))); | |
| assert!(matches!(control.await, Err(StoreError::OperationUnknown))); | |
| assert_eq!(ran.load(Ordering::SeqCst), 0); | |
| log.connection.shutdown().await.unwrap(); | |
| assert!(stored(&path).is_empty()); | |
| } | |
| #[tokio::test] | |
| async fn receipts_and_notifications_wait_for_real_commit_even_when_waiter_drops() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let path = dir.path().join("events.sqlite"); | |
| let log = open(&path).await; | |
| let (entered, reached) = oneshot::channel(); | |
| let mut entered = Some(entered); | |
| let (release_commit, wait) = std::sync::mpsc::channel(); | |
| hook(&log, move || { | |
| entered.take().unwrap().send(()).unwrap(); | |
| wait.recv_timeout(Duration::from_secs(10)).unwrap(); | |
| true | |
| }) | |
| .await; | |
| let (release, gate) = gate(&log).await; | |
| let mut a = Box::pin(log.admit_message(admission("a"))); | |
| let mut b = Box::pin(log.admit_message(admission("b"))); | |
| assert!(futures_util::poll!(&mut a).is_pending()); | |
| assert!(futures_util::poll!(&mut b).is_pending()); | |
| let mut commits = log.subscribe_commits(); | |
| commits.borrow_and_update(); | |
| release.send(()).unwrap(); | |
| gate.await.unwrap(); | |
| tokio::time::timeout(Duration::from_secs(5), reached) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| assert!(futures_util::poll!(&mut a).is_pending()); | |
| assert!(futures_util::poll!(&mut b).is_pending()); | |
| assert!(!commits.has_changed().unwrap()); | |
| assert!(stored(&path).is_empty()); | |
| drop(a); | |
| let mut close = Box::pin(log.connection.shutdown()); | |
| assert!(futures_util::poll!(&mut close).is_pending()); | |
| release_commit.send(()).unwrap(); | |
| assert_eq!(b.await.unwrap(), admission("b")); | |
| close.await.unwrap(); | |
| assert_eq!(stored(&path), ["a", "b"]); | |
| } | |
| #[tokio::test] | |
| async fn intervening_read_and_shutdown_preserve_original_command_order() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let path = dir.path().join("events.sqlite"); | |
| let log = open(&path).await; | |
| let count = Arc::new(AtomicUsize::new(0)); | |
| let seen = count.clone(); | |
| hook(&log, move || { | |
| seen.fetch_add(1, Ordering::SeqCst); | |
| true | |
| }) | |
| .await; | |
| let (release, gate) = gate(&log).await; | |
| let mut a = Box::pin(log.admit_message(admission("a"))); | |
| assert!(futures_util::poll!(&mut a).is_pending()); | |
| let mut read = Box::pin(log.pending_messages("b")); | |
| assert!(futures_util::poll!(&mut read).is_pending()); | |
| let mut b = Box::pin(log.admit_message(admission("b"))); | |
| assert!(futures_util::poll!(&mut b).is_pending()); | |
| let mut close = Box::pin(log.connection.shutdown()); | |
| assert!(futures_util::poll!(&mut close).is_pending()); | |
| release.send(()).unwrap(); | |
| gate.await.unwrap(); | |
| a.await.unwrap(); | |
| assert!(read.await.unwrap().is_empty()); | |
| b.await.unwrap(); | |
| close.await.unwrap(); | |
| assert_eq!(count.load(Ordering::SeqCst), 2); | |
| assert_eq!(stored(&path), ["a", "b"]); | |
| } | |
| #[tokio::test] | |
| async fn storage_failure_rolls_back_prepared_neighbors_and_preserves_cause() { | |
| for rollback_fails in [false, true] { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let path = dir.path().join("events.sqlite"); | |
| let log = open(&path).await; | |
| log.connection.run(|db| Box::pin(async move { | |
| sqlx::raw_sql("CREATE TRIGGER admission_fault AFTER INSERT ON message_admissions WHEN NEW.session_id='b' BEGIN SELECT RAISE(ABORT, 'admission storage fault'); END;").execute(db).await?; | |
| Ok(()) | |
| })).await.unwrap(); | |
| if rollback_fails { | |
| forbid_rollback(&log).await; | |
| } | |
| let (release, gate) = gate(&log).await; | |
| let mut a = Box::pin(log.admit_message(admission("a"))); | |
| let mut b = Box::pin(log.admit_message(admission("b"))); | |
| assert!(futures_util::poll!(&mut a).is_pending()); | |
| assert!(futures_util::poll!(&mut b).is_pending()); | |
| let mut read = Box::pin(log.pending_messages("a")); | |
| assert!(futures_util::poll!(&mut read).is_pending()); | |
| release.send(()).unwrap(); | |
| gate.await.unwrap(); | |
| for error in [a.await.unwrap_err(), b.await.unwrap_err()] { | |
| let error = match error { | |
| StoreError::Sqlx(error) if !rollback_fails => error, | |
| StoreError::CommitUnknown(error) if rollback_fails => error, | |
| error => panic!("{error:?}"), | |
| }; | |
| if rollback_fails { | |
| assert!(error.to_string().contains("rollback unconfirmed")); | |
| } | |
| let database = error.as_database_error().unwrap(); | |
| assert_eq!( | |
| database.code().unwrap(), | |
| rusqlite::ffi::SQLITE_CONSTRAINT_TRIGGER.to_string() | |
| ); | |
| assert!(database.as_error().source().is_some()); | |
| assert!(error.to_string().contains("admission storage fault")); | |
| } | |
| if rollback_fails { | |
| assert!(matches!(read.await, Err(StoreError::OperationUnknown))); | |
| } else { | |
| assert!(read.await.unwrap().is_empty()); | |
| } | |
| log.connection.shutdown().await.unwrap(); | |
| assert!(stored(&path).is_empty()); | |
| let reopened = EventLog::open(&path).await.unwrap(); | |
| assert_eq!(reopened.message_queue_revision("a").await.unwrap(), 0); | |
| assert_eq!(reopened.message_queue_revision("b").await.unwrap(), 0); | |
| reopened.close().await.unwrap(); | |
| } | |
| } | |
| #[tokio::test] | |
| async fn singleton_rejection_requires_confirmed_rollback() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let path = dir.path().join("events.sqlite"); | |
| let log = open(&path).await; | |
| forbid_rollback(&log).await; | |
| let error = log.admit_message(admission("missing")).await.unwrap_err(); | |
| assert!(matches!(error, StoreError::CommitUnknown(_)), "{error:?}"); | |
| log.connection.shutdown().await.unwrap(); | |
| assert!(stored(&path).is_empty()); | |
| } | |
| pub(super) async fn forbid_rollback(log: &EventLog) { | |
| unsafe extern "C" fn deny_rollback( | |
| _: *mut std::ffi::c_void, | |
| action: std::ffi::c_int, | |
| operation: *const std::ffi::c_char, | |
| _: *const std::ffi::c_char, | |
| _: *const std::ffi::c_char, | |
| _: *const std::ffi::c_char, | |
| ) -> std::ffi::c_int { | |
| if action == rusqlite::ffi::SQLITE_TRANSACTION && !operation.is_null() { | |
| // SAFETY: SQLite supplies a callback-local NUL-terminated operation. | |
| if unsafe { std::ffi::CStr::from_ptr(operation) }.to_bytes() == b"ROLLBACK" { | |
| return rusqlite::ffi::SQLITE_DENY; | |
| } | |
| } | |
| rusqlite::ffi::SQLITE_OK | |
| } | |
| log.connection | |
| .run(|db| { | |
| Box::pin(async move { | |
| let mut handle = db.lock_handle().await?; | |
| // SAFETY: callback is static, no context is dereferenced; the handle is exclusive. | |
| assert_eq!( | |
| unsafe { | |
| rusqlite::ffi::sqlite3_set_authorizer( | |
| handle.as_raw_handle().as_mut(), | |
| Some(deny_rollback), | |
| std::ptr::null_mut(), | |
| ) | |
| }, | |
| rusqlite::ffi::SQLITE_OK | |
| ); | |
| Ok(()) | |
| }) | |
| }) | |
| .await | |
| .unwrap(); | |
| } |