Files
rustfs/crates/targets/tests/nats_jetstream_regression_guards.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

260 lines
11 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.
//! Regression guards for the NATS JetStream publish feature.
//!
//! The JetStream surface can be removed and the build still succeed unless something fails when it
//! disappears. These layered guards make a silent removal or a silent classification drift a build or
//! test failure:
//!
//! - a compile-time reference to the JetStream config surface and the target entry point,
//! - a module-presence test naming the public entry type and the publish-error variant,
//! - completeness tests over the publish-error-class enums with no wildcard arm,
//! - a server-backed end-to-end publish-and-ack test, ignored by default so the offline gate stays
//! green without a NATS server.
//!
//! The end-to-end guard requires a running NATS server with JetStream enabled. To run it locally:
//!
//! docker run -d --name rustfs-nats-test -p 4222:4222 nats:2 -js
//! cargo test -p rustfs-targets --test nats_jetstream_regression_guards -- --ignored
//!
//! Override the server URL with RUSTFS_TEST_NATS_URL.
use async_nats::jetstream::context::PublishErrorKind;
use rustfs_targets::check_nats_server_available;
use rustfs_targets::error::TargetError;
use rustfs_targets::target::nats::{NATSArgs, NATSTarget};
use rustfs_targets::target::{FailedErrorClass, TargetType};
// Compile-time module-presence assertion.
//
// A const initializer names the JetStream config fields on the public target args type and the public
// target constructor by path, referencing the JetStream wiring at compile time without running
// anything. Removing the jetstream_enable, jetstream_stream_name, or jetstream_ack_timeout_secs fields
// from NATSArgs, or removing the NATSTarget constructor, while the config flag keys remain, fails to
// compile here. A static reference rather than a runtime check, so it fires at build time even when no
// test is run.
const _: fn(&NATSArgs) = |args: &NATSArgs| {
// Each field is read by name, so removing any of the three from NATSArgs fails to compile here.
let _enable: Option<bool> = args.jetstream_enable;
let _stream_name: &Option<String> = &args.jetstream_stream_name;
let _ack_timeout: Option<u64> = args.jetstream_ack_timeout_secs;
// The publish target entry point. Naming it by path ties this assertion to the constructor that
// wires the JetStream context, so deleting the entry point breaks the build.
let _entry: fn(String, NATSArgs) -> Result<NATSTarget<String>, TargetError> = NATSTarget::<String>::new;
};
/// Module-presence test naming the public JetStream entry type, the health-check entry, and the
/// publish-error variant by path. Deleting the NATSTarget constructor, the check_nats_server_available
/// entry, or the JetStreamPublish error variant fails to compile this test.
#[test]
fn jetstream_public_entry_points_are_present() {
let _entry: fn(String, NATSArgs) -> Result<NATSTarget<String>, TargetError> = NATSTarget::<String>::new;
let _health_check = check_nats_server_available;
// The publish path classifies a publish failure into this variant. Constructing it by name binds
// the guard to the variant, so removing it from TargetError breaks the build.
let publish_error = TargetError::JetStreamPublish {
retryable: true,
detail: "guard".to_string(),
};
match publish_error {
TargetError::JetStreamPublish { retryable, detail } => {
assert!(retryable);
assert_eq!(detail, "guard");
}
other => panic!("expected the JetStream publish variant, found {other:?}"),
}
}
/// Cross-module completeness over the publish-error class.
///
/// Lists every async-nats PublishErrorKind variant by name with no wildcard arm. The publish path
/// classifies each kind into retryable or terminal. A new variant added upstream, or a renamed or
/// removed one, fails to compile this match, forcing the classification to be revisited rather than
/// silently folding the new kind into a catch-all.
#[test]
fn publish_error_kind_is_exhaustively_enumerated() {
fn name_of(kind: PublishErrorKind) -> &'static str {
match kind {
PublishErrorKind::StreamNotFound => "StreamNotFound",
PublishErrorKind::WrongLastMessageId => "WrongLastMessageId",
PublishErrorKind::WrongLastSequence => "WrongLastSequence",
PublishErrorKind::TimedOut => "TimedOut",
PublishErrorKind::BrokenPipe => "BrokenPipe",
PublishErrorKind::MaxAckPending => "MaxAckPending",
PublishErrorKind::MaxPayloadExceeded => "MaxPayloadExceeded",
PublishErrorKind::Other => "Other",
}
}
let all = [
PublishErrorKind::StreamNotFound,
PublishErrorKind::WrongLastMessageId,
PublishErrorKind::WrongLastSequence,
PublishErrorKind::TimedOut,
PublishErrorKind::BrokenPipe,
PublishErrorKind::MaxAckPending,
PublishErrorKind::MaxPayloadExceeded,
PublishErrorKind::Other,
];
assert_eq!(
all.len(),
8,
"the publish-error class has eight kinds, update the classification on a change"
);
for kind in all {
assert!(!name_of(kind).is_empty());
}
}
/// Cross-module completeness over the failed-store error class.
///
/// Lists every FailedErrorClass variant by name with no wildcard arm. The replay worker tags a failed
/// entry with one of these classes. Adding or removing a class fails to compile this match, so the
/// tagging and the operator-facing tag stay in step.
#[test]
fn failed_error_class_is_exhaustively_enumerated() {
fn tag_of(class: FailedErrorClass) -> &'static str {
match class {
FailedErrorClass::Terminal => "terminal",
}
}
let all = [FailedErrorClass::Terminal];
assert_eq!(
all.len(),
1,
"the failed-store error class has one variant, update the tagging on a change"
);
for class in all {
assert_eq!(tag_of(class), class.as_str(), "the guard tag matches the operator-facing tag");
}
}
fn server_url() -> String {
std::env::var("RUSTFS_TEST_NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string())
}
fn jetstream_args(subject: &str, stream_name: &str, queue_dir: &str) -> NATSArgs {
NATSArgs {
enable: true,
address: server_url(),
subject: subject.to_string(),
username: String::new(),
password: String::new(),
token: String::new(),
credentials_file: String::new(),
tls_ca: String::new(),
tls_client_cert: String::new(),
tls_client_key: String::new(),
tls_required: false,
queue_dir: queue_dir.to_string(),
queue_limit: 0,
jetstream_enable: Some(true),
jetstream_stream_name: Some(stream_name.to_string()),
jetstream_ack_timeout_secs: Some(30),
target_type: TargetType::NotifyEvent,
}
}
/// Server-backed end-to-end publish-and-ack guard.
///
/// Provisions a writable stream as an operator would, then drives the real target publish path with
/// the JetStream flag on: init connects and validates the stream, save persists the event to the
/// on-disk queue, send_from_store publishes and awaits the real PublishAck and clears the entry on it.
/// The stream message count rising and the queue draining proves the message was published and
/// acknowledged through the production code, the user-visible signal a build-only check cannot see.
///
/// Ignored by default because it needs a running NATS server with JetStream.
#[tokio::test]
#[ignore]
async fn end_to_end_publish_is_acked_on_the_stream() {
use rustfs_targets::EventName;
use rustfs_targets::Target;
use rustfs_targets::target::EntityTarget;
use std::sync::Arc;
use uuid::Uuid;
let suffix = Uuid::new_v4().simple().to_string();
let stream_name = format!("RUSTFS_TEST_{suffix}");
let subject = format!("rustfs.guard.{suffix}");
let queue_dir = std::env::temp_dir().join(format!("rustfs-nats-guard-{suffix}"));
let queue_dir = queue_dir.to_string_lossy().to_string();
// Provision the stream as the operator would. RustFS validates it, never creates it.
let client = async_nats::connect(server_url()).await.expect("connect to NATS");
let context = async_nats::jetstream::new(client);
let _ = context.delete_stream(&stream_name).await;
context
.create_stream(async_nats::jetstream::stream::Config {
name: stream_name.clone(),
subjects: vec![subject.clone()],
duplicate_window: std::time::Duration::from_secs(300),
..Default::default()
})
.await
.expect("provision the stream");
let args = jetstream_args(&subject, &stream_name, &queue_dir);
let target = NATSTarget::<String>::new("guard-target".to_string(), args).expect("build the flag-on target");
target
.init()
.await
.expect("init connects and validates the operator-provisioned stream");
let count_before = stream_message_count(&context, &stream_name).await;
let event = EntityTarget {
object_name: "guard/object.txt".to_string(),
bucket_name: "guard-bucket".to_string(),
event_name: EventName::ObjectCreatedPut,
data: "guard-payload".to_string(),
};
target
.save(Arc::new(event))
.await
.expect("save persists the event to the queue");
let keys = target.store().expect("the flag-on target has a queue store").list();
assert_eq!(keys.len(), 1, "the saved event is queued exactly once");
target
.send_from_store(keys[0].clone())
.await
.expect("the queued event publishes and is acked");
let count_after = stream_message_count(&context, &stream_name).await;
assert_eq!(
count_after,
count_before + 1,
"the published event is durably on the stream after its ack"
);
let remaining = target.store().expect("the flag-on target has a queue store").list();
assert!(remaining.is_empty(), "the queue entry is cleared on the ack");
target.close().await.expect("close drains the target");
let _ = context.delete_stream(&stream_name).await;
let _ = std::fs::remove_dir_all(&queue_dir);
}
async fn stream_message_count(context: &async_nats::jetstream::Context, stream_name: &str) -> u64 {
let mut stream = context.get_stream(stream_name).await.expect("look up the stream");
let info = stream.info().await.expect("read the stream info");
info.state.messages
}