fix: Non-transactional multi-step context updates between metadata/to… (#1161)

* fix: Non-transactional multi-step context updates between metadata/token setup and DB

* fix: code style
This commit is contained in:
Nick Pismenkov
2026-03-14 13:06:30 -07:00
committed by GitHub
parent 8dfad332d9
commit 3f2796b745
2 changed files with 121 additions and 9 deletions
+88
View File
@@ -87,6 +87,28 @@ impl ContextManager {
Ok(f(context))
}
/// Atomically update a job context and return the updated context.
///
/// This method holds the write lock for the entire update-and-read sequence,
/// preventing concurrent workers from interleaving modifications between the
/// update and the subsequent read (Issue #807: non-transactional context updates).
/// Use this when you need to update context and immediately persist it to DB.
pub async fn update_context_and_get<F>(
&self,
job_id: Uuid,
f: F,
) -> Result<JobContext, JobError>
where
F: FnOnce(&mut JobContext),
{
let mut contexts = self.contexts.write().await;
let context = contexts
.get_mut(&job_id)
.ok_or(JobError::NotFound { id: job_id })?;
f(context);
Ok(context.clone())
}
/// Get job memory.
pub async fn get_memory(&self, job_id: Uuid) -> Result<Memory, JobError> {
self.memories
@@ -877,4 +899,70 @@ mod tests {
assert_eq!(manager.all_jobs().await.len(), 10);
}
#[tokio::test]
async fn update_context_and_get_atomicity_regression_issue_807() {
// Regression test for Issue #807: non-transactional context updates.
// Verify that update_context_and_get returns the exact state that was set,
// without allowing concurrent workers to interleave modifications.
let manager = std::sync::Arc::new(ContextManager::new(100));
let job_id = manager
.create_job("Atomicity Test", "verify no race condition")
.await
.unwrap(); // safety: test code
// Update and get atomically, setting metadata
let metadata = serde_json::json!({ "priority": "high", "user_id": 42 });
let returned_ctx = manager
.update_context_and_get(job_id, |ctx| {
ctx.metadata = metadata.clone();
ctx.max_tokens = 5000;
})
.await
.unwrap(); // safety: test code
// Verify the returned context has the exact updates we set
assert_eq!(returned_ctx.metadata, metadata); // safety: test code
assert_eq!(returned_ctx.max_tokens, 5000); // safety: test code
// Verify a fresh get returns the same state
let fresh_ctx = manager.get_context(job_id).await.unwrap(); // safety: test code
assert_eq!(fresh_ctx.metadata, metadata); // safety: test code
assert_eq!(fresh_ctx.max_tokens, 5000); // safety: test code
}
#[tokio::test]
async fn update_context_and_get_no_concurrent_interleave() {
// Verify that concurrent updates cannot interleave during update_context_and_get.
// If the lock were released too early, a concurrent state transition could
// get mixed into the returned context.
let manager = std::sync::Arc::new(ContextManager::new(100));
let job_id = manager
.create_job("Concurrent Race Test", "ensure atomicity")
.await
.unwrap(); // safety: test code
let metadata = serde_json::json!({ "test": "race_condition" });
let metadata_clone = metadata.clone();
// Spawn a task that will update_context_and_get
let mgr1 = std::sync::Arc::clone(&manager);
let returned_ctx_handle = tokio::spawn(async move {
mgr1.update_context_and_get(job_id, |ctx| {
ctx.metadata = metadata_clone;
ctx.max_tokens = 3000;
})
.await
});
// The returned context should have *only* the metadata update, not any
// concurrent state transitions that might happen during the operation.
let returned_ctx = returned_ctx_handle.await.unwrap().unwrap(); // safety: test code
// Verify atomicity: returned context has the metadata we set
assert_eq!(returned_ctx.metadata, metadata); // safety: test code
assert_eq!(returned_ctx.max_tokens, 3000); // safety: test code
// And it's in the initial state (Pending), not modified by concurrent workers
assert_eq!(returned_ctx.state, crate::context::JobState::Pending); // safety: test code
}
}