blob: 830e5931528ca235852d74f1bfdd94680576d08b [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_agent_facts::event::Fact;
use maka_agent_facts::event::InvocationOutcome;
use maka_agent_facts::message::MessageDisposition as Disposition;
use maka_event_log::{
EventLog, StoreError,
message_interrupts::{InterruptCommand, InterruptReceipt},
};
use serde_json::json;
#[path = "support/message_queue.rs"]
mod support;
use support::{admission, append, invocation, opening};
#[tokio::test]
async fn interrupt_fences_delivery_atomically_and_replays_the_original_queue_cut() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("interrupt.sqlite");
let log = EventLog::open(&path).await.unwrap();
log.create_session("session", "create", &json!({}), 1)
.await
.unwrap();
let owner = invocation("turn");
append(&log, &owner, opening()).await;
log.admit_message(admission(&owner, "delivered", Disposition::Steering))
.await
.unwrap();
assert_eq!(log.commit_pending_steering(&owner).await.unwrap(), 1);
for (id, disposition) in [
("steer", Disposition::Steering),
("next", Disposition::Followup),
] {
log.admit_message(admission(&owner, id, disposition))
.await
.unwrap();
}
let command = InterruptCommand {
host_epoch: "epoch".into(),
session_id: "session".into(),
interrupt_id: "interrupt".into(),
turn_id: owner.turn_id.clone(),
run_id: owner.run_id.clone(),
};
let before = log.message_queue("session").await.unwrap();
let prefix = serde_json::to_vec(&log.prefix(100, 1024 * 1024).await.unwrap()).unwrap();
let db = rusqlite::Connection::open(&path).unwrap();
db.execute_batch(
"CREATE TRIGGER reject_interrupt BEFORE INSERT ON message_interrupt_receipts
BEGIN SELECT RAISE(ABORT, 'interrupt fault'); END;",
)
.unwrap();
assert!(
log.interrupt_message_queue(&command, before.revision, Some(&owner))
.await
.is_err()
);
assert_eq!(log.message_queue("session").await.unwrap(), before);
assert!(!log.message_cancelled("session", "steer").await.unwrap());
assert!(
log.message_interrupt_receipt(&command)
.await
.unwrap()
.is_none()
);
db.execute_batch("DROP TRIGGER reject_interrupt").unwrap();
// Canonical consumption/edit winning the SQL race makes the old cut invalid.
log.admit_message(admission(&owner, "late", Disposition::Followup))
.await
.unwrap();
assert!(matches!(
log.interrupt_message_queue(&command, before.revision, Some(&owner))
.await,
Err(StoreError::RevisionConflict { .. })
));
let queued = support::large_queue(&log).await;
let receipt = log
.interrupt_message_queue(&command, queued.revision, Some(&owner))
.await
.unwrap();
let InterruptReceipt::Fenced(fence) = &receipt else {
panic!("accepted fence");
};
assert_eq!(fence.revision, queued.revision + 1);
assert_eq!(fence.retracted, queued.entries);
assert_eq!(
log.message_interrupt_receipt(&command).await.unwrap(),
Some(receipt.clone())
);
assert_eq!(
fence
.retracted
.iter()
.map(|entry| entry.source.message.message_id.as_str())
.collect::<Vec<_>>(),
["steer", "next", "late"]
);
assert!(
log.message_queue("session")
.await
.unwrap()
.entries
.is_empty()
);
for id in ["steer", "next", "late"] {
assert!(log.message_cancelled("session", id).await.unwrap());
}
assert!(!log.message_cancelled("session", "delivered").await.unwrap());
assert!(
log.steering_message("session", "delivered")
.await
.unwrap()
.is_some()
);
assert_eq!(log.commit_pending_steering(&owner).await.unwrap(), 0);
assert!(matches!(
log.admit_message(admission(&owner, "after", Disposition::Followup))
.await,
Err(StoreError::SessionBusy)
));
assert_eq!(
log.interrupt_message_queue(&command, 0, None)
.await
.unwrap(),
receipt
);
let mut second = command.clone();
second.interrupt_id = "second".into();
assert_eq!(
log.interrupt_message_queue(&second, 0, Some(&owner))
.await
.unwrap(),
receipt
);
assert_eq!(
log.message_queue("session").await.unwrap().revision,
fence.revision
);
let mut wrong = command.clone();
wrong.turn_id = "different".into();
assert!(matches!(
log.message_interrupt_receipt(&wrong).await,
Err(StoreError::InvalidTransition(_))
));
assert_eq!(
serde_json::to_vec(&log.prefix(100, 1024 * 1024).await.unwrap()).unwrap(),
prefix
);
append(
&log,
&owner,
Fact::InvocationEnded {
outcome: InvocationOutcome::Completed,
},
)
.await;
let next = invocation("next-turn");
append(&log, &next, opening()).await;
log.admit_message(admission(&next, "new-work", Disposition::Followup))
.await
.unwrap();
assert_eq!(
log.interrupt_message_queue(&command, 0, Some(&next))
.await
.unwrap(),
receipt
);
assert_eq!(
log.pending_messages("session").await.unwrap().len(),
1,
"old retry cannot affect new Run"
);
second.interrupt_id = "stale-target".into();
assert_eq!(
log.interrupt_message_queue(&second, 0, Some(&next))
.await
.unwrap(),
InterruptReceipt::Conflict
);
assert_eq!(
log.interrupt_message_queue(&second, 0, None).await.unwrap(),
InterruptReceipt::Conflict
);
log.close().await.unwrap();
let log = EventLog::open(&path).await.unwrap();
assert_eq!(
log.message_interrupt_receipt(&command).await.unwrap(),
Some(receipt)
);
log.begin_message_epoch("new-epoch").await.unwrap();
assert!(
log.message_interrupt_receipt(&command)
.await
.unwrap()
.is_none()
);
assert!(log.message_cancelled("session", "steer").await.unwrap());
assert_eq!(log.pending_messages("session").await.unwrap().len(), 1);
// A canonical terminal can precede release of the Host's cleanup owner.
// That owner must still be able to fence even an empty queue exactly once.
log.create_session("empty", "empty-create", &json!({}), 2)
.await
.unwrap();
let mut empty = invocation("empty-turn");
empty.session_id = "empty".into();
append(&log, &empty, opening()).await;
append(
&log,
&empty,
Fact::InvocationEnded {
outcome: InvocationOutcome::Completed,
},
)
.await;
let mut stop_empty = InterruptCommand {
host_epoch: "new-epoch".into(),
session_id: "empty".into(),
interrupt_id: "empty-stop".into(),
turn_id: empty.turn_id.clone(),
run_id: empty.run_id.clone(),
};
let result = log
.interrupt_message_queue(&stop_empty, 0, Some(&empty))
.await
.unwrap();
let InterruptReceipt::Fenced(fence) = &result else {
panic!("terminal cleanup owner lost");
};
assert!(fence.retracted.is_empty());
assert_eq!(fence.revision, 1);
stop_empty.interrupt_id = "another-empty-stop".into();
assert_eq!(
log.interrupt_message_queue(&stop_empty, 0, Some(&empty))
.await
.unwrap(),
result
);
assert_eq!(log.message_queue("empty").await.unwrap().revision, 1);
log.close().await.unwrap();
}