mirror of
https://github.com/openai/codex.git
synced 2026-11-13 22:47:20 +00:00
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:
1 parent
ce803c45ae
commit
5c94796dc9
9 files changed
+518
-50
No files matched your search
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user