Files
rustfs/crates/notify/src/integration.rs
T
escapecode a80699b6dd feat: add an opt-in NATS JetStream publish path for the notify and audit targets (#4634)
feat(targets): add an opt-in NATS JetStream publish path for the notify and audit targets

The NATS notify and audit targets publish through NATS Core, which returns
before the server has durably accepted the message. A broker restart or a
connection drop between the publish and the flush loses the event, even though
the send queue has already cleared it, and no acknowledgement gates that clear.

An opt-in JetStream publish path clears a queued event only after the server
returns a durable PublishAck, so delivery is at-least-once across a broker
restart or a reconnect. It applies to both the notify and audit NATS targets, is
off by default, and is byte-identical to the NATS Core path when disabled.

The path includes durable store-and-forward, a stable dedup id sent as the
Nats-Msg-Id header so a replayed event is collapsed by the stream duplicate
window, pre-flight stream validation, and a bounded failed-events store for
terminally-failed and retry-exhausted events. Three configuration keys per
target select it: JETSTREAM_ENABLE, JETSTREAM_STREAM_NAME, and
JETSTREAM_ACK_TIMEOUT_SECS, under the RUSTFS_NOTIFY_NATS_ and RUSTFS_AUDIT_NATS_
prefixes.

The on-disk batch filename separator changes from colon to underscore so
batch names are valid on Windows filesystems, with transparent read-back
of files written under the previous separator. The migration affects the
shared queue store for every target type and lands with this feature
because the store gains its first Windows-exercised paths here.

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-14 15:36:14 +08:00

488 lines
18 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::notification_system_subscriber::NotificationSystemSubscriberView;
use crate::notifier::{EventNotifier, TargetList};
use crate::services::NotifyServices;
use crate::{
Event, error::NotificationError, pipeline::LiveEventHistory, registry::TargetRegistry, rule_engine::NotifyRuleEngine,
rules::BucketNotificationConfig,
};
use hashbrown::HashMap;
use metrics::{counter, gauge};
use rustfs_config::notify::{DEFAULT_NOTIFY_TARGET_STREAM_CONCURRENCY, ENV_NOTIFY_TARGET_STREAM_CONCURRENCY};
use rustfs_config::server_config::{Config, KVS};
use rustfs_s3_types::EventName;
use rustfs_targets::arn::TargetID;
use rustfs_targets::{ReplayWorkerManager, RuntimeTargetHealthSnapshot, SharedTarget};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::{RwLock, Semaphore, broadcast};
use tracing::info;
const METRIC_NOTIFICATION_CURRENT_SEND_IN_PROGRESS: &str = "rustfs_notification_current_send_in_progress";
const METRIC_NOTIFICATION_EVENTS_ERRORS_TOTAL: &str = "rustfs_notification_events_errors_total";
const METRIC_NOTIFICATION_EVENTS_SENT_TOTAL: &str = "rustfs_notification_events_sent_total";
const METRIC_NOTIFICATION_EVENTS_SKIPPED_TOTAL: &str = "rustfs_notification_events_skipped_total";
const LOG_COMPONENT_NOTIFY: &str = "notify";
const LOG_SUBSYSTEM_INTEGRATION: &str = "integration";
const EVENT_NOTIFY_SYSTEM_DROP: &str = "notify_system_drop";
const EVENT_NOTIFY_SYSTEM_SHUTDOWN_METRIC: &str = "notify_system_shutdown_metric";
const EVENT_NOTIFY_SYSTEM_STATUS_SNAPSHOT: &str = "notify_system_status_snapshot";
#[derive(Clone)]
pub struct LiveEventBatch {
pub events: Vec<Arc<Event>>,
pub next_sequence: u64,
pub truncated: bool,
/// Set when the consumer's cursor is older than the oldest event still
/// retained in the ring buffer: events between the cursor and the oldest
/// retained sequence were evicted and can never be delivered. Consumers
/// must treat a `gap` as lost events (e.g. trigger a full re-sync/alert)
/// instead of assuming the returned batch is contiguous with their cursor.
pub gap: bool,
}
/// Notify the system of monitoring indicators
pub struct NotificationMetrics {
/// The number of events currently being processed
processing_events: AtomicUsize,
/// Number of events that have been successfully processed
processed_events: AtomicUsize,
/// Number of events that failed to handle
failed_events: AtomicUsize,
/// Number of dispatch attempts skipped before delivery
skipped_events: AtomicUsize,
/// System startup time
start_time: Instant,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NotificationMetricSnapshot {
pub current_send_in_progress: u64,
pub events_errors_total: u64,
pub events_sent_total: u64,
pub events_skipped_total: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NotificationTargetMetricSnapshot {
pub failed_messages: u64,
pub failed_store_length: u64,
pub queue_length: u64,
pub target_id: String,
pub target_type: String,
pub total_messages: u64,
}
impl Default for NotificationMetrics {
fn default() -> Self {
Self::new()
}
}
impl NotificationMetrics {
pub fn new() -> Self {
NotificationMetrics {
processing_events: AtomicUsize::new(0),
processed_events: AtomicUsize::new(0),
failed_events: AtomicUsize::new(0),
skipped_events: AtomicUsize::new(0),
start_time: Instant::now(),
}
}
// Provide public methods to increase count
pub fn increment_processing(&self) {
self.processing_events.fetch_add(1, Ordering::Relaxed);
}
pub fn increment_processed(&self) {
self.processing_events.fetch_sub(1, Ordering::Relaxed);
self.processed_events.fetch_add(1, Ordering::Relaxed);
}
pub fn decrement_processing(&self) {
self.processing_events.fetch_sub(1, Ordering::Relaxed);
}
pub fn increment_failed(&self) {
self.processing_events.fetch_sub(1, Ordering::Relaxed);
self.failed_events.fetch_add(1, Ordering::Relaxed);
}
pub fn increment_skipped(&self) {
self.skipped_events.fetch_add(1, Ordering::Relaxed);
}
// Provide public methods to get count
pub fn processing_count(&self) -> usize {
self.processing_events.load(Ordering::Relaxed)
}
pub fn processed_count(&self) -> usize {
self.processed_events.load(Ordering::Relaxed)
}
pub fn failed_count(&self) -> usize {
self.failed_events.load(Ordering::Relaxed)
}
pub fn skipped_count(&self) -> usize {
self.skipped_events.load(Ordering::Relaxed)
}
pub fn uptime(&self) -> Duration {
self.start_time.elapsed()
}
pub fn snapshot(&self) -> NotificationMetricSnapshot {
NotificationMetricSnapshot {
current_send_in_progress: self.processing_count() as u64,
events_errors_total: self.failed_count() as u64,
events_sent_total: self.processed_count() as u64,
events_skipped_total: self.skipped_count() as u64,
}
}
}
/// The notification system that integrates all components
pub struct NotificationSystem {
/// The event notifier
pub notifier: Arc<EventNotifier>,
/// The target registry
pub registry: Arc<TargetRegistry>,
/// The current configuration
pub config: Arc<RwLock<Config>>,
services: NotifyServices,
}
impl NotificationSystem {
/// Creates a new NotificationSystem
pub fn new(config: Config) -> Self {
let concurrency_limiter =
rustfs_utils::get_env_usize(ENV_NOTIFY_TARGET_STREAM_CONCURRENCY, DEFAULT_NOTIFY_TARGET_STREAM_CONCURRENCY);
let (live_event_sender, _) = broadcast::channel(1024);
let metrics = Arc::new(NotificationMetrics::new());
let subscriber_view = Arc::new(NotificationSystemSubscriberView::new());
let rule_engine = NotifyRuleEngine::new();
let notifier = Arc::new(EventNotifier::new(metrics.clone(), rule_engine.clone()));
let target_list = notifier.target_list();
let registry = Arc::new(TargetRegistry::new());
let config = Arc::new(RwLock::new(config));
let stream_cancellers = Arc::new(RwLock::new(ReplayWorkerManager::new()));
let concurrency_limiter = Arc::new(Semaphore::new(concurrency_limiter)); // Limit the maximum number of concurrent processing events to 20
let live_event_history = Arc::new(RwLock::new(LiveEventHistory::default()));
let services = NotifyServices::new(
notifier.clone(),
rule_engine,
target_list,
registry.clone(),
config.clone(),
stream_cancellers,
concurrency_limiter,
metrics,
subscriber_view,
live_event_sender,
live_event_history,
);
NotificationSystem {
notifier,
registry,
config,
services,
}
}
/// Initializes the notification system
pub async fn init(&self) -> Result<(), NotificationError> {
self.services.config_manager.init().await
}
/// Gets a list of Targets for all currently active (initialized).
///
/// # Return
/// A Vec containing all active Targets `TargetID`.
pub async fn get_active_targets(&self) -> Vec<TargetID> {
self.services.runtime_view.get_active_targets().await
}
/// Gets the complete Target list, including both active and inactive Targets.
///
/// # Return
/// An `Arc<RwLock<TargetList>>` containing all Targets.
pub async fn get_all_targets(&self) -> Arc<RwLock<TargetList>> {
self.services.runtime_view.get_all_targets()
}
/// Gets all Target values, including both active and inactive Targets.
///
/// # Return
/// A Vec containing all Targets.
pub async fn get_target_values(&self) -> Vec<SharedTarget<Event>> {
self.services.runtime_view.get_target_values().await
}
/// Checks if there are active subscribers for the given bucket and event name.
pub async fn has_subscriber(&self, bucket: &str, event: &EventName) -> bool {
self.services.bucket_config_manager.has_subscriber(bucket, event).await
}
/// Returns true when at least one in-process consumer is subscribed to live events.
pub fn has_live_listeners(&self) -> bool {
self.services.pipeline.has_live_listeners()
}
/// Subscribes to the in-process live event stream.
pub fn subscribe_live_events(&self) -> broadcast::Receiver<Arc<Event>> {
self.services.pipeline.subscribe_live_events()
}
pub async fn recent_live_events_since(&self, after_sequence: u64, limit: usize) -> LiveEventBatch {
self.services.pipeline.recent_live_events_since(after_sequence, limit).await
}
/// Accurately remove a Target and its related resources through TargetID.
///
/// This process includes:
/// 1. Stop the event stream associated with the Target (if present).
/// 2. Remove the Target instance from the activity list of Notifier.
/// 3. Remove the configuration item of the Target from the system configuration.
///
/// # Parameters
/// * `target_id` - The unique identifier of the Target to be removed.
///
/// # return
/// If successful, return `Ok(())`.
pub async fn remove_target(&self, target_id: &TargetID, target_type: &str) -> Result<(), NotificationError> {
self.services.config_manager.remove_target(target_id, target_type).await
}
/// Set or update a Target configuration.
/// If the configuration is changed, the entire notification system will be automatically reloaded to apply the changes.
///
/// # Arguments
/// * `target_type` - Target type, such as "notify_webhook" or "notify_mqtt".
/// * `target_name` - A unique name for a Target, such as "1".
/// * `kvs` - The full configuration of the Target.
///
/// # Returns
/// Result<(), NotificationError>
/// If the target configuration is successfully set, it returns Ok(()).
/// If the target configuration is invalid, it returns Err(NotificationError::Configuration).
pub async fn set_target_config(&self, target_type: &str, target_name: &str, kvs: KVS) -> Result<(), NotificationError> {
self.services
.config_manager
.set_target_config(target_type, target_name, kvs)
.await
}
/// Removes all notification configurations for a bucket.
/// If the configuration is successfully removed, the entire notification system will be automatically reloaded.
///
/// # Arguments
/// * `bucket` - The name of the bucket whose notification configuration is to be removed.
///
pub async fn remove_bucket_notification_config(&self, bucket: &str) {
self.services
.bucket_config_manager
.remove_bucket_notification_config(bucket)
.await;
}
/// Removes a Target configuration.
/// If the configuration is successfully removed, the entire notification system will be automatically reloaded.
///
/// # Arguments
/// * `target_type` - Target type, such as "notify_webhook" or "notify_mqtt".
/// * `target_name` - A unique name for a Target, such as "1".
///
/// # Returns
/// Result<(), NotificationError>
///
/// If the target configuration is successfully removed, it returns Ok(()).
/// If the target configuration does not exist, it returns Ok(()) without making any changes.
pub async fn remove_target_config(&self, target_type: &str, target_name: &str) -> Result<(), NotificationError> {
self.services
.config_manager
.remove_target_config(target_type, target_name)
.await
}
/// Reloads the configuration
pub async fn reload_config(&self, new_config: Config) -> Result<(), NotificationError> {
self.services.config_manager.reload_config(new_config).await
}
/// Loads the bucket notification configuration
pub async fn load_bucket_notification_config(
&self,
bucket: &str,
cfg: &BucketNotificationConfig,
) -> Result<(), NotificationError> {
self.services
.bucket_config_manager
.load_bucket_notification_config(bucket, cfg)
.await
}
/// Sends an event
pub async fn send_event(&self, event: Arc<Event>) {
self.services.pipeline.send_event(event).await;
}
/// Obtain system status information
pub fn get_status(&self) -> HashMap<String, String> {
self.services.status_view.get_status()
}
pub fn snapshot_metrics(&self) -> NotificationMetricSnapshot {
self.services.status_view.snapshot_metrics()
}
pub async fn snapshot_target_metrics(&self) -> Vec<NotificationTargetMetricSnapshot> {
self.services.runtime_view.snapshot_target_metrics().await
}
pub async fn snapshot_target_health(&self) -> Vec<RuntimeTargetHealthSnapshot> {
self.services.runtime_view.snapshot_target_health().await
}
pub async fn runtime_status_snapshot(&self) -> rustfs_targets::RuntimeStatusSnapshot {
self.services.runtime_view.runtime_status_snapshot().await
}
// Add a method to shut down the system
pub async fn shutdown(&self) {
self.services.runtime_facade.shutdown().await;
}
}
impl Drop for NotificationSystem {
fn drop(&mut self) {
// Asynchronous operation cannot be used here, but logs can be recorded.
info!(
event = EVENT_NOTIFY_SYSTEM_DROP,
component = LOG_COMPONENT_NOTIFY,
subsystem = LOG_SUBSYSTEM_INTEGRATION,
state = "dropping",
"notify system integration state"
);
let snapshot = self.snapshot_metrics();
for (name, value, is_gauge) in [
(METRIC_NOTIFICATION_CURRENT_SEND_IN_PROGRESS, snapshot.current_send_in_progress, true),
(METRIC_NOTIFICATION_EVENTS_ERRORS_TOTAL, snapshot.events_errors_total, false),
(METRIC_NOTIFICATION_EVENTS_SENT_TOTAL, snapshot.events_sent_total, false),
(METRIC_NOTIFICATION_EVENTS_SKIPPED_TOTAL, snapshot.events_skipped_total, false),
] {
if is_gauge {
gauge!(name).set(value as f64);
} else {
counter!(name).absolute(value);
}
info!(
event = EVENT_NOTIFY_SYSTEM_SHUTDOWN_METRIC,
component = LOG_COMPONENT_NOTIFY,
subsystem = LOG_SUBSYSTEM_INTEGRATION,
metric_name = name,
metric_value = value,
metric_kind = if is_gauge { "gauge" } else { "counter" },
"notify system integration state"
);
}
let status = self.get_status();
for (key, value) in status {
info!(
event = EVENT_NOTIFY_SYSTEM_STATUS_SNAPSHOT,
component = LOG_COMPONENT_NOTIFY,
subsystem = LOG_SUBSYSTEM_INTEGRATION,
status_key = %key,
status_value = %value,
"notify system integration state"
);
}
}
}
/// Loads configuration from a file
pub async fn load_config_from_file(path: &str, system: &NotificationSystem) -> Result<(), NotificationError> {
let config_data = tokio::fs::read(path)
.await
.map_err(|e| NotificationError::Configuration(format!("Failed to read config file: {e}")))?;
let config = Config::unmarshal(config_data.as_slice())
.map_err(|e| NotificationError::Configuration(format!("Failed to parse config: {e}")))?;
system.reload_config(config).await
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_s3_types::EventName;
#[test]
fn live_event_history_snapshots_from_sequence() {
let mut history = LiveEventHistory::default();
history.record(Arc::new(Event::new_test_event("bucket", "one", EventName::ObjectCreatedPut)));
history.record(Arc::new(Event::new_test_event("bucket", "two", EventName::ObjectCreatedPut)));
let batch = history.snapshot_since(1, 16);
assert_eq!(batch.next_sequence, 2);
assert!(!batch.truncated);
assert!(!batch.gap);
assert_eq!(batch.events.len(), 1);
assert_eq!(batch.events[0].s3.object.key, "two");
}
#[test]
fn live_event_history_marks_truncation() {
let mut history = LiveEventHistory::default();
history.record(Arc::new(Event::new_test_event("bucket", "one", EventName::ObjectCreatedPut)));
history.record(Arc::new(Event::new_test_event("bucket", "two", EventName::ObjectCreatedPut)));
let batch = history.snapshot_since(0, 1);
assert_eq!(batch.next_sequence, 1);
assert!(batch.truncated);
assert!(!batch.gap);
assert_eq!(batch.events.len(), 1);
assert_eq!(batch.events[0].s3.object.key, "one");
}
#[tokio::test]
async fn notification_system_exposes_live_event_pipeline() {
let system = NotificationSystem::new(Config::default());
assert!(!system.has_live_listeners());
let _rx = system.subscribe_live_events();
assert!(system.has_live_listeners());
system
.send_event(Arc::new(Event::new_test_event("bucket", "object", EventName::ObjectCreatedPut)))
.await;
let batch = system.recent_live_events_since(0, 16).await;
assert_eq!(batch.events.len(), 1);
assert_eq!(batch.events[0].s3.object.key, "object");
assert_eq!(batch.next_sequence, 1);
assert!(!batch.truncated);
assert!(!batch.gap);
}
}