mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-03 11:57:43 +00:00
1655f3192e
* fix(notify): unify runtime lifecycle coordination * fix(notify): repair lifecycle convergence checks * fix(admin): expose effective notify state (#5097)
1520 lines
57 KiB
Rust
1520 lines
57 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use crate::{
|
|
Event, NotificationError, config_manager::notify_configuration_hint, error::transition_join_error, registry::TargetRegistry,
|
|
runtime_facade::NotifyRuntimeFacade,
|
|
};
|
|
use async_trait::async_trait;
|
|
use rustfs_config::server_config::Config;
|
|
use rustfs_targets::Target;
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
use tokio::sync::{Mutex, RwLock};
|
|
use tokio::task::JoinHandle;
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::{debug, info};
|
|
|
|
type CurrentGeneration = Arc<dyn Fn() -> bool + Send + Sync>;
|
|
type CommitGeneration = Box<dyn FnOnce(bool) + Send>;
|
|
type CleanupFuture = Pin<Box<dyn Future<Output = Result<(), NotificationError>> + Send>>;
|
|
|
|
const LOG_COMPONENT_NOTIFY: &str = "notify";
|
|
const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
|
|
const EVENT_NOTIFY_RUNTIME_LIFECYCLE: &str = "notify_runtime_lifecycle";
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum NotificationRuntimeState {
|
|
LiveOnly,
|
|
TargetsEnabled { generation: u64 },
|
|
Terminated,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum DesiredMode {
|
|
LiveOnly,
|
|
TargetsEnabled,
|
|
Terminated,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct DesiredState {
|
|
cancellation: CancellationToken,
|
|
config: Config,
|
|
generation: u64,
|
|
mode: DesiredMode,
|
|
}
|
|
|
|
struct RuntimeApply {
|
|
cleanup: Option<CleanupFuture>,
|
|
}
|
|
|
|
impl RuntimeApply {
|
|
fn complete() -> Self {
|
|
Self { cleanup: None }
|
|
}
|
|
|
|
fn with_cleanup(cleanup: CleanupFuture) -> Self {
|
|
Self { cleanup: Some(cleanup) }
|
|
}
|
|
|
|
async fn finish(self) -> Result<(), NotificationError> {
|
|
match self.cleanup {
|
|
Some(cleanup) => cleanup.await,
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
enum TransitionResult {
|
|
Ready(Result<(), NotificationError>),
|
|
Running(JoinHandle<Result<(), NotificationError>>),
|
|
}
|
|
|
|
pub struct NotificationLifecycleTransition {
|
|
result: TransitionResult,
|
|
}
|
|
|
|
impl NotificationLifecycleTransition {
|
|
fn ready(result: Result<(), NotificationError>) -> Self {
|
|
Self {
|
|
result: TransitionResult::Ready(result),
|
|
}
|
|
}
|
|
|
|
fn running(handle: JoinHandle<Result<(), NotificationError>>) -> Self {
|
|
Self {
|
|
result: TransitionResult::Running(handle),
|
|
}
|
|
}
|
|
|
|
pub async fn wait(self) -> Result<(), NotificationError> {
|
|
match self.result {
|
|
TransitionResult::Ready(result) => result,
|
|
TransitionResult::Running(handle) => handle.await.map_err(transition_join_error)?,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
trait LifecycleRuntime: Send + Sync {
|
|
async fn cache_config(&self, config: Config);
|
|
|
|
async fn enable_targets(
|
|
&self,
|
|
config: &Config,
|
|
cancellation: &CancellationToken,
|
|
publication_gate: Arc<StdMutex<()>>,
|
|
is_current: CurrentGeneration,
|
|
on_committed: CommitGeneration,
|
|
) -> Result<RuntimeApply, NotificationError>;
|
|
|
|
async fn disable_targets(
|
|
&self,
|
|
publication_gate: Arc<StdMutex<()>>,
|
|
is_current: CurrentGeneration,
|
|
on_committed: CommitGeneration,
|
|
) -> Result<RuntimeApply, NotificationError>;
|
|
}
|
|
|
|
struct DefaultLifecycleRuntime {
|
|
config: Arc<RwLock<Config>>,
|
|
registry: Arc<TargetRegistry>,
|
|
runtime_facade: NotifyRuntimeFacade,
|
|
#[cfg(test)]
|
|
handoff_observer: Option<Arc<dyn Fn() + Send + Sync>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl LifecycleRuntime for DefaultLifecycleRuntime {
|
|
async fn cache_config(&self, config: Config) {
|
|
*self.config.write().await = config;
|
|
}
|
|
|
|
async fn enable_targets(
|
|
&self,
|
|
config: &Config,
|
|
cancellation: &CancellationToken,
|
|
publication_gate: Arc<StdMutex<()>>,
|
|
is_current: CurrentGeneration,
|
|
on_committed: CommitGeneration,
|
|
) -> Result<RuntimeApply, NotificationError> {
|
|
info!(
|
|
event = EVENT_NOTIFY_RUNTIME_LIFECYCLE,
|
|
component = LOG_COMPONENT_NOTIFY,
|
|
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
|
state = "preparing",
|
|
"notify runtime lifecycle"
|
|
);
|
|
|
|
let (targets, creation_failures): (Vec<Box<dyn Target<Event> + Send + Sync>>, Vec<String>) =
|
|
self.registry.create_dormant_targets_from_config(config).await?;
|
|
if targets.is_empty() {
|
|
debug!(
|
|
event = EVENT_NOTIFY_RUNTIME_LIFECYCLE,
|
|
component = LOG_COMPONENT_NOTIFY,
|
|
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
|
state = "idle",
|
|
reason = "no_targets_configured",
|
|
hint = %notify_configuration_hint(),
|
|
"notify runtime lifecycle"
|
|
);
|
|
}
|
|
|
|
let mut prepared = self.runtime_facade.prepare_targets(targets, cancellation).await;
|
|
prepared.extend_creation_failures(creation_failures);
|
|
if !is_current() {
|
|
let runtime_facade = self.runtime_facade.clone();
|
|
return Ok(RuntimeApply::with_cleanup(Box::pin(async move {
|
|
runtime_facade.close_prepared(prepared).await
|
|
})));
|
|
}
|
|
|
|
// Drain sends that cloned the old targets, then keep later sends queued
|
|
// until queue-store ownership and the target list move atomically. A
|
|
// successful current-generation check under publication_gate is the
|
|
// non-cancellable commit barrier: later intents serialize after this
|
|
// handoff instead of abandoning a workerless old runtime.
|
|
let dispatch_guard = self.runtime_facade.pause_dispatch().await;
|
|
{
|
|
let _publication_guard = publication_gate.lock().unwrap_or_else(|err| err.into_inner());
|
|
if !is_current() {
|
|
drop(_publication_guard);
|
|
drop(dispatch_guard);
|
|
let runtime_facade = self.runtime_facade.clone();
|
|
return Ok(RuntimeApply::with_cleanup(Box::pin(async move {
|
|
runtime_facade.close_prepared(prepared).await
|
|
})));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
if let Some(observer) = &self.handoff_observer {
|
|
observer();
|
|
}
|
|
|
|
self.runtime_facade.stop_active_replay_workers().await;
|
|
let (opened, rejected) = self.runtime_facade.open_prepared_stores(prepared).await?;
|
|
let open_complete = rejected.failure_summary().is_none();
|
|
// A newer accepted generation remains queued behind apply_lock and
|
|
// replaces this one next.
|
|
let (detached, activation_rejected) = self
|
|
.runtime_facade
|
|
.commit_prepared(opened, move |activation_complete| on_committed(open_complete && activation_complete))
|
|
.await;
|
|
let activation_failures = [rejected.failure_summary(), activation_rejected.failure_summary()]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect::<Vec<_>>();
|
|
drop(dispatch_guard);
|
|
let runtime_facade = self.runtime_facade.clone();
|
|
|
|
info!(
|
|
event = EVENT_NOTIFY_RUNTIME_LIFECYCLE,
|
|
component = LOG_COMPONENT_NOTIFY,
|
|
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
|
state = "enabled",
|
|
"notify runtime lifecycle"
|
|
);
|
|
Ok(RuntimeApply::with_cleanup(Box::pin(async move {
|
|
let (close_old, close_rejected, close_activation_rejected) = tokio::join!(
|
|
runtime_facade.close_detached_targets(detached),
|
|
runtime_facade.close_prepared(rejected),
|
|
runtime_facade.close_prepared(activation_rejected),
|
|
);
|
|
close_old?;
|
|
close_rejected?;
|
|
close_activation_rejected?;
|
|
if !activation_failures.is_empty() {
|
|
return Err(NotificationError::Initialization(format!(
|
|
"one or more notification targets failed to activate: {}",
|
|
activation_failures.join("; ")
|
|
)));
|
|
}
|
|
Ok(())
|
|
})))
|
|
}
|
|
|
|
async fn disable_targets(
|
|
&self,
|
|
publication_gate: Arc<StdMutex<()>>,
|
|
is_current: CurrentGeneration,
|
|
on_committed: CommitGeneration,
|
|
) -> Result<RuntimeApply, NotificationError> {
|
|
let dispatch_guard = self.runtime_facade.pause_dispatch().await;
|
|
{
|
|
let _publication_guard = publication_gate.lock().unwrap_or_else(|err| err.into_inner());
|
|
if !is_current() {
|
|
drop(_publication_guard);
|
|
drop(dispatch_guard);
|
|
return Ok(RuntimeApply::complete());
|
|
}
|
|
}
|
|
|
|
// The generation check above is the non-cancellable publication
|
|
// barrier. Later intents serialize behind apply_lock while the old
|
|
// replay workers stop, so LiveOnly is not visible until every old
|
|
// worker has joined.
|
|
self.runtime_facade.stop_active_replay_workers().await;
|
|
let detached = self.runtime_facade.commit_disabled(move || on_committed(true)).await;
|
|
drop(dispatch_guard);
|
|
let runtime_facade = self.runtime_facade.clone();
|
|
Ok(RuntimeApply::with_cleanup(Box::pin(async move {
|
|
runtime_facade.close_detached_targets(detached).await
|
|
})))
|
|
}
|
|
}
|
|
|
|
struct LifecycleCoordinatorInner {
|
|
applied_generation: AtomicU64,
|
|
apply_lock: Mutex<()>,
|
|
desired: StdMutex<DesiredState>,
|
|
publication_gate: Arc<StdMutex<()>>,
|
|
runtime: Arc<dyn LifecycleRuntime>,
|
|
state: StdMutex<NotificationRuntimeState>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct NotifyLifecycleCoordinator {
|
|
inner: Arc<LifecycleCoordinatorInner>,
|
|
}
|
|
|
|
impl NotifyLifecycleCoordinator {
|
|
pub(crate) fn new(config: Arc<RwLock<Config>>, registry: Arc<TargetRegistry>, runtime_facade: NotifyRuntimeFacade) -> Self {
|
|
let runtime = Arc::new(DefaultLifecycleRuntime {
|
|
config,
|
|
registry,
|
|
runtime_facade,
|
|
#[cfg(test)]
|
|
handoff_observer: None,
|
|
});
|
|
Self::with_runtime(Config::new(), runtime)
|
|
}
|
|
|
|
fn with_runtime(initial_config: Config, runtime: Arc<dyn LifecycleRuntime>) -> Self {
|
|
Self {
|
|
inner: Arc::new(LifecycleCoordinatorInner {
|
|
applied_generation: AtomicU64::new(0),
|
|
apply_lock: Mutex::new(()),
|
|
desired: StdMutex::new(DesiredState {
|
|
cancellation: CancellationToken::new(),
|
|
config: initial_config,
|
|
generation: 0,
|
|
mode: DesiredMode::LiveOnly,
|
|
}),
|
|
publication_gate: Arc::new(StdMutex::new(())),
|
|
runtime,
|
|
state: StdMutex::new(NotificationRuntimeState::LiveOnly),
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_mode(&self, enabled: bool, config: Option<Config>) -> NotificationLifecycleTransition {
|
|
self.accept(false, move |desired| {
|
|
desired.mode = if enabled {
|
|
DesiredMode::TargetsEnabled
|
|
} else {
|
|
DesiredMode::LiveOnly
|
|
};
|
|
if let Some(config) = config {
|
|
desired.config = config;
|
|
}
|
|
Ok(true)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn update_config(&self, config: Config) -> NotificationLifecycleTransition {
|
|
self.accept(false, move |desired| {
|
|
desired.config = config;
|
|
Ok(true)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn terminate(&self) -> NotificationLifecycleTransition {
|
|
self.accept(true, |desired| {
|
|
if desired.mode == DesiredMode::Terminated {
|
|
return Ok(false);
|
|
}
|
|
desired.mode = DesiredMode::Terminated;
|
|
Ok(true)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn state(&self) -> NotificationRuntimeState {
|
|
*self.inner.state.lock().unwrap_or_else(|err| err.into_inner())
|
|
}
|
|
|
|
pub(crate) fn is_converged(&self) -> bool {
|
|
let desired_generation = self.inner.desired.lock().unwrap_or_else(|err| err.into_inner()).generation;
|
|
self.inner.applied_generation.load(Ordering::Acquire) >= desired_generation
|
|
}
|
|
|
|
fn accept<F>(&self, allow_terminated: bool, update: F) -> NotificationLifecycleTransition
|
|
where
|
|
F: FnOnce(&mut DesiredState) -> Result<bool, NotificationError>,
|
|
{
|
|
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
|
return NotificationLifecycleTransition::ready(Err(NotificationError::Initialization(
|
|
"Notification lifecycle coordinator is unavailable".to_string(),
|
|
)));
|
|
};
|
|
let _publication_guard = self.inner.publication_gate.lock().unwrap_or_else(|err| err.into_inner());
|
|
let mut desired = self.inner.desired.lock().unwrap_or_else(|err| err.into_inner());
|
|
if desired.mode == DesiredMode::Terminated && !allow_terminated {
|
|
return NotificationLifecycleTransition::ready(Err(NotificationError::Initialization(
|
|
"Notification runtime has terminated".to_string(),
|
|
)));
|
|
}
|
|
|
|
let previous = desired.clone();
|
|
let changed = match update(&mut desired) {
|
|
Ok(changed) => changed,
|
|
Err(err) => return NotificationLifecycleTransition::ready(Err(err)),
|
|
};
|
|
if changed {
|
|
let Some(next_generation) = desired.generation.checked_add(1) else {
|
|
*desired = previous;
|
|
return NotificationLifecycleTransition::ready(Err(NotificationError::Initialization(
|
|
"Notification lifecycle generation is exhausted".to_string(),
|
|
)));
|
|
};
|
|
desired.generation = next_generation;
|
|
previous.cancellation.cancel();
|
|
desired.cancellation = CancellationToken::new();
|
|
}
|
|
let accepted = desired.clone();
|
|
drop(desired);
|
|
drop(_publication_guard);
|
|
let inner = Arc::clone(&self.inner);
|
|
NotificationLifecycleTransition::running(runtime.spawn(async move { inner.converge(accepted).await }))
|
|
}
|
|
}
|
|
|
|
impl LifecycleCoordinatorInner {
|
|
async fn converge(self: &Arc<Self>, desired: DesiredState) -> Result<(), NotificationError> {
|
|
let _apply_guard = self.apply_lock.lock().await;
|
|
if self.applied_generation.load(Ordering::Acquire) >= desired.generation {
|
|
return Ok(());
|
|
}
|
|
if !self.current_matches(desired.generation, desired.mode) {
|
|
return Ok(());
|
|
}
|
|
|
|
self.runtime.cache_config(desired.config.clone()).await;
|
|
let current_inner = Arc::clone(self);
|
|
let generation = desired.generation;
|
|
let mode = desired.mode;
|
|
let is_current: CurrentGeneration = Arc::new(move || current_inner.current_matches(generation, mode));
|
|
let publication_gate = self.publication_gate.clone();
|
|
|
|
let state = match desired.mode {
|
|
DesiredMode::LiveOnly => NotificationRuntimeState::LiveOnly,
|
|
DesiredMode::TargetsEnabled => NotificationRuntimeState::TargetsEnabled {
|
|
generation: desired.generation,
|
|
},
|
|
DesiredMode::Terminated => NotificationRuntimeState::Terminated,
|
|
};
|
|
let committed_inner = Arc::clone(self);
|
|
let on_committed: CommitGeneration =
|
|
Box::new(move |complete| committed_inner.mark_published(generation, state, complete));
|
|
|
|
let applied = match desired.mode {
|
|
DesiredMode::LiveOnly | DesiredMode::Terminated => {
|
|
self.runtime
|
|
.disable_targets(publication_gate, is_current, on_committed)
|
|
.await?
|
|
}
|
|
DesiredMode::TargetsEnabled => {
|
|
self.runtime
|
|
.enable_targets(&desired.config, &desired.cancellation, publication_gate, is_current, on_committed)
|
|
.await?
|
|
}
|
|
};
|
|
drop(_apply_guard);
|
|
applied.finish().await
|
|
}
|
|
|
|
fn current_matches(&self, generation: u64, mode: DesiredMode) -> bool {
|
|
let desired = self.desired.lock().unwrap_or_else(|err| err.into_inner());
|
|
desired.generation == generation && desired.mode == mode
|
|
}
|
|
|
|
fn mark_published(&self, generation: u64, state: NotificationRuntimeState, complete: bool) {
|
|
*self.state.lock().unwrap_or_else(|err| err.into_inner()) = state;
|
|
if complete {
|
|
self.applied_generation.store(generation, Ordering::Release);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
CommitGeneration, CurrentGeneration, DefaultLifecycleRuntime, LifecycleRuntime, NotificationRuntimeState,
|
|
NotifyLifecycleCoordinator, RuntimeApply,
|
|
};
|
|
use crate::{
|
|
Event, NotificationError, integration::NotificationMetrics, notifier::EventNotifier, registry::TargetRegistry,
|
|
rule_engine::NotifyRuleEngine, runtime_facade::NotifyRuntimeFacade, runtime_view::NotifyRuntimeView,
|
|
};
|
|
use async_trait::async_trait;
|
|
use rustfs_config::ENABLE_KEY;
|
|
use rustfs_config::server_config::{Config, KVS};
|
|
use rustfs_targets::arn::TargetID;
|
|
use rustfs_targets::store::{Key, QueueStore, Store};
|
|
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
|
use rustfs_targets::{
|
|
EventName, ReplayWorkerManager, StoreError, Target, TargetError, TargetPluginDescriptor, TargetPluginRegistry,
|
|
};
|
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
use tokio::sync::{Notify, RwLock, Semaphore, mpsc};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
const BLOCKING_INIT_TARGET_TYPE: &str = "lifecycle_blocking_init";
|
|
const RETRY_INIT_TARGET_TYPE: &str = "lifecycle_retry_init";
|
|
const REPLAY_TARGET_TYPE: &str = "lifecycle_replay";
|
|
|
|
struct BlockingInitState {
|
|
close_calls: AtomicUsize,
|
|
init_entered: Notify,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct BlockingInitTarget {
|
|
id: TargetID,
|
|
state: Arc<BlockingInitState>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Target<Event> for BlockingInitTarget {
|
|
fn id(&self) -> TargetID {
|
|
self.id.clone()
|
|
}
|
|
|
|
async fn is_active(&self) -> Result<bool, TargetError> {
|
|
Ok(true)
|
|
}
|
|
|
|
async fn save(&self, _event: Arc<EntityTarget<Event>>) -> Result<(), TargetError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn close(&self) -> Result<(), TargetError> {
|
|
self.state.close_calls.fetch_add(1, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
|
|
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
|
None
|
|
}
|
|
|
|
fn clone_dyn(&self) -> Box<dyn Target<Event> + Send + Sync> {
|
|
Box::new(self.clone())
|
|
}
|
|
|
|
async fn init(&self) -> Result<(), TargetError> {
|
|
self.state.init_entered.notify_one();
|
|
std::future::pending::<()>().await;
|
|
Ok(())
|
|
}
|
|
|
|
fn is_enabled(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct RetryInitTarget {
|
|
fail_once: Arc<AtomicBool>,
|
|
id: TargetID,
|
|
should_fail: bool,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Target<Event> for RetryInitTarget {
|
|
fn id(&self) -> TargetID {
|
|
self.id.clone()
|
|
}
|
|
|
|
async fn is_active(&self) -> Result<bool, TargetError> {
|
|
Ok(true)
|
|
}
|
|
|
|
async fn save(&self, _event: Arc<EntityTarget<Event>>) -> Result<(), TargetError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn close(&self) -> Result<(), TargetError> {
|
|
Ok(())
|
|
}
|
|
|
|
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
|
None
|
|
}
|
|
|
|
fn clone_dyn(&self) -> Box<dyn Target<Event> + Send + Sync> {
|
|
Box::new(self.clone())
|
|
}
|
|
|
|
async fn init(&self) -> Result<(), TargetError> {
|
|
if self.should_fail && self.fail_once.swap(false, Ordering::AcqRel) {
|
|
return Err(TargetError::Initialization("forced transient init failure".to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn is_enabled(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
struct ReplayState {
|
|
active_workers: AtomicUsize,
|
|
completed_deliveries: AtomicUsize,
|
|
created_targets: AtomicUsize,
|
|
delivery_attempts: AtomicUsize,
|
|
delivery_release: Notify,
|
|
delivered_bodies: StdMutex<Vec<Vec<u8>>>,
|
|
blocked_init_generation: usize,
|
|
blocked_init_release: Notify,
|
|
init_tx: mpsc::UnboundedSender<usize>,
|
|
list_tx: mpsc::UnboundedSender<usize>,
|
|
peak_workers: AtomicUsize,
|
|
send_tx: mpsc::UnboundedSender<usize>,
|
|
}
|
|
|
|
struct ReplayWorkerLease {
|
|
state: Arc<ReplayState>,
|
|
}
|
|
|
|
impl ReplayWorkerLease {
|
|
fn acquire(state: Arc<ReplayState>) -> Self {
|
|
let active = state.active_workers.fetch_add(1, Ordering::SeqCst) + 1;
|
|
let mut peak = state.peak_workers.load(Ordering::SeqCst);
|
|
while active > peak {
|
|
match state
|
|
.peak_workers
|
|
.compare_exchange_weak(peak, active, Ordering::SeqCst, Ordering::SeqCst)
|
|
{
|
|
Ok(_) => break,
|
|
Err(observed) => peak = observed,
|
|
}
|
|
}
|
|
Self { state }
|
|
}
|
|
}
|
|
|
|
impl Drop for ReplayWorkerLease {
|
|
fn drop(&mut self) {
|
|
self.state.active_workers.fetch_sub(1, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ObservedQueueStore {
|
|
generation: usize,
|
|
inner: QueueStore<QueuedPayload>,
|
|
state: Arc<ReplayState>,
|
|
_worker_lease: Option<Arc<ReplayWorkerLease>>,
|
|
}
|
|
|
|
impl Store<QueuedPayload> for ObservedQueueStore {
|
|
type Error = StoreError;
|
|
type Key = Key;
|
|
|
|
fn open(&self) -> Result<(), Self::Error> {
|
|
self.inner.open()
|
|
}
|
|
|
|
fn put(&self, item: Arc<QueuedPayload>) -> Result<Self::Key, Self::Error> {
|
|
self.inner.put(item)
|
|
}
|
|
|
|
fn put_multiple(&self, items: Vec<QueuedPayload>) -> Result<Self::Key, Self::Error> {
|
|
self.inner.put_multiple(items)
|
|
}
|
|
|
|
fn put_raw(&self, data: &[u8]) -> Result<Self::Key, Self::Error> {
|
|
self.inner.put_raw(data)
|
|
}
|
|
|
|
fn get(&self, key: &Self::Key) -> Result<QueuedPayload, Self::Error> {
|
|
self.inner.get(key)
|
|
}
|
|
|
|
fn get_multiple(&self, key: &Self::Key) -> Result<Vec<QueuedPayload>, Self::Error> {
|
|
self.inner.get_multiple(key)
|
|
}
|
|
|
|
fn get_raw(&self, key: &Self::Key) -> Result<Vec<u8>, Self::Error> {
|
|
self.inner.get_raw(key)
|
|
}
|
|
|
|
fn del(&self, key: &Self::Key) -> Result<(), Self::Error> {
|
|
self.inner.del(key)
|
|
}
|
|
|
|
fn delete(&self) -> Result<(), Self::Error> {
|
|
self.inner.delete()
|
|
}
|
|
|
|
fn list(&self) -> Vec<Self::Key> {
|
|
let _ = self.state.list_tx.send(self.generation);
|
|
self.inner.list()
|
|
}
|
|
|
|
fn len(&self) -> usize {
|
|
self.inner.len()
|
|
}
|
|
|
|
fn is_empty(&self) -> bool {
|
|
self.inner.is_empty()
|
|
}
|
|
|
|
fn boxed_clone(&self) -> Box<dyn Store<QueuedPayload, Error = Self::Error, Key = Self::Key> + Send + Sync> {
|
|
Box::new(Self {
|
|
generation: self.generation,
|
|
inner: self.inner.clone(),
|
|
state: self.state.clone(),
|
|
_worker_lease: Some(Arc::new(ReplayWorkerLease::acquire(self.state.clone()))),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ReplayTarget {
|
|
id: TargetID,
|
|
generation: usize,
|
|
state: Arc<ReplayState>,
|
|
store: ObservedQueueStore,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Target<Event> for ReplayTarget {
|
|
fn id(&self) -> TargetID {
|
|
self.id.clone()
|
|
}
|
|
|
|
async fn is_active(&self) -> Result<bool, TargetError> {
|
|
Ok(true)
|
|
}
|
|
|
|
async fn save(&self, _event: Arc<EntityTarget<Event>>) -> Result<(), TargetError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_raw_from_store(&self, _key: Key, body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
|
let attempt = self.state.delivery_attempts.fetch_add(1, Ordering::SeqCst) + 1;
|
|
let _ = self.state.send_tx.send(attempt);
|
|
self.state.delivery_release.notified().await;
|
|
self.state
|
|
.delivered_bodies
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner())
|
|
.push(body);
|
|
self.state.completed_deliveries.fetch_add(1, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
|
|
async fn close(&self) -> Result<(), TargetError> {
|
|
Ok(())
|
|
}
|
|
|
|
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
|
Some(&self.store)
|
|
}
|
|
|
|
fn clone_dyn(&self) -> Box<dyn Target<Event> + Send + Sync> {
|
|
Box::new(self.clone())
|
|
}
|
|
|
|
async fn init(&self) -> Result<(), TargetError> {
|
|
let _ = self.state.init_tx.send(self.generation);
|
|
if self.generation == self.state.blocked_init_generation {
|
|
self.state.blocked_init_release.notified().await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn is_enabled(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
struct TestRuntime {
|
|
block_cleanup: AtomicBool,
|
|
block_enable: AtomicBool,
|
|
cached_config: StdMutex<Config>,
|
|
cleanup_entered: Arc<Notify>,
|
|
cleanup_release: Arc<Notify>,
|
|
disable_calls: AtomicUsize,
|
|
enable_calls: AtomicUsize,
|
|
enable_entered: Notify,
|
|
fail_enable: AtomicBool,
|
|
enable_release: Notify,
|
|
enabled_configs: StdMutex<Vec<Config>>,
|
|
}
|
|
|
|
impl TestRuntime {
|
|
fn new(initial_config: Config) -> Self {
|
|
Self {
|
|
block_cleanup: AtomicBool::new(false),
|
|
block_enable: AtomicBool::new(false),
|
|
cached_config: StdMutex::new(initial_config),
|
|
cleanup_entered: Arc::new(Notify::new()),
|
|
cleanup_release: Arc::new(Notify::new()),
|
|
disable_calls: AtomicUsize::new(0),
|
|
enable_calls: AtomicUsize::new(0),
|
|
enable_entered: Notify::new(),
|
|
fail_enable: AtomicBool::new(false),
|
|
enable_release: Notify::new(),
|
|
enabled_configs: StdMutex::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
fn block_next_enable(&self) {
|
|
self.block_enable.store(true, Ordering::Release);
|
|
}
|
|
|
|
fn block_next_cleanup(&self) {
|
|
self.block_cleanup.store(true, Ordering::Release);
|
|
}
|
|
|
|
fn fail_next_enable(&self) {
|
|
self.fail_enable.store(true, Ordering::Release);
|
|
}
|
|
|
|
fn last_enabled_config(&self) -> Option<Config> {
|
|
self.enabled_configs
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner())
|
|
.last()
|
|
.cloned()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl LifecycleRuntime for TestRuntime {
|
|
async fn cache_config(&self, config: Config) {
|
|
*self.cached_config.lock().unwrap_or_else(|err| err.into_inner()) = config;
|
|
}
|
|
|
|
async fn enable_targets(
|
|
&self,
|
|
config: &Config,
|
|
_cancellation: &CancellationToken,
|
|
publication_gate: Arc<StdMutex<()>>,
|
|
is_current: CurrentGeneration,
|
|
on_committed: CommitGeneration,
|
|
) -> Result<RuntimeApply, NotificationError> {
|
|
self.enable_calls.fetch_add(1, Ordering::SeqCst);
|
|
if self.block_enable.swap(false, Ordering::AcqRel) {
|
|
self.enable_entered.notify_one();
|
|
self.enable_release.notified().await;
|
|
}
|
|
if self.fail_enable.swap(false, Ordering::AcqRel) {
|
|
return Err(NotificationError::Initialization("forced enable failure".to_string()));
|
|
}
|
|
let _publication_guard = publication_gate.lock().unwrap_or_else(|err| err.into_inner());
|
|
if !is_current() {
|
|
return Ok(RuntimeApply::complete());
|
|
}
|
|
self.enabled_configs
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner())
|
|
.push(config.clone());
|
|
on_committed(true);
|
|
if self.block_cleanup.swap(false, Ordering::AcqRel) {
|
|
let cleanup_entered = self.cleanup_entered.clone();
|
|
let cleanup_release = self.cleanup_release.clone();
|
|
return Ok(RuntimeApply::with_cleanup(Box::pin(async move {
|
|
cleanup_entered.notify_one();
|
|
cleanup_release.notified().await;
|
|
Ok(())
|
|
})));
|
|
}
|
|
Ok(RuntimeApply::complete())
|
|
}
|
|
|
|
async fn disable_targets(
|
|
&self,
|
|
publication_gate: Arc<StdMutex<()>>,
|
|
is_current: CurrentGeneration,
|
|
on_committed: CommitGeneration,
|
|
) -> Result<RuntimeApply, NotificationError> {
|
|
let _publication_guard = publication_gate.lock().unwrap_or_else(|err| err.into_inner());
|
|
if !is_current() {
|
|
return Ok(RuntimeApply::complete());
|
|
}
|
|
self.disable_calls.fetch_add(1, Ordering::SeqCst);
|
|
on_committed(true);
|
|
Ok(RuntimeApply::complete())
|
|
}
|
|
}
|
|
|
|
fn config_with_target(name: &str) -> Config {
|
|
let mut config = Config::new();
|
|
config
|
|
.0
|
|
.entry("notify_webhook".to_string())
|
|
.or_default()
|
|
.insert(name.to_string(), KVS::default());
|
|
config
|
|
}
|
|
|
|
fn config_with_enabled_test_target(target_type: &str, name: &str) -> Config {
|
|
let mut target = KVS::new();
|
|
target.insert(ENABLE_KEY.to_string(), "on".to_string());
|
|
let mut config = Config::new();
|
|
config
|
|
.0
|
|
.entry(format!("notify_{target_type}"))
|
|
.or_default()
|
|
.insert(name.to_string(), target);
|
|
config
|
|
}
|
|
|
|
fn real_runtime(
|
|
initial_config: Config,
|
|
registry: Arc<TargetRegistry>,
|
|
handoff_observer: Option<Arc<dyn Fn() + Send + Sync>>,
|
|
) -> (NotifyLifecycleCoordinator, NotifyRuntimeView, Arc<RwLock<ReplayWorkerManager>>) {
|
|
let metrics = Arc::new(NotificationMetrics::new());
|
|
let rule_engine = NotifyRuleEngine::new();
|
|
let notifier = Arc::new(EventNotifier::new(metrics.clone(), rule_engine));
|
|
let target_list = notifier.target_list();
|
|
let replay_workers = Arc::new(RwLock::new(ReplayWorkerManager::new()));
|
|
let runtime_view = NotifyRuntimeView::new(target_list.clone(), replay_workers.clone());
|
|
let runtime_facade = NotifyRuntimeFacade::new_with_dispatch_gate(
|
|
target_list,
|
|
replay_workers.clone(),
|
|
notifier.dispatch_gate(),
|
|
Arc::new(Semaphore::new(4)),
|
|
metrics,
|
|
);
|
|
let config = Arc::new(RwLock::new(initial_config.clone()));
|
|
let runtime = Arc::new(DefaultLifecycleRuntime {
|
|
config,
|
|
registry,
|
|
runtime_facade,
|
|
handoff_observer,
|
|
});
|
|
(
|
|
NotifyLifecycleCoordinator::with_runtime(initial_config, runtime),
|
|
runtime_view,
|
|
replay_workers,
|
|
)
|
|
}
|
|
|
|
fn blocking_init_runtime() -> (
|
|
Arc<BlockingInitState>,
|
|
NotifyLifecycleCoordinator,
|
|
NotifyRuntimeView,
|
|
Arc<AtomicUsize>,
|
|
Config,
|
|
) {
|
|
let state = Arc::new(BlockingInitState {
|
|
close_calls: AtomicUsize::new(0),
|
|
init_entered: Notify::new(),
|
|
});
|
|
let mut plugins = TargetPluginRegistry::<Event>::new();
|
|
let factory_state = state.clone();
|
|
plugins.register(TargetPluginDescriptor::new(
|
|
BLOCKING_INIT_TARGET_TYPE,
|
|
&[ENABLE_KEY],
|
|
|_config| Ok(()),
|
|
move |id, _config| {
|
|
Ok(Box::new(BlockingInitTarget {
|
|
id: TargetID::new(id, BLOCKING_INIT_TARGET_TYPE.to_string()),
|
|
state: factory_state.clone(),
|
|
}))
|
|
},
|
|
));
|
|
let config = config_with_enabled_test_target(BLOCKING_INIT_TARGET_TYPE, "primary");
|
|
let handoff_count = Arc::new(AtomicUsize::new(0));
|
|
let observer_count = handoff_count.clone();
|
|
let (coordinator, runtime_view, _) = real_runtime(
|
|
config.clone(),
|
|
Arc::new(TargetRegistry::with_plugins(plugins)),
|
|
Some(Arc::new(move || {
|
|
observer_count.fetch_add(1, Ordering::SeqCst);
|
|
})),
|
|
);
|
|
(state, coordinator, runtime_view, handoff_count, config)
|
|
}
|
|
|
|
async fn recv_until_generation(receiver: &mut mpsc::UnboundedReceiver<usize>, expected: usize, context: &str) {
|
|
loop {
|
|
let generation = receiver.recv().await.expect(context);
|
|
if generation == expected {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn coordinator(runtime: Arc<TestRuntime>, initial_config: Config) -> NotifyLifecycleCoordinator {
|
|
NotifyLifecycleCoordinator::with_runtime(initial_config, runtime)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn slow_enable_then_disable_converges_to_live_only() {
|
|
let initial = config_with_target("initial");
|
|
let runtime = Arc::new(TestRuntime::new(initial.clone()));
|
|
runtime.block_next_enable();
|
|
let coordinator = coordinator(runtime.clone(), initial);
|
|
|
|
let enable = coordinator.set_mode(true, None);
|
|
runtime.enable_entered.notified().await;
|
|
let disable = coordinator.set_mode(false, None);
|
|
runtime.enable_release.notify_one();
|
|
|
|
enable.wait().await.expect("enable transition should finish");
|
|
disable.wait().await.expect("disable transition should finish");
|
|
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::LiveOnly);
|
|
assert_eq!(runtime.enable_calls.load(Ordering::SeqCst), 1);
|
|
assert_eq!(runtime.disable_calls.load(Ordering::SeqCst), 1);
|
|
assert_eq!(runtime.last_enabled_config(), None, "superseded enable must not publish targets");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn real_runtime_disable_supersedes_target_init_and_leaves_no_runtime_state() {
|
|
let (state, coordinator, runtime_view, handoff_count, config) = blocking_init_runtime();
|
|
|
|
let enable = coordinator.set_mode(true, Some(config));
|
|
state.init_entered.notified().await;
|
|
let disable = coordinator.set_mode(false, None);
|
|
|
|
enable.wait().await.expect("superseded real enable should finish");
|
|
disable.wait().await.expect("real disable should converge");
|
|
|
|
let status = runtime_view.runtime_status_snapshot().await;
|
|
assert_eq!(status.target_count, 0);
|
|
assert_eq!(status.replay_worker_count, 0);
|
|
assert_eq!(state.close_calls.load(Ordering::SeqCst), 1);
|
|
assert_eq!(handoff_count.load(Ordering::SeqCst), 0);
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::LiveOnly);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn real_runtime_disable_publishes_live_only_after_replay_join() {
|
|
let (coordinator, _, replay_workers) = real_runtime(Config::new(), Arc::new(TargetRegistry::new()), None);
|
|
coordinator
|
|
.set_mode(true, Some(Config::new()))
|
|
.wait()
|
|
.await
|
|
.expect("empty target runtime should enable");
|
|
|
|
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
|
|
let stop_entered = Arc::new(Notify::new());
|
|
let stop_release = Arc::new(Notify::new());
|
|
let worker_entered = stop_entered.clone();
|
|
let worker_release = stop_release.clone();
|
|
let join = tokio::spawn(async move {
|
|
cancel_rx.recv().await.expect("worker should receive cancellation");
|
|
worker_entered.notify_one();
|
|
worker_release.notified().await;
|
|
});
|
|
replay_workers
|
|
.write()
|
|
.await
|
|
.insert_with_handle("blocking-replay".to_string(), cancel_tx, join);
|
|
|
|
let disable = coordinator.set_mode(false, None);
|
|
stop_entered.notified().await;
|
|
assert_eq!(
|
|
coordinator.state(),
|
|
NotificationRuntimeState::TargetsEnabled { generation: 1 },
|
|
"LiveOnly must not publish while an old replay worker is still running"
|
|
);
|
|
|
|
stop_release.notify_one();
|
|
disable.wait().await.expect("disable should finish after replay joins");
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::LiveOnly);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn real_runtime_terminate_cancels_target_init_and_is_final() {
|
|
let (state, coordinator, runtime_view, handoff_count, config) = blocking_init_runtime();
|
|
|
|
let enable = coordinator.set_mode(true, Some(config));
|
|
state.init_entered.notified().await;
|
|
let terminate = coordinator.terminate();
|
|
|
|
enable.wait().await.expect("superseded real enable should finish");
|
|
terminate.wait().await.expect("real terminate should converge");
|
|
|
|
let status = runtime_view.runtime_status_snapshot().await;
|
|
assert_eq!(status.target_count, 0);
|
|
assert_eq!(status.replay_worker_count, 0);
|
|
assert_eq!(state.close_calls.load(Ordering::SeqCst), 1);
|
|
assert_eq!(handoff_count.load(Ordering::SeqCst), 0);
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::Terminated);
|
|
let err = coordinator
|
|
.set_mode(false, None)
|
|
.wait()
|
|
.await
|
|
.expect_err("terminated runtime cannot be restarted");
|
|
assert!(matches!(
|
|
err,
|
|
NotificationError::Initialization(detail) if detail == "Notification runtime has terminated"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn partial_activation_reports_error_and_same_config_can_retry() {
|
|
let fail_once = Arc::new(AtomicBool::new(true));
|
|
let mut plugins = TargetPluginRegistry::<Event>::new();
|
|
let factory_fail_once = fail_once.clone();
|
|
plugins.register(TargetPluginDescriptor::new(
|
|
RETRY_INIT_TARGET_TYPE,
|
|
&[ENABLE_KEY],
|
|
|_config| Ok(()),
|
|
move |id, _config| {
|
|
Ok(Box::new(RetryInitTarget {
|
|
should_fail: id == "bad",
|
|
id: TargetID::new(id, RETRY_INIT_TARGET_TYPE.to_string()),
|
|
fail_once: factory_fail_once.clone(),
|
|
}))
|
|
},
|
|
));
|
|
let mut config = config_with_enabled_test_target(RETRY_INIT_TARGET_TYPE, "good");
|
|
let mut bad = KVS::new();
|
|
bad.insert(ENABLE_KEY.to_string(), "on".to_string());
|
|
config
|
|
.0
|
|
.get_mut(&format!("notify_{RETRY_INIT_TARGET_TYPE}"))
|
|
.expect("test target subsystem should exist")
|
|
.insert("bad".to_string(), bad);
|
|
let (coordinator, runtime_view, _) = real_runtime(config.clone(), Arc::new(TargetRegistry::with_plugins(plugins)), None);
|
|
|
|
coordinator
|
|
.set_mode(true, Some(config.clone()))
|
|
.wait()
|
|
.await
|
|
.expect_err("the originating transition must report the failed target");
|
|
let partial = runtime_view.runtime_status_snapshot().await;
|
|
assert_eq!(partial.target_count, 1, "the healthy target should remain fault-isolated and active");
|
|
assert!(!coordinator.is_converged(), "a partial generation must remain retryable");
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::TargetsEnabled { generation: 1 });
|
|
|
|
coordinator
|
|
.update_config(config)
|
|
.wait()
|
|
.await
|
|
.expect("the same persisted config should recover after the transient failure");
|
|
let recovered = runtime_view.runtime_status_snapshot().await;
|
|
assert_eq!(recovered.target_count, 2);
|
|
assert!(coordinator.is_converged());
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::TargetsEnabled { generation: 2 });
|
|
|
|
coordinator
|
|
.set_mode(false, None)
|
|
.wait()
|
|
.await
|
|
.expect("test runtime should shut down");
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn real_runtime_reload_never_overlaps_replay_workers_or_redelivers_queued_payload() {
|
|
let queue_root = tempfile::tempdir().expect("queue root should be created");
|
|
let queue_dir = queue_root.path().join("shared-queue");
|
|
let body = br#"{"x":1}"#.to_vec();
|
|
let seed_store = QueueStore::<QueuedPayload>::new_with_compression(&queue_dir, 16, ".event", false);
|
|
seed_store.open().expect("seed queue should open");
|
|
let meta = QueuedPayloadMeta::new(
|
|
EventName::ObjectCreatedPut,
|
|
"bucket-a".to_string(),
|
|
"obj.txt".to_string(),
|
|
"application/json",
|
|
body.len(),
|
|
);
|
|
let payload = QueuedPayload::new(meta, body.clone());
|
|
seed_store
|
|
.put_raw(&payload.encode().expect("queued payload should encode"))
|
|
.expect("queued payload should be seeded");
|
|
assert_eq!(seed_store.len(), 1);
|
|
drop(seed_store);
|
|
|
|
let (init_tx, mut init_rx) = mpsc::unbounded_channel();
|
|
let (list_tx, mut list_rx) = mpsc::unbounded_channel();
|
|
let (send_tx, mut send_rx) = mpsc::unbounded_channel();
|
|
let state = Arc::new(ReplayState {
|
|
active_workers: AtomicUsize::new(0),
|
|
blocked_init_generation: 3,
|
|
blocked_init_release: Notify::new(),
|
|
completed_deliveries: AtomicUsize::new(0),
|
|
created_targets: AtomicUsize::new(0),
|
|
delivery_attempts: AtomicUsize::new(0),
|
|
delivery_release: Notify::new(),
|
|
delivered_bodies: StdMutex::new(Vec::new()),
|
|
init_tx,
|
|
list_tx,
|
|
peak_workers: AtomicUsize::new(0),
|
|
send_tx,
|
|
});
|
|
let mut plugins = TargetPluginRegistry::<Event>::new();
|
|
let factory_state = state.clone();
|
|
let factory_queue_dir = queue_dir.clone();
|
|
plugins.register(TargetPluginDescriptor::new(
|
|
REPLAY_TARGET_TYPE,
|
|
&[ENABLE_KEY],
|
|
|_config| Ok(()),
|
|
move |id, _config| {
|
|
let generation = factory_state.created_targets.fetch_add(1, Ordering::SeqCst) + 1;
|
|
Ok(Box::new(ReplayTarget {
|
|
id: TargetID::new(id, REPLAY_TARGET_TYPE.to_string()),
|
|
generation,
|
|
state: factory_state.clone(),
|
|
store: ObservedQueueStore {
|
|
generation,
|
|
inner: QueueStore::new_with_compression(&factory_queue_dir, 16, ".event", false),
|
|
state: factory_state.clone(),
|
|
_worker_lease: None,
|
|
},
|
|
}))
|
|
},
|
|
));
|
|
|
|
let (handoff_tx, mut handoff_rx) = mpsc::unbounded_channel();
|
|
let handoff_count = Arc::new(AtomicUsize::new(0));
|
|
let observer_count = handoff_count.clone();
|
|
let handoff_observer: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
|
|
let generation = observer_count.fetch_add(1, Ordering::SeqCst) + 1;
|
|
let _ = handoff_tx.send(generation);
|
|
});
|
|
let config = config_with_enabled_test_target(REPLAY_TARGET_TYPE, "primary");
|
|
let (coordinator, runtime_view, _) =
|
|
real_runtime(config.clone(), Arc::new(TargetRegistry::with_plugins(plugins)), Some(handoff_observer));
|
|
|
|
coordinator
|
|
.set_mode(true, Some(config.clone()))
|
|
.wait()
|
|
.await
|
|
.expect("initial real runtime enable should succeed");
|
|
assert_eq!(init_rx.recv().await.expect("first target init should be observed"), 1);
|
|
assert_eq!(handoff_rx.recv().await.expect("first handoff should be observed"), 1);
|
|
recv_until_generation(&mut list_rx, 1, "first replay worker should scan the queue").await;
|
|
|
|
let status = runtime_view.runtime_status_snapshot().await;
|
|
assert_eq!(status.target_count, 1);
|
|
assert_eq!(status.replay_worker_count, 1);
|
|
assert_eq!(state.active_workers.load(Ordering::SeqCst), 1);
|
|
assert_eq!(state.peak_workers.load(Ordering::SeqCst), 1);
|
|
|
|
tokio::time::advance(std::time::Duration::from_secs(6)).await;
|
|
assert_eq!(send_rx.recv().await.expect("seeded payload delivery should start"), 1);
|
|
|
|
let second_generation = coordinator.update_config(config.clone());
|
|
assert_eq!(init_rx.recv().await.expect("replacement target init should be observed"), 2);
|
|
assert_eq!(handoff_rx.recv().await.expect("replacement handoff should be observed"), 2);
|
|
|
|
let status_during_handoff = runtime_view.runtime_status_snapshot().await;
|
|
assert_eq!(status_during_handoff.target_count, 1);
|
|
assert_eq!(status_during_handoff.replay_worker_count, 0);
|
|
assert_eq!(state.active_workers.load(Ordering::SeqCst), 1);
|
|
assert_eq!(state.peak_workers.load(Ordering::SeqCst), 1);
|
|
|
|
let third_generation = coordinator.update_config(config);
|
|
state.delivery_release.notify_one();
|
|
second_generation
|
|
.wait()
|
|
.await
|
|
.expect("second generation should commit after crossing the handoff barrier");
|
|
assert_eq!(init_rx.recv().await.expect("third target init should be observed"), 3);
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::TargetsEnabled { generation: 2 });
|
|
recv_until_generation(&mut list_rx, 2, "second-generation replay worker should scan the queue").await;
|
|
|
|
let status_after_second_generation = runtime_view.runtime_status_snapshot().await;
|
|
assert_eq!(status_after_second_generation.target_count, 1);
|
|
assert_eq!(status_after_second_generation.replay_worker_count, 1);
|
|
assert_eq!(state.active_workers.load(Ordering::SeqCst), 1);
|
|
assert_eq!(state.peak_workers.load(Ordering::SeqCst), 1);
|
|
|
|
state.blocked_init_release.notify_one();
|
|
assert_eq!(handoff_rx.recv().await.expect("third handoff should be observed"), 3);
|
|
third_generation
|
|
.wait()
|
|
.await
|
|
.expect("third generation should converge after the committed second generation");
|
|
recv_until_generation(&mut list_rx, 3, "third-generation replay worker should scan the queue").await;
|
|
|
|
assert_eq!(state.delivery_attempts.load(Ordering::SeqCst), 1);
|
|
assert_eq!(state.completed_deliveries.load(Ordering::SeqCst), 1);
|
|
assert_eq!(state.peak_workers.load(Ordering::SeqCst), 1);
|
|
assert_eq!(state.active_workers.load(Ordering::SeqCst), 1);
|
|
assert_eq!(*state.delivered_bodies.lock().unwrap_or_else(|err| err.into_inner()), vec![body]);
|
|
let status_after_reload = runtime_view.runtime_status_snapshot().await;
|
|
assert_eq!(status_after_reload.target_count, 1);
|
|
assert_eq!(status_after_reload.replay_worker_count, 1);
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::TargetsEnabled { generation: 3 });
|
|
|
|
tokio::time::advance(std::time::Duration::from_secs(6)).await;
|
|
recv_until_generation(&mut list_rx, 3, "third-generation replay worker should rescan after the batch timeout").await;
|
|
assert!(matches!(send_rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)));
|
|
|
|
coordinator
|
|
.set_mode(false, None)
|
|
.wait()
|
|
.await
|
|
.expect("real runtime disable should stop the replacement worker");
|
|
let stopped = runtime_view.runtime_status_snapshot().await;
|
|
assert_eq!(stopped.target_count, 0);
|
|
assert_eq!(stopped.replay_worker_count, 0);
|
|
assert_eq!(state.active_workers.load(Ordering::SeqCst), 0);
|
|
|
|
let verify_store = QueueStore::<QueuedPayload>::new_with_compression(&queue_dir, 16, ".event", false);
|
|
verify_store.open().expect("queue should reopen after runtime shutdown");
|
|
assert_eq!(verify_store.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn later_config_generation_wins_after_slow_reload() {
|
|
let first = config_with_target("first");
|
|
let second = config_with_target("second");
|
|
let runtime = Arc::new(TestRuntime::new(first.clone()));
|
|
runtime.block_next_enable();
|
|
let coordinator = coordinator(runtime.clone(), first.clone());
|
|
|
|
let enable = coordinator.set_mode(true, Some(first));
|
|
runtime.enable_entered.notified().await;
|
|
let reload = coordinator.update_config(second.clone());
|
|
runtime.enable_release.notify_one();
|
|
|
|
enable.wait().await.expect("first generation should finish");
|
|
reload.wait().await.expect("latest generation should finish");
|
|
|
|
assert_eq!(runtime.last_enabled_config(), Some(second));
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::TargetsEnabled { generation: 2 });
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn accepted_transition_survives_caller_cancellation() {
|
|
let initial = config_with_target("initial");
|
|
let runtime = Arc::new(TestRuntime::new(initial.clone()));
|
|
runtime.block_next_enable();
|
|
let coordinator = coordinator(runtime.clone(), initial);
|
|
|
|
let receipt = coordinator.set_mode(true, None);
|
|
runtime.enable_entered.notified().await;
|
|
drop(receipt);
|
|
runtime.enable_release.notify_one();
|
|
|
|
let apply_guard = coordinator.inner.apply_lock.lock().await;
|
|
drop(apply_guard);
|
|
assert!(matches!(coordinator.state(), NotificationRuntimeState::TargetsEnabled { generation: 1 }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_config_update_is_cached_until_reenabled() {
|
|
let initial = config_with_target("initial");
|
|
let latest = config_with_target("latest");
|
|
let runtime = Arc::new(TestRuntime::new(initial.clone()));
|
|
let coordinator = coordinator(runtime.clone(), initial);
|
|
|
|
coordinator
|
|
.update_config(latest.clone())
|
|
.wait()
|
|
.await
|
|
.expect("disabled update should be cached");
|
|
assert_eq!(runtime.enable_calls.load(Ordering::SeqCst), 0);
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::LiveOnly);
|
|
|
|
coordinator
|
|
.set_mode(true, None)
|
|
.wait()
|
|
.await
|
|
.expect("re-enable should use cached config");
|
|
assert_eq!(runtime.last_enabled_config(), Some(latest));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_enable_publications_activate_once() {
|
|
let initial = config_with_target("initial");
|
|
let runtime = Arc::new(TestRuntime::new(initial.clone()));
|
|
let coordinator = coordinator(runtime.clone(), initial);
|
|
|
|
let apply_guard = coordinator.inner.apply_lock.lock().await;
|
|
let first = coordinator.set_mode(true, None);
|
|
let second = coordinator.set_mode(true, None);
|
|
drop(apply_guard);
|
|
|
|
first.wait().await.expect("first enable should converge");
|
|
second.wait().await.expect("second enable should converge");
|
|
assert_eq!(runtime.enable_calls.load(Ordering::SeqCst), 1);
|
|
assert!(matches!(coordinator.state(), NotificationRuntimeState::TargetsEnabled { generation: 2 }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn committed_generation_cleanup_does_not_block_a_newer_generation() {
|
|
let initial = config_with_target("initial");
|
|
let latest = config_with_target("latest");
|
|
let runtime = Arc::new(TestRuntime::new(initial.clone()));
|
|
runtime.block_next_cleanup();
|
|
let coordinator = coordinator(runtime.clone(), initial);
|
|
|
|
let first = coordinator.set_mode(true, None);
|
|
runtime.cleanup_entered.notified().await;
|
|
|
|
coordinator
|
|
.update_config(latest.clone())
|
|
.wait()
|
|
.await
|
|
.expect("newer generation should not wait for old cleanup");
|
|
assert_eq!(runtime.enable_calls.load(Ordering::SeqCst), 2);
|
|
assert_eq!(runtime.last_enabled_config(), Some(latest));
|
|
|
|
runtime.cleanup_release.notify_one();
|
|
first.wait().await.expect("old generation cleanup should finish");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn superseded_receipt_does_not_retry_a_failed_latest_generation() {
|
|
let initial = config_with_target("initial");
|
|
let latest = config_with_target("latest");
|
|
let runtime = Arc::new(TestRuntime::new(initial.clone()));
|
|
let coordinator = coordinator(runtime.clone(), initial);
|
|
|
|
let apply_guard = coordinator.inner.apply_lock.lock().await;
|
|
let superseded = coordinator.set_mode(true, None);
|
|
tokio::task::yield_now().await;
|
|
let latest_transition = coordinator.update_config(latest.clone());
|
|
tokio::task::yield_now().await;
|
|
runtime.fail_next_enable();
|
|
drop(apply_guard);
|
|
|
|
superseded
|
|
.wait()
|
|
.await
|
|
.expect("a superseded generation should not apply the latest intent");
|
|
latest_transition
|
|
.wait()
|
|
.await
|
|
.expect_err("the latest generation must own its apply error");
|
|
assert_eq!(runtime.enable_calls.load(Ordering::SeqCst), 1);
|
|
assert!(!coordinator.is_converged());
|
|
|
|
coordinator
|
|
.update_config(latest.clone())
|
|
.wait()
|
|
.await
|
|
.expect("an explicit publication should retry the failed configuration");
|
|
assert_eq!(runtime.enable_calls.load(Ordering::SeqCst), 2);
|
|
assert_eq!(runtime.last_enabled_config(), Some(latest));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_generation_can_retry_the_same_config() {
|
|
let initial = config_with_target("initial");
|
|
let latest = config_with_target("latest");
|
|
let runtime = Arc::new(TestRuntime::new(initial.clone()));
|
|
let coordinator = coordinator(runtime.clone(), initial);
|
|
|
|
coordinator
|
|
.set_mode(true, None)
|
|
.wait()
|
|
.await
|
|
.expect("initial enable should succeed");
|
|
runtime.fail_next_enable();
|
|
coordinator
|
|
.update_config(latest.clone())
|
|
.wait()
|
|
.await
|
|
.expect_err("forced reload should fail");
|
|
assert!(!coordinator.is_converged());
|
|
|
|
coordinator
|
|
.update_config(latest.clone())
|
|
.wait()
|
|
.await
|
|
.expect("same config should be retryable");
|
|
assert!(coordinator.is_converged());
|
|
assert_eq!(runtime.last_enabled_config(), Some(latest));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suspend_is_restartable_but_terminate_is_final() {
|
|
let initial = config_with_target("initial");
|
|
let runtime = Arc::new(TestRuntime::new(initial.clone()));
|
|
let coordinator = coordinator(runtime, initial);
|
|
|
|
coordinator.set_mode(true, None).wait().await.expect("enable should succeed");
|
|
coordinator
|
|
.set_mode(false, None)
|
|
.wait()
|
|
.await
|
|
.expect("suspend should succeed");
|
|
coordinator
|
|
.set_mode(true, None)
|
|
.wait()
|
|
.await
|
|
.expect("suspended runtime should restart");
|
|
coordinator.terminate().wait().await.expect("terminate should succeed");
|
|
coordinator.terminate().wait().await.expect("terminate should be idempotent");
|
|
|
|
let err = coordinator
|
|
.set_mode(true, None)
|
|
.wait()
|
|
.await
|
|
.expect_err("terminated runtime must reject restart");
|
|
assert!(matches!(
|
|
err,
|
|
NotificationError::Initialization(detail) if detail == "Notification runtime has terminated"
|
|
));
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::Terminated);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminate_supersedes_blocked_enable_before_commit() {
|
|
let initial = config_with_target("initial");
|
|
let runtime = Arc::new(TestRuntime::new(initial.clone()));
|
|
runtime.block_next_enable();
|
|
let coordinator = coordinator(runtime.clone(), initial);
|
|
|
|
let enable = coordinator.set_mode(true, None);
|
|
runtime.enable_entered.notified().await;
|
|
let terminate = coordinator.terminate();
|
|
runtime.enable_release.notify_one();
|
|
|
|
enable.wait().await.expect("superseded enable task should finish");
|
|
terminate.wait().await.expect("terminate should finish");
|
|
assert_eq!(runtime.last_enabled_config(), None);
|
|
assert_eq!(coordinator.state(), NotificationRuntimeState::Terminated);
|
|
}
|
|
|
|
#[test]
|
|
fn publication_without_tokio_runtime_does_not_mutate_desired_state() {
|
|
let initial = config_with_target("initial");
|
|
let latest = config_with_target("latest");
|
|
let runtime = Arc::new(TestRuntime::new(initial.clone()));
|
|
let coordinator = coordinator(runtime, initial.clone());
|
|
|
|
let _transition = coordinator.update_config(latest);
|
|
let desired = coordinator.inner.desired.lock().unwrap_or_else(|err| err.into_inner());
|
|
assert_eq!(desired.generation, 0);
|
|
assert_eq!(desired.config, initial);
|
|
}
|
|
}
|