fix: make compaction activation atomic

This commit is contained in:
2026-09-16 03:44:48 +09:00
parent 016dbd7cb1
commit 4e7a314a00
9 changed files with 523 additions and 67 deletions
+166 -3
View File
@@ -8,7 +8,7 @@
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use agen::Engine;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
@@ -17,14 +17,103 @@ use agen::llm_client::{ClientError, LlmClient, Request};
use async_trait::async_trait;
use futures::Stream;
use protocol::{Event, Method, RunResult};
use session_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore};
use session_store::{FsStore, LogEntry, Store};
use session_store::{
CombinedStore, FsStore, FsWorkerStore, LogEntry, Store, WorkerMetadata, WorkerMetadataStore,
WorkerStoreError,
};
use tokio::sync::broadcast;
use worker::{Worker, WorkerController};
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
#[derive(Clone)]
struct FaultingWorkerMetadataStore {
inner: FsWorkerStore,
fail_next_update: Arc<AtomicBool>,
}
impl FaultingWorkerMetadataStore {
fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self {
inner: FsWorkerStore::new(root).unwrap(),
fail_next_update: Arc::new(AtomicBool::new(false)),
}
}
fn arm_update_failure(&self) {
self.fail_next_update.store(true, Ordering::SeqCst);
}
}
impl WorkerMetadataStore for FaultingWorkerMetadataStore {
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
let old_segment_id = self
.inner
.read_by_name(&metadata.worker_name)?
.and_then(|current| current.active)
.and_then(|active| active.segment_id);
let new_segment_id = metadata
.active
.as_ref()
.and_then(|active| active.segment_id);
if old_segment_id != new_segment_id && self.fail_next_update.swap(false, Ordering::SeqCst) {
return Err(WorkerStoreError::Io(std::io::Error::other(
"injected active Segment commit failure",
)));
}
self.inner.write(metadata)
}
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
self.inner.read_by_name(worker_name)
}
fn update_by_name<F>(
&self,
worker_name: &str,
mutate: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
let mut metadata = self
.inner
.read_by_name(worker_name)?
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
let old_segment_id = metadata
.active
.as_ref()
.and_then(|active| active.segment_id);
mutate(&mut metadata);
let new_segment_id = metadata
.active
.as_ref()
.and_then(|active| active.segment_id);
if old_segment_id != new_segment_id && self.fail_next_update.swap(false, Ordering::SeqCst) {
return Err(WorkerStoreError::Io(std::io::Error::other(
"injected active Segment commit failure",
)));
}
self.inner.write(&metadata)?;
Ok(metadata)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
self.inner.list_names()
}
fn root_dir(&self) -> Option<std::path::PathBuf> {
self.inner.root_dir()
}
fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
self.inner.delete_by_name(worker_name)
}
}
type FaultingTestStore = CombinedStore<FsStore, FaultingWorkerMetadataStore>;
fn annotated(item: Item) -> session_store::LoggedHistoryEntry {
session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item),
@@ -229,6 +318,41 @@ async fn make_worker(client: MockClient) -> Worker<MockClient, TestStore> {
make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await
}
async fn make_faulting_worker(
client: MockClient,
) -> (
Worker<MockClient, FaultingTestStore>,
FaultingWorkerMetadataStore,
FsStore,
) {
let manifest = worker::WorkerManifest::from_toml(MID_TURN_MANIFEST_TOML).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let segment_store = FsStore::new(store_tmp.path()).unwrap();
let metadata_store = FaultingWorkerMetadataStore::new(store_tmp.path().join("pods"));
let store = CombinedStore::new(segment_store.clone(), metadata_store.clone());
std::mem::forget(store_tmp);
let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp);
let engine =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new(
manifest,
engine,
store,
worker::WorkerWorkspaceContext::local_filesystem(None),
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
scope,
)
.await
.unwrap();
worker.enable_worker_metadata_write_through().unwrap();
(worker, metadata_store, segment_store)
}
/// Drain whatever events are already queued on `rx`. Non-blocking.
fn drain(rx: &mut broadcast::Receiver<Event>) -> Vec<Event> {
let mut out = Vec::new();
@@ -282,6 +406,45 @@ fn system_texts_in_sink_session_start(
Vec::new()
}
#[tokio::test]
async fn failed_active_segment_commit_keeps_live_and_durable_history_on_old_segment() {
let client = MockClient::new(vec![
single_text_events("seed response"),
write_summary_tool_use_events("summary-1", "replacement summary"),
single_text_events("continued on old segment"),
]);
let (mut worker, metadata_store, segment_store) = make_faulting_worker(client).await;
worker.run_text("seed input").await.unwrap();
let old_segment_id = worker.segment_id();
metadata_store.arm_update_failure();
let error = worker.compact(0).await.unwrap_err();
assert!(
error
.to_string()
.contains("injected active Segment commit failure")
);
assert_eq!(worker.segment_id(), old_segment_id);
let metadata = metadata_store
.read_by_name("test-worker")
.unwrap()
.expect("active Worker metadata should remain present");
assert_eq!(
metadata.active.and_then(|active| active.segment_id),
Some(old_segment_id)
);
worker.run_text("continue input").await.unwrap();
let active_records = segment_store
.read_all(worker.session_id(), old_segment_id)
.unwrap();
assert!(
format!("{active_records:?}").contains("continue input"),
"the live Worker must continue appending to the old active Segment"
);
}
/// Worker metadata starts with a reserved Session and no Segment, then becomes
/// active once the first SegmentStart is materialized by `run`.
#[tokio::test]