Enforce single-writer ownership for paginated threads (#34986)

## Why

Only one app-server process should keep a paginated thread open for writing at
a time, while other processes must still be able to read it.

## What changed

- Acquire a per-thread filesystem lock when creating or resuming a paginated
  thread, and retain it for the lifetime of the live recorder.
- Reject a competing `thread/resume` request with JSON-RPC error `-32600` and
  release ownership when the writer is discarded, deleted, or shut down.
- Remove stale lock files without disturbing active writers.

## Testing

Added coverage for competing processes, ownership transfer after shutdown,
stale-lock cleanup, and lock release during discard and deletion.

GitOrigin-RevId: 81d49a2fffc73ba8135752b4944f27feafa4c0d6
This commit is contained in:
Owen Lin authored and copyberry committed 2026-07-23 18:22:41 +00:00
1 parent ce803c45ae
commit 5c94796dc9
9 files changed
+518 -50

No files matched your search

+71 -41
View File
@@ -14,6 +14,7 @@ mod thread_history;
mod thread_history_materialization;
mod unarchive_thread;
mod update_thread_metadata;
mod writer_lock;
#[cfg(test)]
mod test_support;
@@ -59,6 +60,8 @@ use crate::ThreadStoreFuture;
use crate::ThreadStoreResult;
use crate::TurnPage;
use crate::UpdateThreadMetadataParams;
use crate::local::writer_lock::WriterLockCoordinator;
use crate::local::writer_lock::WriterLockGuard;
/// Local filesystem/SQLite-backed implementation of [`ThreadStore`].
///
@@ -78,6 +81,7 @@ pub struct LocalThreadStore {
pub(super) config: LocalThreadStoreConfig,
live_recorders: Arc<Mutex<HashMap<ThreadId, LiveRecorderEntry>>>,
live_writer_locks: Arc<LiveWriterLocks>,
writer_lock_coordinator: Arc<WriterLockCoordinator>,
state_db: Option<StateDbHandle>,
thread_history_db: Arc<OnceCell<sqlx::SqlitePool>>,
}
@@ -88,6 +92,7 @@ struct LiveRecorderEntry {
// canonical SessionMeta is durable. Retain the mode captured when live persistence was opened
// so missing SQLite rows can still be seeded.
history_mode: ThreadHistoryMode,
_writer_lock: Option<WriterLockGuard>,
}
#[derive(Default)]
@@ -150,10 +155,12 @@ impl std::fmt::Debug for LocalThreadStore {
impl LocalThreadStore {
/// Create a local store using an already initialized state DB handle.
pub fn new(config: LocalThreadStoreConfig, state_db: Option<StateDbHandle>) -> Self {
let writer_lock_coordinator = Arc::new(WriterLockCoordinator::new(&config.codex_home));
Self {
config,
live_recorders: Arc::new(Mutex::new(HashMap::new())),
live_writer_locks: Arc::new(LiveWriterLocks::default()),
writer_lock_coordinator,
state_db,
thread_history_db: Arc::new(OnceCell::new()),
}
@@ -208,11 +215,12 @@ impl LocalThreadStore {
Ok(())
}
pub(super) async fn insert_live_recorder(
async fn insert_live_recorder(
&self,
thread_id: ThreadId,
recorder: RolloutRecorder,
history_mode: ThreadHistoryMode,
writer_lock: Option<WriterLockGuard>,
) -> ThreadStoreResult<()> {
match self.live_recorders.lock().await.entry(thread_id) {
Entry::Occupied(entry) => Err(ThreadStoreError::InvalidRequest {
@@ -222,6 +230,7 @@ impl LocalThreadStore {
entry.insert(LiveRecorderEntry {
recorder,
history_mode,
_writer_lock: writer_lock,
});
Ok(())
}
@@ -963,56 +972,77 @@ mod tests {
async fn create_thread_rejects_missing_cwd() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let thread_id = ThreadId::default();
let mut params = create_thread_params(thread_id);
params.metadata.cwd = None;
for history_mode in [ThreadHistoryMode::Legacy, ThreadHistoryMode::Paginated] {
let thread_id = ThreadId::default();
let mut params = create_thread_params(thread_id);
params.history_mode = history_mode;
params.metadata.cwd = None;
let err = store
.create_thread(params)
.await
.expect_err("local thread store should require cwd");
let err = store
.create_thread(params)
.await
.expect_err("local thread store should require cwd");
assert!(matches!(
err,
ThreadStoreError::InvalidRequest { message }
if message == "local thread store requires a cwd"
));
assert!(matches!(
err,
ThreadStoreError::InvalidRequest { message }
if message == "local thread store requires a cwd"
));
let mut valid_params = create_thread_params(thread_id);
valid_params.history_mode = history_mode;
store
.create_thread(valid_params)
.await
.expect("failed initialization should release writer ownership");
}
}
#[tokio::test]
async fn discard_thread_drops_unmaterialized_live_writer() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let thread_id = ThreadId::default();
for history_mode in [ThreadHistoryMode::Legacy, ThreadHistoryMode::Paginated] {
let thread_id = ThreadId::default();
let mut params = create_thread_params(thread_id);
params.history_mode = history_mode;
store
.create_thread(create_thread_params(thread_id))
.await
.expect("create live thread");
let rollout_path = store
.live_rollout_path(thread_id)
.await
.expect("load rollout path");
store
.discard_thread(thread_id)
.await
.expect("discard live thread");
assert!(
!tokio::fs::try_exists(rollout_path.as_path())
store
.create_thread(params)
.await
.expect("check rollout path")
);
let err = store
.append_items(AppendThreadItemsParams {
thread_id,
items: vec![user_message_item("write after discard")],
})
.await
.expect_err("discard should remove the live thread writer");
assert!(
matches!(err, ThreadStoreError::ThreadNotFound { thread_id: missing } if missing == thread_id)
);
.expect("create live thread");
let rollout_path = store
.live_rollout_path(thread_id)
.await
.expect("load rollout path");
assert!(!rollout_path.exists());
let lock_path = home
.path()
.join("thread-writer-locks")
.join(format!("{thread_id}.lock"));
assert_eq!(
lock_path.exists(),
matches!(history_mode, ThreadHistoryMode::Paginated)
);
store
.discard_thread(thread_id)
.await
.expect("discard live thread");
assert!(!rollout_path.exists());
assert!(!lock_path.exists());
let err = store
.append_items(AppendThreadItemsParams {
thread_id,
items: vec![user_message_item("write after discard")],
})
.await
.expect_err("discard should remove the live thread writer");
assert!(
matches!(err, ThreadStoreError::ThreadNotFound { thread_id: missing } if missing == thread_id)
);
}
}
#[tokio::test]