blob: 6a98bef2ea2929a6148a4e142fd2fc36014bc084 [file]
/*
* 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 maka_event_log::{EventLog, StoreError};
use serde_json::json;
#[tokio::test]
async fn execution_receipts_commit_with_work_and_survive_delivery_restarts_and_lost_replies() {
use futures_util::FutureExt;
use maka_agent_facts::event::EventWrite;
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::InvocationInput;
use maka_agent_facts::event::InvocationOutcome;
use maka_agent_facts::event::RuntimeEvent;
use maka_agent_ports::execution::Submit;
use maka_capabilities::storage::Namespace;
use maka_plugin_core::scope::Scope;
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("receipts.sqlite");
let log = EventLog::open(&path).await.unwrap();
log.create_session("session", "create", &json!({}), 1)
.await
.unwrap();
let namespace = Namespace::new("graph", Scope::Profile).unwrap();
let request = Submit {
orchestration_mode: None,
operation_id: "graph-1:node-a:attempt-1".into(),
session_id: "session".into(),
content: "Implement the node".into(),
};
let db = rusqlite::Connection::open(&path).unwrap();
db.execute_batch(
"CREATE TRIGGER reject_receipt BEFORE INSERT ON plugin_execution_receipts
BEGIN SELECT RAISE(ABORT, 'receipt fault'); END;",
)
.unwrap();
assert!(
log.admit_plugin_execution(&namespace, request.clone(), pending(&request))
.await
.is_err()
);
assert!(log.pending_messages("session").await.unwrap().is_empty());
assert!(
log.plugin_execution_receipt(&namespace, &request.operation_id)
.await
.unwrap()
.is_none()
);
db.execute_batch("DROP TRIGGER reject_receipt").unwrap();
// Lose the receipt waiter after the SQL owner has accepted the command.
assert!(
log.admit_plugin_execution(&namespace, request.clone(), pending(&request))
.now_or_never()
.is_none()
);
let receipt = log
.admit_plugin_execution(&namespace, request.clone(), pending(&request))
.await
.unwrap();
assert_eq!(log.pending_messages("session").await.unwrap().len(), 1);
let admission = log.pending_messages("session").await.unwrap().remove(0);
assert_eq!(admission.invocation, receipt.invocation);
let writes = [
Fact::InvocationOpened {
configuration: None,
input: InvocationInput::Message {
content: admission.source.message.content.clone(),
request_fingerprint: None,
source_messages: vec![admission.source],
},
},
Fact::InvocationEnded {
outcome: InvocationOutcome::Completed,
},
]
.into_iter()
.map(|fact| EventWrite::plain(RuntimeEvent::new(receipt.invocation.clone(), fact)).unwrap())
.collect::<Vec<_>>();
log.append_batch(&writes[..1]).await.unwrap();
use maka_agent_facts::interaction::InteractionOutcome;
use maka_agent_facts::interaction::InteractionQuestion;
use maka_agent_facts::interaction::InteractionRecord;
use maka_agent_facts::interaction::InteractionRequest;
use maka_agent_facts::interaction::QuestionOption;
let mut question = InteractionRecord {
session_id: receipt.invocation.session_id.clone(),
turn_id: receipt.invocation.turn_id.clone(),
run_id: receipt.invocation.run_id.clone(),
request_id: "question-one".into(),
created_at: 1,
request: InteractionRequest::Question {
tool_use_id: "question-tool".into(),
questions: vec![InteractionQuestion {
question: "Continue?".into(),
options: vec![
QuestionOption {
label: "Yes".into(),
description: None,
},
QuestionOption {
label: "No".into(),
description: None,
},
],
}],
},
outcome: None,
};
log.establish_interaction(&question).await.unwrap();
let first = log
.plugin_execution_progress(receipt.clone())
.await
.unwrap();
assert!(first.attention_id.is_some());
log.create_session("unrelated", "create-unrelated", &json!({}), 2)
.await
.unwrap();
assert_eq!(
first.attention_id,
log.plugin_execution_progress(receipt.clone())
.await
.unwrap()
.attention_id
);
log.commit_interaction_outcome(
&question.request_id,
InteractionOutcome::QuestionAnswer {
answers: vec![Some("Yes".into())],
committed_at: 3,
},
)
.await
.unwrap();
assert!(
log.plugin_execution_progress(receipt.clone())
.await
.unwrap()
.attention_id
.is_none()
);
question.request_id = "question-two".into();
question.created_at = 4;
log.establish_interaction(&question).await.unwrap();
let second = log
.plugin_execution_progress(receipt.clone())
.await
.unwrap();
assert!(second.attention_id.is_some());
assert_ne!(first.attention_id, second.attention_id);
log.commit_interaction_outcome(
&question.request_id,
InteractionOutcome::QuestionAnswer {
answers: vec![Some("Yes".into())],
committed_at: 5,
},
)
.await
.unwrap();
log.append_batch(&writes[1..]).await.unwrap();
assert!(log.pending_messages("session").await.unwrap().is_empty());
log.close().await.unwrap();
let log = EventLog::open(&path).await.unwrap();
assert_eq!(
log.admit_plugin_execution(&namespace, request.clone(), pending(&request))
.await
.unwrap(),
receipt
);
assert!(log.pending_messages("session").await.unwrap().is_empty());
let mut changed = request.clone();
changed.orchestration_mode =
Some(maka_agent_facts::execution::BehaviorId::try_from("swarm".to_owned()).unwrap());
assert!(matches!(
log.admit_plugin_execution(&namespace, changed.clone(), pending(&changed))
.await,
Err(StoreError::EventConflict)
));
let mut changed = request.clone();
changed.content = "Different work".into();
assert!(matches!(
log.admit_plugin_execution(&namespace, changed.clone(), pending(&changed))
.await,
Err(StoreError::EventConflict)
));
let separate = Namespace::new("schedule", Scope::Profile).unwrap();
let other = log
.admit_plugin_execution(&separate, request.clone(), pending(&request))
.await
.unwrap();
assert_ne!(other.invocation, receipt.invocation);
log.close().await.unwrap();
}
fn pending(
request: &maka_agent_ports::execution::Submit,
) -> maka_event_log::message_admissions::PendingMessageAdmission {
use maka_agent_facts::event::Invocation;
use maka_agent_facts::input::DeliveredMessage;
use maka_agent_facts::message::*;
let mode = request.orchestration_mode.clone();
maka_event_log::message_admissions::PendingMessageAdmission {
execution: maka_event_log::message_admissions::PendingExecution {
owner: "maka-runtime-host".into(),
basis: serde_json::Value::Null,
},
invocation: Invocation {
session_id: request.session_id.clone(),
turn_id: uuid::Uuid::new_v4().to_string(),
run_id: uuid::Uuid::new_v4().to_string(),
invocation_id: uuid::Uuid::new_v4().to_string(),
},
steering_invocation: None,
source: RootSourceMessage {
unprepared_content: request.content.clone(),
message: DeliveredMessage {
origin: None,
message_id: uuid::Uuid::new_v4().to_string(),
submitted_content_digest: request.content.content_digest().unwrap(),
content: request.content.clone(),
},
submitted_placement: if mode.is_some() {
Placement::CurrentTurn
} else {
Placement::NextTurn
},
disposition: MessageDisposition::TurnStarted,
submitted_intent: mode.map(|mode| SubmittedTurnIntent {
input_selections: Default::default(),
input_selection_sources: Default::default(),
turn_orchestration: Some(TurnOrchestration {
mode,
source: TurnOrchestrationSource::HostApi,
}),
}),
},
required_tools: Default::default(),
admitted_at: 1,
}
}
#[tokio::test]
async fn data_batches_are_namespaced_atomic_and_preserve_tombstone_revisions() {
use maka_capabilities::storage::Data;
use maka_capabilities::storage::Mutation;
use maka_capabilities::storage::Namespace;
use maka_capabilities::storage::Scan;
use maka_plugin_core::scope::Scope;
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("data.sqlite");
let log = EventLog::open(&path).await.unwrap();
let namespace = Namespace::new("graph", Scope::Profile).unwrap();
let mutation = |key: &str, revision, data| Mutation {
key: key.into(),
expected_revision: revision,
data,
};
log.plugin_data_batch(
&namespace,
vec![mutation("state", None, Data::Present(json!(null)))],
)
.await
.unwrap();
let stored = log.plugin_data(&namespace, "state").await.unwrap().unwrap();
assert_eq!(stored.revision, 1);
assert_eq!(stored.data, Data::Present(json!(null)));
let rejected = log
.plugin_data_batch(
&namespace,
vec![
mutation("new", None, Data::Present(json!(1))),
mutation("state", None, Data::Deleted),
],
)
.await;
assert!(matches!(rejected, Err(StoreError::RevisionConflict { .. })));
assert!(log.plugin_data(&namespace, "new").await.unwrap().is_none());
log.plugin_data_batch(&namespace, vec![mutation("state", Some(1), Data::Deleted)])
.await
.unwrap();
log.close().await.unwrap();
let log = EventLog::open(&path).await.unwrap();
let deleted = log.plugin_data(&namespace, "state").await.unwrap().unwrap();
assert_eq!(deleted.revision, 2);
assert_eq!(deleted.data, Data::Deleted);
let page = log
.plugin_data_scan(&namespace, Scan::default())
.await
.unwrap();
assert_eq!(page.entries.len(), 1);
assert_eq!(page.entries[0].record, deleted);
assert!(page.next_after.is_none());
assert!(
log.plugin_data_batch(
&namespace,
vec![mutation("state", None, Data::Present(json!(2)))]
)
.await
.is_err()
);
let separate = Namespace::new("graph", Scope::Session("other".into())).unwrap();
assert!(log.plugin_data(&separate, "state").await.unwrap().is_none());
assert!(
log.plugin_data_scan(&separate, Scan::default())
.await
.unwrap()
.entries
.is_empty()
);
let mut records = (0..66)
.map(|n| mutation(&format!("r%_/{n:03}"), None, Data::Present(json!(n))))
.collect::<Vec<_>>();
records.push(mutation(
"rax/not-selected",
None,
Data::Present(json!(false)),
));
log.plugin_data_batch(&namespace, records).await.unwrap();
let page = log
.plugin_data_scan(
&namespace,
Scan {
prefix: "r%_/".into(),
after: None,
},
)
.await
.unwrap();
assert_eq!(page.entries.len(), 64);
assert_eq!(page.next_after.as_deref(), Some("r%_/063"));
let tail = log
.plugin_data_scan(
&namespace,
Scan {
prefix: "r%_/".into(),
after: page.next_after,
},
)
.await
.unwrap();
assert_eq!(
tail.entries
.iter()
.map(|entry| entry.key.as_str())
.collect::<Vec<_>>(),
["r%_/064", "r%_/065"]
);
assert!(tail.next_after.is_none());
log.plugin_data_batch(
&namespace,
(0..3)
.map(|n| {
mutation(
&format!("large/{n}"),
None,
Data::Present(json!("x".repeat(900_000))),
)
})
.collect(),
)
.await
.unwrap();
let page = log
.plugin_data_scan(
&namespace,
Scan {
prefix: "large/".into(),
after: None,
},
)
.await
.unwrap();
assert_eq!(
page.entries.len(),
2,
"byte budget also bounds decoded rows"
);
assert_eq!(page.next_after.as_deref(), Some("large/1"));
let tail = log
.plugin_data_scan(
&namespace,
Scan {
prefix: "large/".into(),
after: page.next_after,
},
)
.await
.unwrap();
assert_eq!(tail.entries.len(), 1);
assert!(
matches!(&tail.entries[0].record.data, Data::Present(value) if value.as_str().unwrap().len() == 900_000)
);
assert!(tail.next_after.is_none());
log.plugin_data_batch(
&namespace,
vec![mutation("state", Some(2), Data::Present(json!(3)))],
)
.await
.unwrap();
log.close().await.unwrap();
}
#[tokio::test]
async fn atomic_large_value_and_many_mutations_survive_scan_and_reopen() {
use maka_capabilities::storage::{Data, Mutation, Namespace, Scan};
use maka_plugin_core::scope::Scope;
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("complete-data.sqlite");
let log = EventLog::open(&path).await.unwrap();
let namespace = Namespace::new("complete.graph", Scope::Profile).unwrap();
let body = "甲🙂z".repeat((2 * 1024 * 1024 + 128 * 1024) / 8);
let large = log
.plugin_data_batch(
&namespace,
vec![Mutation {
key: "000-large".into(),
expected_revision: None,
data: Data::Present(json!(body)),
}],
)
.await;
// Attempt both independent original gates before reporting either refusal.
let batch = log
.plugin_data_batch(
&namespace,
(0..129)
.map(|index| Mutation {
key: format!("item/{index:03}"),
expected_revision: None,
data: Data::Present(json!({"index":index})),
})
.collect(),
)
.await;
let stale = if batch.is_ok() {
Some(
log.plugin_data_batch(
&namespace,
vec![
Mutation {
key: "must-rollback".into(),
expected_revision: None,
data: Data::Present(json!(true)),
},
Mutation {
key: "item/000".into(),
expected_revision: None,
data: Data::Deleted,
},
],
)
.await,
)
} else {
None
};
log.close().await.unwrap();
let reopened = EventLog::open(&path).await.unwrap();
let original = reopened.plugin_data(&namespace, "000-large").await;
let rolled_back = reopened.plugin_data(&namespace, "must-rollback").await;
let pages: Result<Vec<_>, String> = async {
let mut entries = Vec::new();
let mut after = None;
loop {
let page = reopened
.plugin_data_scan(
&namespace,
Scan {
prefix: String::new(),
after: after.clone(),
},
)
.await
.map_err(|error| error.to_string())?;
if page.next_after.is_some() && (page.entries.is_empty() || page.next_after <= after) {
return Err("storage scan did not advance".into());
}
entries.extend(page.entries);
after = page.next_after;
if after.is_none() {
return Ok(entries);
}
}
}
.await;
reopened.close().await.unwrap();
assert!(
large.is_ok(),
"complete value admission failed: {}",
large.err().map(|e| e.to_string()).unwrap_or_default()
);
assert!(
batch.is_ok(),
"atomic batch admission failed: {}",
batch.err().map(|e| e.to_string()).unwrap_or_default()
);
assert!(matches!(
stale,
Some(Err(StoreError::RevisionConflict { .. }))
));
assert!(
rolled_back.unwrap().is_none(),
"a stale atomic batch left a partial write"
);
let original = original.unwrap().unwrap();
assert_eq!(original.revision, 1);
assert!(
original.data.value() == Some(&json!(body)),
"reopened complete value changed"
);
let entries = pages.expect("complete storage scan failed");
assert_eq!(entries.len(), 130);
assert_eq!(entries[0].key, "000-large");
assert!(
entries[0].record == original,
"oversized first scan record changed"
);
for (index, entry) in entries[1..].iter().enumerate() {
assert_eq!(entry.key, format!("item/{index:03}"));
assert_eq!(entry.record.revision, 1);
assert_eq!(entry.record.data, Data::Present(json!({"index":index})));
}
}