Files
rustfs/crates/targets/tests/nats_jetstream_regression_guards.rs
T

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 = "requires a live NATS JetStream server (see module docs; RUSTFS_TEST_NATS_URL overrides)"]
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
}