fix: hold feature task barrier across rewrites
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
//! feature was explicitly granted at install time.
|
//! feature was explicitly granted at install time.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -25,6 +25,9 @@ const MAX_TASK_ATTEMPTS: u16 = 16;
|
|||||||
const MAX_TASK_TIMEOUT_MS: u64 = 24 * 60 * 60 * 1_000;
|
const MAX_TASK_TIMEOUT_MS: u64 = 24 * 60 * 60 * 1_000;
|
||||||
const MAX_RETAINED_DIAGNOSTICS: usize = 128;
|
const MAX_RETAINED_DIAGNOSTICS: usize = 128;
|
||||||
const TASK_SETTLE_TIMEOUT_MS: u64 = 30_000;
|
const TASK_SETTLE_TIMEOUT_MS: u64 = 30_000;
|
||||||
|
const TASK_SCOPE_ACCEPTING: u8 = 0;
|
||||||
|
const TASK_SCOPE_REWRITING: u8 = 1;
|
||||||
|
const TASK_SCOPE_STOPPING: u8 = 2;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -258,7 +261,7 @@ impl FeatureBackgroundTaskRegistryBuilder {
|
|||||||
diagnostics: Mutex::new(Vec::new()),
|
diagnostics: Mutex::new(Vec::new()),
|
||||||
next_execution_id: AtomicU64::new(1),
|
next_execution_id: AtomicU64::new(1),
|
||||||
session_generation: Arc::new(AtomicU64::new(1)),
|
session_generation: Arc::new(AtomicU64::new(1)),
|
||||||
accepting: AtomicBool::new(true),
|
lifecycle: AtomicU8::new(TASK_SCOPE_ACCEPTING),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,7 +280,7 @@ struct RegistryInner {
|
|||||||
diagnostics: Mutex<Vec<BackgroundTaskDiagnostic>>,
|
diagnostics: Mutex<Vec<BackgroundTaskDiagnostic>>,
|
||||||
next_execution_id: AtomicU64,
|
next_execution_id: AtomicU64,
|
||||||
session_generation: Arc<AtomicU64>,
|
session_generation: Arc<AtomicU64>,
|
||||||
accepting: AtomicBool,
|
lifecycle: AtomicU8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for RegistryInner {
|
impl Drop for RegistryInner {
|
||||||
@@ -297,6 +300,31 @@ pub struct FeatureBackgroundTaskRegistry {
|
|||||||
inner: Arc<RegistryInner>,
|
inner: Arc<RegistryInner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holds the task registry quiescent across the entire Session rewrite. Drop
|
||||||
|
/// reopens starts only when shutdown has not moved the scope to Stopping.
|
||||||
|
pub struct BackgroundTaskRewriteGuard {
|
||||||
|
inner: Arc<RegistryInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for BackgroundTaskRewriteGuard {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("BackgroundTaskRewriteGuard")
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for BackgroundTaskRewriteGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.inner.lifecycle.compare_exchange(
|
||||||
|
TASK_SCOPE_REWRITING,
|
||||||
|
TASK_SCOPE_ACCEPTING,
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Acquire,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for FeatureBackgroundTaskRegistry {
|
impl Default for FeatureBackgroundTaskRegistry {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
FeatureBackgroundTaskRegistryBuilder::default().build()
|
FeatureBackgroundTaskRegistryBuilder::default().build()
|
||||||
@@ -361,7 +389,7 @@ impl FeatureBackgroundTaskRegistry {
|
|||||||
task_name: &str,
|
task_name: &str,
|
||||||
invocation: HookInvocationContext,
|
invocation: HookInvocationContext,
|
||||||
) -> Result<BackgroundTaskStart, HookError> {
|
) -> Result<BackgroundTaskStart, HookError> {
|
||||||
if !self.inner.accepting.load(Ordering::Acquire) {
|
if self.inner.lifecycle.load(Ordering::Acquire) != TASK_SCOPE_ACCEPTING {
|
||||||
return Err(HookError::new(
|
return Err(HookError::new(
|
||||||
HookErrorCategory::ScopeDisposed,
|
HookErrorCategory::ScopeDisposed,
|
||||||
"background task scope is stopping",
|
"background task scope is stopping",
|
||||||
@@ -380,10 +408,11 @@ impl FeatureBackgroundTaskRegistry {
|
|||||||
.running
|
.running
|
||||||
.lock()
|
.lock()
|
||||||
.expect("background tasks poisoned");
|
.expect("background tasks poisoned");
|
||||||
// `shutdown()` flips accepting before taking this same lock. Recheck
|
// `shutdown()` and `begin_session_rewrite()` change lifecycle before
|
||||||
// while holding the spawn/drain serialization boundary so a starter
|
// taking this same lock. Recheck while holding the spawn/drain
|
||||||
// that passed the optimistic check cannot publish a task after drain.
|
// serialization boundary so a starter that passed the optimistic
|
||||||
if !self.inner.accepting.load(Ordering::Acquire) {
|
// check cannot publish after either barrier.
|
||||||
|
if self.inner.lifecycle.load(Ordering::Acquire) != TASK_SCOPE_ACCEPTING {
|
||||||
return Err(HookError::new(
|
return Err(HookError::new(
|
||||||
HookErrorCategory::ScopeDisposed,
|
HookErrorCategory::ScopeDisposed,
|
||||||
"background task scope is stopping",
|
"background task scope is stopping",
|
||||||
@@ -484,14 +513,45 @@ impl FeatureBackgroundTaskRegistry {
|
|||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn before_session_rewrite(&self) -> Result<(), HookError> {
|
pub async fn begin_session_rewrite(&self) -> Result<BackgroundTaskRewriteGuard, HookError> {
|
||||||
|
match self.inner.lifecycle.compare_exchange(
|
||||||
|
TASK_SCOPE_ACCEPTING,
|
||||||
|
TASK_SCOPE_REWRITING,
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Acquire,
|
||||||
|
) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(TASK_SCOPE_REWRITING) => {
|
||||||
|
return Err(HookError::new(
|
||||||
|
HookErrorCategory::Dependency,
|
||||||
|
"another Session rewrite already owns the background task barrier",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Err(HookError::new(
|
||||||
|
HookErrorCategory::ScopeDisposed,
|
||||||
|
"background task scope is stopping",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let guard = BackgroundTaskRewriteGuard {
|
||||||
|
inner: Arc::clone(&self.inner),
|
||||||
|
};
|
||||||
self.settle(false).await?;
|
self.settle(false).await?;
|
||||||
|
if self.inner.lifecycle.load(Ordering::Acquire) != TASK_SCOPE_REWRITING {
|
||||||
|
return Err(HookError::new(
|
||||||
|
HookErrorCategory::ScopeDisposed,
|
||||||
|
"background task scope stopped during Session rewrite preparation",
|
||||||
|
));
|
||||||
|
}
|
||||||
self.inner.session_generation.fetch_add(1, Ordering::AcqRel);
|
self.inner.session_generation.fetch_add(1, Ordering::AcqRel);
|
||||||
Ok(())
|
Ok(guard)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn shutdown(&self) -> Result<(), HookError> {
|
pub async fn shutdown(&self) -> Result<(), HookError> {
|
||||||
self.inner.accepting.store(false, Ordering::Release);
|
self.inner
|
||||||
|
.lifecycle
|
||||||
|
.store(TASK_SCOPE_STOPPING, Ordering::Release);
|
||||||
self.settle(true).await
|
self.settle(true).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,7 +728,7 @@ async fn execute_task(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
|
||||||
fn invocation() -> HookInvocationContext {
|
fn invocation() -> HookInvocationContext {
|
||||||
HookInvocationContext {
|
HookInvocationContext {
|
||||||
@@ -893,6 +953,73 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct StartsTaskFromRewriteHook {
|
||||||
|
registry: FeatureBackgroundTaskRegistry,
|
||||||
|
feature: FeatureId,
|
||||||
|
rejected: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl crate::hook::Hook<crate::hook::BeforeSessionRewrite> for StartsTaskFromRewriteHook {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
_input: &crate::hook::BeforeSessionRewriteContext,
|
||||||
|
) -> Result<crate::hook::BeforeSessionRewriteAction, HookError> {
|
||||||
|
let error = self
|
||||||
|
.registry
|
||||||
|
.start(&self.feature, "hook-start", invocation())
|
||||||
|
.unwrap_err();
|
||||||
|
self.rejected.store(
|
||||||
|
error.category == HookErrorCategory::ScopeDisposed,
|
||||||
|
Ordering::Release,
|
||||||
|
);
|
||||||
|
Ok(crate::hook::BeforeSessionRewriteAction::Continue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rewrite_hook_cannot_start_task_while_quiescence_guard_is_held() {
|
||||||
|
let feature = FeatureId::builtin("hook-start-test");
|
||||||
|
let declaration = BackgroundTaskDeclaration::worker_managed("hook-start", "hook-start");
|
||||||
|
let mut task_builder = FeatureBackgroundTaskRegistryBuilder::default();
|
||||||
|
task_builder
|
||||||
|
.register(
|
||||||
|
feature.clone(),
|
||||||
|
BackgroundTaskSpec::single_flight(declaration, Duration::from_secs(1)),
|
||||||
|
WaitForCancellation,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let tasks = task_builder.build();
|
||||||
|
let rejected = Arc::new(AtomicBool::new(false));
|
||||||
|
let mut hook_builder = crate::hook::HookRegistryBuilder::new();
|
||||||
|
hook_builder
|
||||||
|
.add_named_before_session_rewrite(
|
||||||
|
"hook-start-test",
|
||||||
|
crate::hook::HookExecutionPolicy::fail_closed(),
|
||||||
|
StartsTaskFromRewriteHook {
|
||||||
|
registry: tasks.clone(),
|
||||||
|
feature,
|
||||||
|
rejected: Arc::clone(&rejected),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let hooks = hook_builder.build();
|
||||||
|
let guard = tasks.begin_session_rewrite().await.unwrap();
|
||||||
|
let context = crate::hook::BeforeSessionRewriteContext {
|
||||||
|
invocation: invocation(),
|
||||||
|
kind: crate::hook::SessionRewriteKind::Compact,
|
||||||
|
current_history: crate::hook::HookHistoryRange::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hooks.before_session_rewrite(&context).await.unwrap(),
|
||||||
|
crate::hook::BeforeSessionRewriteAction::Continue
|
||||||
|
);
|
||||||
|
assert!(rejected.load(Ordering::Acquire));
|
||||||
|
drop(guard);
|
||||||
|
tasks.shutdown().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rewrite_joins_old_tasks_before_advancing_session_generation() {
|
async fn rewrite_joins_old_tasks_before_advancing_session_generation() {
|
||||||
let feature = FeatureId::builtin("generation-test");
|
let feature = FeatureId::builtin("generation-test");
|
||||||
@@ -915,9 +1042,16 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(registry.inner.session_generation.load(Ordering::Acquire), 1);
|
assert_eq!(registry.inner.session_generation.load(Ordering::Acquire), 1);
|
||||||
|
|
||||||
registry.before_session_rewrite().await.unwrap();
|
let rewrite_guard = registry.begin_session_rewrite().await.unwrap();
|
||||||
|
|
||||||
assert_eq!(registry.inner.session_generation.load(Ordering::Acquire), 2);
|
assert_eq!(registry.inner.session_generation.load(Ordering::Acquire), 2);
|
||||||
|
assert!(matches!(
|
||||||
|
registry.start(&feature, "generation", invocation()),
|
||||||
|
Err(HookError {
|
||||||
|
category: HookErrorCategory::ScopeDisposed,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stale_fence.ensure_current().unwrap_err().category,
|
stale_fence.ensure_current().unwrap_err().category,
|
||||||
HookErrorCategory::Cancelled
|
HookErrorCategory::Cancelled
|
||||||
@@ -927,6 +1061,27 @@ mod tests {
|
|||||||
registry.diagnostics()[0].outcome,
|
registry.diagnostics()[0].outcome,
|
||||||
BackgroundTaskOutcome::Cancelled
|
BackgroundTaskOutcome::Cancelled
|
||||||
);
|
);
|
||||||
|
drop(rewrite_guard);
|
||||||
|
assert!(matches!(
|
||||||
|
registry
|
||||||
|
.start(&feature, "generation", invocation())
|
||||||
|
.unwrap(),
|
||||||
|
BackgroundTaskStart::Started { .. }
|
||||||
|
));
|
||||||
|
registry.shutdown().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_during_rewrite_prevents_guard_drop_from_reopening_starts() {
|
||||||
|
let registry = FeatureBackgroundTaskRegistry::default();
|
||||||
|
let guard = registry.begin_session_rewrite().await.unwrap();
|
||||||
|
registry.shutdown().await.unwrap();
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let error = registry
|
||||||
|
.start(&FeatureId::builtin("missing"), "missing", invocation())
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.category, HookErrorCategory::ScopeDisposed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -942,7 +1097,7 @@ mod tests {
|
|||||||
let registry = builder.build();
|
let registry = builder.build();
|
||||||
registry.start(&feature, "rewrite", invocation()).unwrap();
|
registry.start(&feature, "rewrite", invocation()).unwrap();
|
||||||
|
|
||||||
let error = registry.before_session_rewrite().await.unwrap_err();
|
let error = registry.begin_session_rewrite().await.unwrap_err();
|
||||||
assert_eq!(error.category, HookErrorCategory::Dependency);
|
assert_eq!(error.category, HookErrorCategory::Dependency);
|
||||||
registry.shutdown().await.unwrap();
|
registry.shutdown().await.unwrap();
|
||||||
assert_eq!(registry.diagnostics().len(), 1);
|
assert_eq!(registry.diagnostics().len(), 1);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ use manifest::{
|
|||||||
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use crate::feature::background::FeatureBackgroundTaskRegistry;
|
use crate::feature::background::{BackgroundTaskRewriteGuard, FeatureBackgroundTaskRegistry};
|
||||||
use crate::feature::builtin::memory::WorkspaceMemoryBackendError;
|
use crate::feature::builtin::memory::WorkspaceMemoryBackendError;
|
||||||
use crate::feature::builtin::{
|
use crate::feature::builtin::{
|
||||||
MemoryExtractFeature, MemoryExtractState, SessionExploreFeature, SessionExploreState,
|
MemoryExtractFeature, MemoryExtractState, SessionExploreFeature, SessionExploreState,
|
||||||
@@ -2031,7 +2031,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
target: RewindTargetId,
|
target: RewindTargetId,
|
||||||
expected_head_entries: usize,
|
expected_head_entries: usize,
|
||||||
) -> Result<RewindAppliedState, RewindError> {
|
) -> Result<RewindAppliedState, RewindError> {
|
||||||
self.prepare_session_rewrite(SessionRewriteKind::Rewind)
|
let _rewrite_guard = self
|
||||||
|
.prepare_session_rewrite(SessionRewriteKind::Rewind)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RewindError::Invalid(error.to_string()))?;
|
.map_err(|error| RewindError::Invalid(error.to_string()))?;
|
||||||
let loc = self.segment_state.location();
|
let loc = self.segment_state.location();
|
||||||
@@ -3393,7 +3394,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
// state up to that turn). The new SegmentStart replaces the mirror
|
// state up to that turn). The new SegmentStart replaces the mirror
|
||||||
// and is broadcast through the sink so existing subscribers reset
|
// and is broadcast through the sink so existing subscribers reset
|
||||||
// their view.
|
// their view.
|
||||||
self.prepare_session_rewrite(SessionRewriteKind::Fork)
|
let _rewrite_guard = self
|
||||||
|
.prepare_session_rewrite(SessionRewriteKind::Fork)
|
||||||
.await?;
|
.await?;
|
||||||
let w = self.engine.as_ref().unwrap();
|
let w = self.engine.as_ref().unwrap();
|
||||||
let fork_segment_id = session_store::new_segment_id();
|
let fork_segment_id = session_store::new_segment_id();
|
||||||
@@ -3693,9 +3695,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
async fn prepare_session_rewrite(
|
async fn prepare_session_rewrite(
|
||||||
&mut self,
|
&mut self,
|
||||||
kind: SessionRewriteKind,
|
kind: SessionRewriteKind,
|
||||||
) -> Result<(), WorkerError> {
|
) -> Result<BackgroundTaskRewriteGuard, WorkerError> {
|
||||||
self.feature_background_tasks
|
let rewrite_guard = self
|
||||||
.before_session_rewrite()
|
.feature_background_tasks
|
||||||
|
.begin_session_rewrite()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| WorkerError::FeatureLifecycle(error.to_string()))?;
|
.map_err(|error| WorkerError::FeatureLifecycle(error.to_string()))?;
|
||||||
if let Some(hooks) = self.hook_registry.clone() {
|
if let Some(hooks) = self.hook_registry.clone() {
|
||||||
@@ -3723,7 +3726,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(rewrite_guard)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn manual_compact(&mut self) -> Result<ManualCompactResult, WorkerError> {
|
pub async fn manual_compact(&mut self) -> Result<ManualCompactResult, WorkerError> {
|
||||||
@@ -3941,7 +3944,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
/// Runs one parent-owned observable compaction service and returns the new
|
/// Runs one parent-owned observable compaction service and returns the new
|
||||||
/// Segment ID. Lifecycle revisions are committed before they are broadcast.
|
/// Segment ID. Lifecycle revisions are committed before they are broadcast.
|
||||||
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, WorkerError> {
|
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, WorkerError> {
|
||||||
self.prepare_session_rewrite(SessionRewriteKind::Compact)
|
let _rewrite_guard = self
|
||||||
|
.prepare_session_rewrite(SessionRewriteKind::Compact)
|
||||||
.await?;
|
.await?;
|
||||||
let mut lifecycle = CompactionLifecycle {
|
let mut lifecycle = CompactionLifecycle {
|
||||||
schema_version: 2,
|
schema_version: 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user