mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-20 11:32:19 +00:00
1655f3192e
* fix(notify): unify runtime lifecycle coordination * fix(notify): repair lifecycle convergence checks * fix(admin): expose effective notify state (#5097)
1424 lines
53 KiB
Rust
1424 lines
53 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::plugin::PluginEvent;
|
|
use crate::{
|
|
StoreError, Target,
|
|
arn::TargetID,
|
|
error::TargetError,
|
|
runtime::tls::{
|
|
ReloadableTargetTls, TargetTlsGeneration, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode,
|
|
validate_tls_material,
|
|
},
|
|
store::{Key, Store},
|
|
target::{
|
|
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
|
|
TargetType, build_queued_payload, invalidate_cache_on_connectivity_error, is_connectivity_error,
|
|
mark_target_disconnected_on_connectivity_error, open_target_queue_store, persist_queued_payload_to_store,
|
|
with_delivery_deadline,
|
|
},
|
|
};
|
|
use async_trait::async_trait;
|
|
use redis::{
|
|
AsyncCommands, Client, ClientTlsConfig, ConnectionInfo, IntoConnectionInfo, RedisError, TlsCertificates,
|
|
aio::{ConnectionManager, ConnectionManagerConfig},
|
|
cmd,
|
|
io::tcp::{TcpSettings, socket2},
|
|
};
|
|
use rustfs_config::{REDIS_TLS_CA, REDIS_TLS_CLIENT_CERT, REDIS_TLS_CLIENT_KEY, REDIS_TLS_POLICY};
|
|
use rustls::pki_types::CertificateDer;
|
|
use rustls::pki_types::pem::PemObject;
|
|
use std::fmt;
|
|
use std::io::BufReader;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::time::Duration;
|
|
use tokio::sync::Mutex;
|
|
use tracing::{debug, info, instrument, warn};
|
|
use url::Url;
|
|
|
|
const REDIS_CONNECTION_TIMEOUT_DEFAULT: Duration = Duration::from_secs(5);
|
|
const REDIS_RESPONSE_TIMEOUT_DEFAULT: Duration = Duration::from_secs(5);
|
|
|
|
fn redis_total_delivery_timeout(args: &RedisArgs) -> Duration {
|
|
let attempts = u32::try_from(args.max_retry_attempts).unwrap_or(u32::MAX);
|
|
let per_attempt = args
|
|
.connection_timeout
|
|
.unwrap_or(REDIS_CONNECTION_TIMEOUT_DEFAULT)
|
|
.saturating_add(args.response_timeout.unwrap_or(REDIS_RESPONSE_TIMEOUT_DEFAULT))
|
|
.saturating_add(args.max_retry_delay.unwrap_or(Duration::from_secs(2)));
|
|
per_attempt.saturating_mul(attempts)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RedisTlsPolicy {
|
|
SystemCa,
|
|
CustomCa,
|
|
}
|
|
|
|
impl RedisTlsPolicy {
|
|
fn parse(value: &str) -> Result<Self, TargetError> {
|
|
match value.trim() {
|
|
value if value.eq_ignore_ascii_case("system_ca") => Ok(Self::SystemCa),
|
|
value if value.eq_ignore_ascii_case("custom_ca") => Ok(Self::CustomCa),
|
|
_ => Err(TargetError::Configuration(
|
|
"Redis tls_policy must be one of: system_ca, custom_ca".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct RedisTlsConfig {
|
|
pub policy: Option<RedisTlsPolicy>,
|
|
pub ca_path: String,
|
|
pub client_cert_path: String,
|
|
pub client_key_path: String,
|
|
pub allow_insecure: bool,
|
|
}
|
|
|
|
impl RedisTlsConfig {
|
|
pub fn from_values(
|
|
policy: Option<&str>,
|
|
ca_path: Option<&str>,
|
|
client_cert_path: Option<&str>,
|
|
client_key_path: Option<&str>,
|
|
allow_insecure: Option<&str>,
|
|
) -> Result<Self, TargetError> {
|
|
let policy = match policy.map(str::trim).filter(|value| !value.is_empty()) {
|
|
Some(value) => Some(RedisTlsPolicy::parse(value)?),
|
|
None => None,
|
|
};
|
|
let allow_insecure = allow_insecure
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| {
|
|
value
|
|
.parse::<rustfs_config::EnableState>()
|
|
.map(rustfs_config::EnableState::is_enabled)
|
|
.or_else(|_| value.parse::<bool>())
|
|
.map_err(|_| TargetError::Configuration("Redis tls_allow_insecure must be a boolean value".to_string()))
|
|
})
|
|
.transpose()?
|
|
.unwrap_or(false);
|
|
|
|
Ok(Self {
|
|
policy,
|
|
ca_path: ca_path.unwrap_or_default().trim().to_string(),
|
|
client_cert_path: client_cert_path.unwrap_or_default().trim().to_string(),
|
|
client_key_path: client_key_path.unwrap_or_default().trim().to_string(),
|
|
allow_insecure,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct RedisArgs {
|
|
/// Whether the target is enabled
|
|
pub enable: bool,
|
|
/// The Redis server URL in format: `{redis|rediss|valkey|valkeys}://[<username>][:<password>@]<hostname>[:port][/<db>]`
|
|
pub url: Url,
|
|
/// The Redis pub/sub channel to publish to
|
|
pub channel: String,
|
|
/// The username for the Redis connection (leave it empty if you parse with url)
|
|
pub username: Option<String>,
|
|
/// The password for the Redis connection (leave it empty if you parse with url)
|
|
pub password: Option<String>,
|
|
/// TLS configuration
|
|
pub tls: RedisTlsConfig,
|
|
/// The keep alive interval
|
|
pub keep_alive: Duration,
|
|
/// The directory to store events in case of failure
|
|
pub queue_dir: String,
|
|
/// The maximum number of events to store
|
|
pub queue_limit: u64,
|
|
/// Maximum number of synchronous publish retries per payload
|
|
pub max_retry_attempts: usize,
|
|
/// Maximum number of reconnect retries in the underlying connection manager (6 if not provided)
|
|
pub reconnect_retry_attempts: Option<usize>,
|
|
/// Minimum retry delay between publish retry attempts (100ms if not provided)
|
|
pub min_retry_delay: Option<Duration>,
|
|
/// Maximum retry delay between publish retry attempts (2s if not provided)
|
|
pub max_retry_delay: Option<Duration>,
|
|
/// Timeout for establishing a Redis connection (5s if not provided)
|
|
pub connection_timeout: Option<Duration>,
|
|
/// Timeout for command responses (5s if not provided)
|
|
pub response_timeout: Option<Duration>,
|
|
/// Internal command buffer size for the multiplexed connection (50 if not provided)
|
|
pub pipeline_buffer_size: Option<usize>,
|
|
/// the target type
|
|
pub target_type: TargetType,
|
|
}
|
|
|
|
fn redact_redis_url(url: &Url) -> String {
|
|
let mut redacted = url.clone();
|
|
if redacted.password().is_some() {
|
|
let _ = redacted.set_password(Some("***"));
|
|
}
|
|
redacted.to_string()
|
|
}
|
|
|
|
impl fmt::Debug for RedisArgs {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("RedisArgs")
|
|
.field("enable", &self.enable)
|
|
.field("url", &redact_redis_url(&self.url))
|
|
.field("channel", &self.channel)
|
|
.field("username", &self.username)
|
|
.field(
|
|
"password",
|
|
if self.password.as_deref().unwrap_or_default().is_empty() {
|
|
&""
|
|
} else {
|
|
&"***REDACTED***"
|
|
},
|
|
)
|
|
.field("tls", &self.tls)
|
|
.field("keep_alive", &self.keep_alive)
|
|
.field("queue_dir", &self.queue_dir)
|
|
.field("queue_limit", &self.queue_limit)
|
|
.field("max_retry_attempts", &self.max_retry_attempts)
|
|
.field("reconnect_retry_attempts", &self.reconnect_retry_attempts)
|
|
.field("min_retry_delay", &self.min_retry_delay)
|
|
.field("max_retry_delay", &self.max_retry_delay)
|
|
.field("connection_timeout", &self.connection_timeout)
|
|
.field("response_timeout", &self.response_timeout)
|
|
.field("pipeline_buffer_size", &self.pipeline_buffer_size)
|
|
.field("target_type", &self.target_type)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl RedisArgs {
|
|
pub fn validate(&self) -> Result<(), TargetError> {
|
|
if !self.enable {
|
|
return Ok(());
|
|
}
|
|
|
|
validate_redis_url(&self.url)?;
|
|
validate_redis_tls_config(&self.url, &self.tls)?;
|
|
|
|
if self.channel.trim().is_empty() {
|
|
return Err(TargetError::Configuration("Redis channel cannot be empty".to_string()));
|
|
}
|
|
|
|
if self.username.as_deref().unwrap_or_default().is_empty() != self.password.as_deref().unwrap_or_default().is_empty()
|
|
&& !(self.username.is_none() && self.password.is_none())
|
|
{
|
|
return Err(TargetError::Configuration(
|
|
"Redis username and password must be specified together when provided explicitly".to_string(),
|
|
));
|
|
}
|
|
|
|
if self.max_retry_attempts == 0 {
|
|
return Err(TargetError::Configuration(
|
|
"Redis max_retry_attempts must be greater than zero".to_string(),
|
|
));
|
|
}
|
|
|
|
if self.connection_timeout == Some(Duration::ZERO) {
|
|
return Err(TargetError::Configuration(
|
|
"Redis connection_timeout must be greater than zero".to_string(),
|
|
));
|
|
}
|
|
|
|
if self.response_timeout == Some(Duration::ZERO) {
|
|
return Err(TargetError::Configuration("Redis response_timeout must be greater than zero".to_string()));
|
|
}
|
|
|
|
if self.pipeline_buffer_size == Some(0) {
|
|
return Err(TargetError::Configuration(
|
|
"Redis pipeline_buffer_size must be greater than zero".to_string(),
|
|
));
|
|
}
|
|
|
|
if let (Some(min_retry_delay), Some(max_retry_delay)) = (self.min_retry_delay, self.max_retry_delay)
|
|
&& max_retry_delay < min_retry_delay
|
|
{
|
|
return Err(TargetError::Configuration(
|
|
"Redis max_retry_delay must be greater than or equal to min_retry_delay".to_string(),
|
|
));
|
|
}
|
|
|
|
if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
|
|
return Err(TargetError::Configuration("Redis queue_dir path should be absolute".to_string()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn validate_redis_url(url: &Url) -> Result<(), TargetError> {
|
|
let _: ConnectionInfo = url.clone().into_connection_info().map_err(map_redis_error)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_redis_tls_config(url: &Url, tls: &RedisTlsConfig) -> Result<(), TargetError> {
|
|
let secure_scheme = matches!(url.scheme(), "rediss" | "valkeys");
|
|
|
|
if !tls.client_cert_path.is_empty() && !Path::new(&tls.client_cert_path).is_absolute() {
|
|
return Err(TargetError::Configuration(format!("{REDIS_TLS_CLIENT_CERT} must be an absolute path")));
|
|
}
|
|
if !tls.client_key_path.is_empty() && !Path::new(&tls.client_key_path).is_absolute() {
|
|
return Err(TargetError::Configuration(format!("{REDIS_TLS_CLIENT_KEY} must be an absolute path")));
|
|
}
|
|
if tls.client_cert_path.is_empty() != tls.client_key_path.is_empty() {
|
|
return Err(TargetError::Configuration(
|
|
"Redis tls_client_cert and tls_client_key must be specified together".to_string(),
|
|
));
|
|
}
|
|
|
|
if !secure_scheme {
|
|
if tls.policy.is_some()
|
|
|| !tls.ca_path.is_empty()
|
|
|| !tls.client_cert_path.is_empty()
|
|
|| !tls.client_key_path.is_empty()
|
|
|| tls.allow_insecure
|
|
{
|
|
return Err(TargetError::Configuration(
|
|
"TLS settings are only allowed for rediss/valkeys schemes".to_string(),
|
|
));
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some(policy) = tls.policy {
|
|
match policy {
|
|
RedisTlsPolicy::SystemCa => {
|
|
if !tls.ca_path.is_empty() {
|
|
return Err(TargetError::Configuration(format!(
|
|
"{REDIS_TLS_CA} is not allowed when {REDIS_TLS_POLICY}=system_ca"
|
|
)));
|
|
}
|
|
}
|
|
RedisTlsPolicy::CustomCa => {
|
|
if tls.ca_path.is_empty() {
|
|
return Err(TargetError::Configuration(format!(
|
|
"{REDIS_TLS_CA} is required when {REDIS_TLS_POLICY}=custom_ca"
|
|
)));
|
|
}
|
|
if !Path::new(&tls.ca_path).is_absolute() {
|
|
return Err(TargetError::Configuration(format!("{REDIS_TLS_CA} must be an absolute path")));
|
|
}
|
|
}
|
|
}
|
|
} else if !tls.ca_path.is_empty() && !Path::new(&tls.ca_path).is_absolute() {
|
|
return Err(TargetError::Configuration(format!("{REDIS_TLS_CA} must be an absolute path")));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub struct RedisTarget<E>
|
|
where
|
|
E: PluginEvent,
|
|
{
|
|
id: TargetID,
|
|
args: RedisArgs,
|
|
/// Redis client, wrapped in a lock so TLS hot-reload can atomically replace it.
|
|
publisher_client: Arc<parking_lot::Mutex<Client>>,
|
|
publisher: Arc<Mutex<Option<ConnectionManager>>>,
|
|
store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
|
|
/// Business-level liveness flag.
|
|
///
|
|
/// We only flip this to `false` on final/terminal failure paths (for example: init failed,
|
|
/// publish exhausted retries, or the target was explicitly closed). Temporary reconnectable
|
|
/// errors only invalidate the cached publisher so that a later request can lazily rebuild it.
|
|
connected: Arc<AtomicBool>,
|
|
/// TLS fingerprint tracking for hot reload (inline fallback path).
|
|
tls_state: Arc<parking_lot::Mutex<super::TargetTlsState>>,
|
|
/// Adapter that bridges this target to the TLS reload coordinator.
|
|
/// When `Some`, the target uses coordinator-managed material; when `None`,
|
|
/// it falls back to inline fingerprint-based change detection.
|
|
tls_adapter: Option<TlsReloadAdapter<Client>>,
|
|
delivery_counters: Arc<TargetDeliveryCounters>,
|
|
_phantom: std::marker::PhantomData<E>,
|
|
}
|
|
|
|
impl<E> RedisTarget<E>
|
|
where
|
|
E: PluginEvent,
|
|
{
|
|
#[instrument(skip(args), fields(target_id_as_string = %id))]
|
|
pub fn new(id: String, args: RedisArgs) -> Result<Self, TargetError> {
|
|
args.validate()?;
|
|
|
|
let target_id = TargetID::new(id, ChannelTargetType::Redis.as_str().to_string());
|
|
let publisher_client = build_redis_client(&args)?;
|
|
|
|
let queue_store = open_target_queue_store(
|
|
&args.queue_dir,
|
|
args.queue_limit,
|
|
args.target_type,
|
|
ChannelTargetType::Redis.as_str(),
|
|
&target_id,
|
|
"Failed to open store for Redis target",
|
|
)?;
|
|
|
|
info!(target_id = %target_id, "Redis target created");
|
|
Ok(Self {
|
|
id: target_id,
|
|
args,
|
|
publisher_client: Arc::new(parking_lot::Mutex::new(publisher_client)),
|
|
publisher: Arc::new(Mutex::new(None)),
|
|
store: queue_store,
|
|
connected: Arc::new(AtomicBool::new(false)),
|
|
tls_state: Arc::new(parking_lot::Mutex::new(super::TargetTlsState::default())),
|
|
tls_adapter: None,
|
|
delivery_counters: Arc::new(TargetDeliveryCounters::default()),
|
|
_phantom: std::marker::PhantomData,
|
|
})
|
|
}
|
|
|
|
pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
|
|
Box::new(Self {
|
|
id: self.id.clone(),
|
|
args: self.args.clone(),
|
|
publisher_client: Arc::clone(&self.publisher_client),
|
|
publisher: Arc::clone(&self.publisher),
|
|
store: self.store.as_ref().map(|s| s.boxed_clone()),
|
|
connected: Arc::clone(&self.connected),
|
|
tls_state: Arc::clone(&self.tls_state),
|
|
tls_adapter: self.tls_adapter.clone(),
|
|
delivery_counters: Arc::clone(&self.delivery_counters),
|
|
_phantom: std::marker::PhantomData,
|
|
})
|
|
}
|
|
|
|
async fn get_or_create_publisher(&self) -> Result<ConnectionManager, TargetError> {
|
|
// Adapter-managed path: use the material directly from the TLS reload adapter.
|
|
if let Some(adapter) = &self.tls_adapter {
|
|
let client: Client = (*adapter.current_material()).clone();
|
|
|
|
// Ensure the client is also stored locally so close() can drain it.
|
|
*self.publisher_client.lock() = client.clone();
|
|
|
|
let manager = client
|
|
.get_connection_manager_lazy(build_redis_connection_manager_config(&self.args))
|
|
.map_err(map_redis_error)?;
|
|
|
|
*self.publisher.lock().await = Some(manager.clone());
|
|
return Ok(manager);
|
|
}
|
|
|
|
// Inline fingerprint fallback path (no coordinator).
|
|
let secure_scheme = matches!(self.args.url.scheme(), "rediss" | "valkeys");
|
|
if secure_scheme {
|
|
let next_fingerprint = super::build_target_tls_fingerprint(
|
|
&self.args.tls.ca_path,
|
|
&self.args.tls.client_cert_path,
|
|
&self.args.tls.client_key_path,
|
|
)
|
|
.await?;
|
|
let tls_changed = {
|
|
let tls_state_guard = self.tls_state.lock();
|
|
tls_state_guard.needs_update(&next_fingerprint)
|
|
};
|
|
if tls_changed {
|
|
let new_client = build_redis_client(&self.args)?;
|
|
*self.publisher_client.lock() = new_client;
|
|
self.invalidate_cached_publisher().await;
|
|
self.tls_state.lock().refresh(next_fingerprint);
|
|
}
|
|
}
|
|
|
|
let mut guard = self.publisher.lock().await;
|
|
if let Some(manager) = guard.clone() {
|
|
return Ok(manager);
|
|
}
|
|
|
|
let client = self.publisher_client.lock().clone();
|
|
let manager = client
|
|
.get_connection_manager_lazy(build_redis_connection_manager_config(&self.args))
|
|
.map_err(map_redis_error)?;
|
|
|
|
*guard = Some(manager.clone());
|
|
Ok(manager)
|
|
}
|
|
|
|
async fn invalidate_cached_publisher(&self) {
|
|
// Intentionally does not touch `connected`: invalidating the current manager only means
|
|
// "recreate the publisher on the next attempt", not "this target is now definitively
|
|
// inactive". That distinction preserves the business semantics of `is_active()`.
|
|
*self.publisher.lock().await = None;
|
|
}
|
|
|
|
async fn ensure_publisher_ready(&self) -> Result<(), TargetError> {
|
|
let mut publisher = self.get_or_create_publisher().await?;
|
|
match cmd("PING").query_async::<String>(&mut publisher).await {
|
|
Ok(_) => Ok(()),
|
|
Err(err) => {
|
|
let mapped = map_redis_error(err);
|
|
invalidate_cache_on_connectivity_error(&mapped, || self.invalidate_cached_publisher()).await;
|
|
Err(mapped)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn init_inner(&self) -> Result<(), TargetError> {
|
|
if let Err(err) = self.ensure_publisher_ready().await {
|
|
self.connected.store(false, Ordering::SeqCst);
|
|
return Err(err);
|
|
}
|
|
self.connected.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
|
|
#[instrument(skip(self, body, meta), fields(target_id = %self.id))]
|
|
async fn send_body(&self, body: Vec<u8>, meta: &QueuedPayloadMeta) -> Result<(), TargetError> {
|
|
debug!(
|
|
target = %self.id,
|
|
bucket = %meta.bucket_name,
|
|
object = %meta.object_name,
|
|
event = %meta.event_name,
|
|
payload_len = body.len(),
|
|
channel = %self.args.channel,
|
|
"Sending Redis payload"
|
|
);
|
|
|
|
let result = with_delivery_deadline(redis_total_delivery_timeout(&self.args), "Redis delivery", async {
|
|
let mut attempt = 0usize;
|
|
let mut last_error = None;
|
|
while attempt < self.args.max_retry_attempts {
|
|
attempt += 1;
|
|
|
|
let connection_timeout = self.args.connection_timeout.unwrap_or(REDIS_CONNECTION_TIMEOUT_DEFAULT);
|
|
let mut publisher = match with_delivery_deadline(
|
|
connection_timeout,
|
|
"Redis connection",
|
|
self.get_or_create_publisher(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(publisher) => publisher,
|
|
Err(err) => {
|
|
invalidate_cache_on_connectivity_error(&err, || self.invalidate_cached_publisher()).await;
|
|
return Err(err);
|
|
}
|
|
};
|
|
let response_timeout = self.args.response_timeout.unwrap_or(REDIS_RESPONSE_TIMEOUT_DEFAULT);
|
|
match with_delivery_deadline(response_timeout, "Redis publish response", async {
|
|
publisher
|
|
.publish::<_, _, i64>(self.args.channel.as_str(), body.as_slice())
|
|
.await
|
|
.map_err(map_redis_error)
|
|
})
|
|
.await
|
|
{
|
|
Ok(receiver_count) => {
|
|
// PUBLISH returns the number of subscribers that received the
|
|
// message. Redis pub/sub is best-effort: with zero subscribers
|
|
// the event is delivered to no one, yet the durable copy is
|
|
// deleted. Warn so operators relying on reliable delivery are
|
|
// not silently losing events (backlog#982).
|
|
if receiver_count == 0 {
|
|
warn!(
|
|
target_id = %self.id,
|
|
channel = %self.args.channel,
|
|
"Redis PUBLISH reached 0 subscribers; the event was not received by any consumer (pub/sub is best-effort)"
|
|
);
|
|
}
|
|
debug!(
|
|
target_id = %self.id,
|
|
channel = %self.args.channel,
|
|
attempt,
|
|
receiver_count,
|
|
"Event published to Redis channel"
|
|
);
|
|
self.delivery_counters.record_success();
|
|
return Ok(());
|
|
}
|
|
Err(mapped) => {
|
|
invalidate_cache_on_connectivity_error(&mapped, || self.invalidate_cached_publisher()).await;
|
|
|
|
warn!(
|
|
target_id = %self.id,
|
|
channel = %self.args.channel,
|
|
attempt,
|
|
max_attempts = self.args.max_retry_attempts,
|
|
error = %mapped,
|
|
"Redis publish attempt failed"
|
|
);
|
|
|
|
if !is_connectivity_error(&mapped) || attempt >= self.args.max_retry_attempts {
|
|
last_error = Some(mapped);
|
|
break;
|
|
}
|
|
|
|
last_error = Some(mapped);
|
|
tokio::time::sleep(compute_retry_delay(
|
|
attempt,
|
|
self.args.min_retry_delay.unwrap_or(Duration::from_millis(100)),
|
|
self.args.max_retry_delay.unwrap_or(Duration::from_secs(2)),
|
|
))
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(last_error.unwrap_or(TargetError::Unknown(
|
|
"Redis publish failed without a captured error".to_string(),
|
|
)))
|
|
})
|
|
.await;
|
|
|
|
if let Err(err) = &result {
|
|
invalidate_cache_on_connectivity_error(err, || self.invalidate_cached_publisher()).await;
|
|
self.connected.store(false, Ordering::SeqCst);
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl<E> Target<E> for RedisTarget<E>
|
|
where
|
|
E: PluginEvent,
|
|
{
|
|
fn id(&self) -> TargetID {
|
|
self.id.clone()
|
|
}
|
|
|
|
async fn is_active(&self) -> Result<bool, TargetError> {
|
|
if !self.is_enabled() {
|
|
return Ok(false);
|
|
}
|
|
|
|
// Reuse the cached ConnectionManager for the health probe (via
|
|
// ensure_publisher_ready) instead of building a brand-new manager — and
|
|
// thus a fresh TCP+TLS handshake — on every health check (backlog#982).
|
|
// ensure_publisher_ready already invalidates the cached manager on a
|
|
// connectivity error so the next attempt rebuilds it.
|
|
match tokio::time::timeout(REDIS_CONNECTION_TIMEOUT_DEFAULT, self.ensure_publisher_ready()).await {
|
|
Ok(Ok(())) => {
|
|
self.connected.store(true, Ordering::SeqCst);
|
|
Ok(true)
|
|
}
|
|
Ok(Err(err)) => {
|
|
mark_target_disconnected_on_connectivity_error(&self.connected, &err);
|
|
Err(err)
|
|
}
|
|
Err(_) => {
|
|
let timeout_err = TargetError::Timeout("Redis connection timed out".to_string());
|
|
invalidate_cache_on_connectivity_error(&timeout_err, || self.invalidate_cached_publisher()).await;
|
|
mark_target_disconnected_on_connectivity_error(&self.connected, &timeout_err);
|
|
Err(timeout_err)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
|
let queued = match build_queued_payload(event.as_ref()) {
|
|
Ok(queued) => queued,
|
|
Err(err) => {
|
|
self.delivery_counters.record_final_failure();
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
if let Some(store) = &self.store {
|
|
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
|
|
self.delivery_counters.record_final_failure();
|
|
return Err(e);
|
|
}
|
|
|
|
debug!(target_id = %self.id, "Event saved to store for Redis target");
|
|
Ok(())
|
|
} else {
|
|
if !self.is_enabled() {
|
|
return Err(TargetError::Disabled);
|
|
}
|
|
|
|
if let Err(err) = self.init_inner().await {
|
|
self.delivery_counters.record_final_failure();
|
|
return Err(err);
|
|
}
|
|
|
|
if let Err(err) = self.send_body(queued.body, &queued.meta).await {
|
|
self.delivery_counters.record_final_failure();
|
|
return Err(err);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
async fn send_raw_from_store(&self, key: Key, body: Vec<u8>, meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
|
debug!(target_id = %self.id, ?key, "Attempting to send queued payload from Redis store");
|
|
|
|
if !self.is_enabled() {
|
|
return Err(TargetError::Disabled);
|
|
}
|
|
|
|
if let Err(err) = self.init_inner().await {
|
|
if is_connectivity_error(&err) {
|
|
warn!(target_id = %self.id, error = %err, "Redis target not ready; queued event remains in store");
|
|
}
|
|
return Err(err);
|
|
}
|
|
|
|
if let Err(err) = self.send_body(body, &meta).await {
|
|
if is_connectivity_error(&err) {
|
|
warn!(target_id = %self.id, error = %err, "Failed to send Redis event from store: target not connected. Event remains queued.");
|
|
}
|
|
return Err(err);
|
|
}
|
|
|
|
debug!(target_id = %self.id, ?key, "Queued Redis payload sent successfully");
|
|
Ok(())
|
|
}
|
|
|
|
async fn close(&self) -> Result<(), TargetError> {
|
|
self.invalidate_cached_publisher().await;
|
|
self.tls_state.lock().reset();
|
|
self.connected.store(false, Ordering::SeqCst);
|
|
info!(target_id = %self.id, "Redis target closed");
|
|
Ok(())
|
|
}
|
|
|
|
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
|
self.store.as_deref()
|
|
}
|
|
|
|
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
|
self.clone_box()
|
|
}
|
|
|
|
async fn init(&self) -> Result<(), TargetError> {
|
|
if !self.is_enabled() {
|
|
return Ok(());
|
|
}
|
|
self.init_inner().await
|
|
}
|
|
|
|
fn is_enabled(&self) -> bool {
|
|
self.args.enable
|
|
}
|
|
|
|
fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
|
|
self.delivery_counters.snapshot(
|
|
self.store.as_deref().map_or(0, |store| store.len() as u64),
|
|
// Redis targets record no terminal failures and keep no failed store.
|
|
0,
|
|
)
|
|
}
|
|
|
|
fn record_final_failure(&self) {
|
|
self.delivery_counters.record_final_failure();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn build_redis_client(args: &RedisArgs) -> Result<Client, TargetError> {
|
|
let mut url = args.url.clone();
|
|
if args.tls.allow_insecure {
|
|
url.set_fragment(Some("insecure"));
|
|
// Mirror the webhook `skip_tls_verify` warning: this disables Redis
|
|
// server certificate verification and must only be used for testing
|
|
// (backlog#982).
|
|
warn!(
|
|
target = %redact_redis_url(&args.url),
|
|
"Redis tls_allow_insecure is enabled: server certificate verification is DISABLED (insecure). Use for testing only."
|
|
);
|
|
}
|
|
|
|
let mut connection_info: ConnectionInfo = url.into_connection_info().map_err(map_redis_error)?;
|
|
|
|
let base_redis = connection_info.redis_settings().clone();
|
|
|
|
let mut redis_settings = base_redis.clone().set_lib_name("rustfs-targets", env!("CARGO_PKG_VERSION"));
|
|
|
|
if let Some(username) = args.username.as_deref().filter(|value| !value.is_empty()) {
|
|
if base_redis.username().is_some_and(|base| base != username) {
|
|
warn!(url_username = ?base_redis.username(), arg_username = %username, "Redis target protocol username from URL is being overridden");
|
|
}
|
|
redis_settings = redis_settings.set_username(username);
|
|
}
|
|
if let Some(password) = args.password.as_deref().filter(|value| !value.is_empty()) {
|
|
if base_redis.password().is_some() {
|
|
warn!("RedisArgs.password overrides password from Redis URL");
|
|
}
|
|
redis_settings = redis_settings.set_password(password);
|
|
}
|
|
|
|
let mut tcp_settings = TcpSettings::default().set_nodelay(true);
|
|
#[cfg(not(target_family = "wasm"))]
|
|
{
|
|
if !args.keep_alive.is_zero() {
|
|
tcp_settings = tcp_settings.set_keepalive(socket2::TcpKeepalive::new().with_time(args.keep_alive));
|
|
}
|
|
}
|
|
|
|
connection_info = connection_info
|
|
.set_redis_settings(redis_settings)
|
|
.set_tcp_settings(tcp_settings);
|
|
|
|
let secure_scheme = matches!(args.url.scheme(), "rediss" | "valkeys");
|
|
if secure_scheme {
|
|
super::ensure_rustls_provider_installed();
|
|
let tls_certs = TlsCertificates {
|
|
client_tls: read_client_tls(&args.tls)?,
|
|
root_cert: read_root_cert(&args.tls)?,
|
|
};
|
|
Client::build_with_tls(connection_info, tls_certs).map_err(map_redis_error)
|
|
} else {
|
|
Client::open(connection_info).map_err(map_redis_error)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn build_redis_connection_manager_config(args: &RedisArgs) -> ConnectionManagerConfig {
|
|
let mut config = ConnectionManagerConfig::new();
|
|
|
|
if let Some(reconnect_retry_attempts) = args.reconnect_retry_attempts {
|
|
config = config.set_number_of_retries(reconnect_retry_attempts);
|
|
}
|
|
if let Some(min_retry_delay) = args.min_retry_delay {
|
|
config = config.set_min_delay(min_retry_delay);
|
|
}
|
|
if let Some(max_retry_delay) = args.max_retry_delay {
|
|
config = config.set_max_delay(max_retry_delay);
|
|
}
|
|
if let Some(connection_timeout) = args.connection_timeout {
|
|
config = config.set_connection_timeout(Some(connection_timeout));
|
|
}
|
|
if let Some(response_timeout) = args.response_timeout {
|
|
config = config.set_response_timeout(Some(response_timeout));
|
|
}
|
|
if let Some(pipeline_buffer_size) = args.pipeline_buffer_size {
|
|
config = config.set_pipeline_buffer_size(pipeline_buffer_size);
|
|
}
|
|
|
|
config
|
|
}
|
|
|
|
pub(crate) async fn ping_redis_server(client: &Client, args: &RedisArgs) -> Result<(), TargetError> {
|
|
let config = build_redis_connection_manager_config(args);
|
|
let mut conn = client
|
|
.get_connection_manager_with_config(config)
|
|
.await
|
|
.map_err(map_redis_error)?;
|
|
|
|
cmd("PING").query_async::<String>(&mut conn).await.map_err(map_redis_error)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn read_client_tls(tls: &RedisTlsConfig) -> Result<Option<ClientTlsConfig>, TargetError> {
|
|
if tls.client_cert_path.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let client_cert = std::fs::read(&tls.client_cert_path)
|
|
.map_err(|e| TargetError::Configuration(format!("Failed to read Redis client cert: {e}")))?;
|
|
let client_key = std::fs::read(&tls.client_key_path)
|
|
.map_err(|e| TargetError::Configuration(format!("Failed to read Redis client key: {e}")))?;
|
|
|
|
Ok(Some(ClientTlsConfig { client_cert, client_key }))
|
|
}
|
|
|
|
fn read_root_cert(tls: &RedisTlsConfig) -> Result<Option<Vec<u8>>, TargetError> {
|
|
if tls.ca_path.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let pem =
|
|
std::fs::read(&tls.ca_path).map_err(|e| TargetError::Configuration(format!("Failed to read Redis root CA cert: {e}")))?;
|
|
let mut reader = BufReader::new(pem.as_slice());
|
|
let certs_der = CertificateDer::pem_reader_iter(&mut reader)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| TargetError::Configuration(format!("Failed to parse Redis root CA cert: {e}")))?;
|
|
|
|
if certs_der.is_empty() {
|
|
return Err(TargetError::Configuration(
|
|
"Redis root CA cert did not contain any parsable certificates".to_string(),
|
|
));
|
|
}
|
|
|
|
Ok(Some(pem))
|
|
}
|
|
|
|
fn map_redis_error(err: RedisError) -> TargetError {
|
|
use redis::ErrorKind;
|
|
|
|
match err.kind() {
|
|
ErrorKind::AuthenticationFailed => TargetError::Authentication(err.to_string()),
|
|
ErrorKind::RESP3NotSupported => TargetError::Initialization(err.to_string()),
|
|
ErrorKind::InvalidClientConfig => TargetError::Configuration(err.to_string()),
|
|
ErrorKind::Io if err.is_timeout() => TargetError::Timeout(err.to_string()),
|
|
ErrorKind::Io if err.is_connection_dropped() || err.is_connection_refusal() => TargetError::NotConnected,
|
|
ErrorKind::Io => TargetError::Network(err.to_string()),
|
|
_ if err.is_unrecoverable_error() => TargetError::NotConnected,
|
|
_ => TargetError::Request(err.to_string()),
|
|
}
|
|
}
|
|
|
|
fn compute_retry_delay(attempt: usize, min_delay: Duration, max_delay: Duration) -> Duration {
|
|
let shift = attempt.saturating_sub(1).min(16) as u32;
|
|
let factor = 1u32 << shift;
|
|
min_delay.saturating_mul(factor).min(max_delay)
|
|
}
|
|
|
|
/// Coordinated TLS hot-reload implementation for Redis targets.
|
|
///
|
|
/// The coordinator calls these methods on a background poll loop to detect
|
|
/// TLS file changes and rebuild the Redis client without restarting.
|
|
#[async_trait]
|
|
impl<E> ReloadableTargetTls for RedisTarget<E>
|
|
where
|
|
E: PluginEvent,
|
|
{
|
|
type Material = Client;
|
|
|
|
fn tls_input_set(&self) -> TargetTlsInputSet {
|
|
TargetTlsInputSet {
|
|
ca_path: self.args.tls.ca_path.clone(),
|
|
client_cert_path: self.args.tls.client_cert_path.clone(),
|
|
client_key_path: self.args.tls.client_key_path.clone(),
|
|
target_label: format!("redis:{}", self.id.id),
|
|
}
|
|
}
|
|
|
|
async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
|
|
build_redis_client(&self.args)
|
|
}
|
|
|
|
async fn apply_tls_material(
|
|
&self,
|
|
_generation: TargetTlsGeneration,
|
|
material: Arc<Self::Material>,
|
|
_mode: ReloadApplyMode,
|
|
) -> Result<(), TargetError> {
|
|
*self.publisher_client.lock() = (*material).clone();
|
|
self.invalidate_cached_publisher().await;
|
|
Ok(())
|
|
}
|
|
|
|
async fn validate_tls_files(&self) -> Result<(), TargetError> {
|
|
validate_tls_material(&self.args.tls.ca_path, &self.args.tls.client_cert_path, &self.args.tls.client_key_path)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use redis::ProtocolVersion;
|
|
use std::sync::atomic::Ordering;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
|
|
fn absolute_test_path(path: &str) -> String {
|
|
std::env::temp_dir().join(path).to_string_lossy().into_owned()
|
|
}
|
|
|
|
fn base_args() -> RedisArgs {
|
|
RedisArgs {
|
|
enable: true,
|
|
url: Url::parse("redis://127.0.0.1:6379").unwrap(),
|
|
channel: "rustfs-events".to_string(),
|
|
username: None,
|
|
password: None,
|
|
tls: RedisTlsConfig::default(),
|
|
keep_alive: Duration::from_secs(15),
|
|
queue_dir: String::new(),
|
|
queue_limit: 0,
|
|
max_retry_attempts: 3,
|
|
reconnect_retry_attempts: None,
|
|
min_retry_delay: None,
|
|
max_retry_delay: None,
|
|
connection_timeout: None,
|
|
response_timeout: None,
|
|
pipeline_buffer_size: None,
|
|
target_type: TargetType::NotifyEvent,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_empty_channel() {
|
|
let args = RedisArgs {
|
|
channel: String::new(),
|
|
..base_args()
|
|
};
|
|
assert!(args.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_accepts_embedded_credentials_in_url() {
|
|
let url = Url::parse("redis://user:pass@127.0.0.1:6379").unwrap();
|
|
assert!(validate_redis_url(&url).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_relative_queue_dir() {
|
|
let args = RedisArgs {
|
|
queue_dir: "relative/path".to_string(),
|
|
..base_args()
|
|
};
|
|
assert!(args.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_zero_connection_timeout() {
|
|
let args = RedisArgs {
|
|
connection_timeout: Some(Duration::ZERO),
|
|
..base_args()
|
|
};
|
|
|
|
assert!(args.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_zero_response_timeout() {
|
|
let args = RedisArgs {
|
|
response_timeout: Some(Duration::ZERO),
|
|
..base_args()
|
|
};
|
|
|
|
assert!(args.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_accepts_custom_ca_tls_policy() {
|
|
let args = RedisArgs {
|
|
url: Url::parse("rediss://127.0.0.1:6379").unwrap(),
|
|
tls: RedisTlsConfig {
|
|
policy: Some(RedisTlsPolicy::CustomCa),
|
|
ca_path: absolute_test_path("redis-ca.pem"),
|
|
..RedisTlsConfig::default()
|
|
},
|
|
..base_args()
|
|
};
|
|
assert!(args.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn debug_redacts_passwords_from_url_and_args() {
|
|
let args = RedisArgs {
|
|
url: Url::parse("redis://user:secret@127.0.0.1:6379/0").unwrap(),
|
|
password: Some("override-secret".to_string()),
|
|
..base_args()
|
|
};
|
|
|
|
let rendered = format!("{args:?}");
|
|
assert!(!rendered.contains("secret"), "url password leaked: {rendered}");
|
|
assert!(!rendered.contains("override-secret"), "args password leaked: {rendered}");
|
|
assert!(rendered.contains("redis://user:***@127.0.0.1:6379/0"));
|
|
assert!(rendered.contains("\"***REDACTED***\""));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_insecure_tls_for_non_secure_scheme() {
|
|
let args = RedisArgs {
|
|
tls: RedisTlsConfig {
|
|
allow_insecure: true,
|
|
..RedisTlsConfig::default()
|
|
},
|
|
..base_args()
|
|
};
|
|
assert!(args.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn build_redis_client_preserves_url_auth_when_args_are_none() {
|
|
let args = RedisArgs {
|
|
url: Url::parse("redis://user:pass@127.0.0.1:6379/2").unwrap(),
|
|
..base_args()
|
|
};
|
|
|
|
let client = build_redis_client(&args).expect("client should build");
|
|
let info = client.get_connection_info();
|
|
let redis = info.redis_settings();
|
|
|
|
assert_eq!(redis.username(), Some("user"));
|
|
assert_eq!(redis.password(), Some("pass"));
|
|
assert_eq!(redis.db(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn build_redis_client_overrides_url_auth_when_args_are_set() {
|
|
let args = RedisArgs {
|
|
url: Url::parse("redis://user:pass@127.0.0.1:6379/2").unwrap(),
|
|
username: Some("override-user".to_string()),
|
|
password: Some("override-pass".to_string()),
|
|
..base_args()
|
|
};
|
|
|
|
let client = build_redis_client(&args).expect("client should build");
|
|
let redis = client.get_connection_info().redis_settings();
|
|
|
|
assert_eq!(redis.username(), Some("override-user"));
|
|
assert_eq!(redis.password(), Some("override-pass"));
|
|
assert_eq!(redis.db(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn build_redis_client_preserves_url_protocol_when_args_do_not_override_it() {
|
|
let args = RedisArgs {
|
|
url: Url::parse("redis://127.0.0.1:6379/?protocol=resp3").unwrap(),
|
|
..base_args()
|
|
};
|
|
|
|
let client = build_redis_client(&args).expect("client should build");
|
|
let redis = client.get_connection_info().redis_settings();
|
|
|
|
assert_eq!(redis.protocol(), ProtocolVersion::RESP3);
|
|
}
|
|
|
|
#[test]
|
|
fn build_redis_client_enables_insecure_tls_when_requested() {
|
|
let args = RedisArgs {
|
|
url: Url::parse("rediss://127.0.0.1:6379").unwrap(),
|
|
tls: RedisTlsConfig {
|
|
allow_insecure: true,
|
|
..RedisTlsConfig::default()
|
|
},
|
|
..base_args()
|
|
};
|
|
|
|
let client = build_redis_client(&args).expect("client should build");
|
|
match client.get_connection_info().addr() {
|
|
redis::ConnectionAddr::TcpTls { insecure, .. } => assert!(*insecure),
|
|
other => panic!("expected TLS address, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalidate_cached_publisher_keeps_connected_state() {
|
|
let target = RedisTarget::<String>::new("redis:test".to_string(), base_args()).expect("target should build");
|
|
target.connected.store(true, Ordering::SeqCst);
|
|
|
|
target.invalidate_cached_publisher().await;
|
|
|
|
assert!(target.connected.load(Ordering::SeqCst));
|
|
assert!(target.publisher.lock().await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn is_active_succeeds_when_ping_returns_pong() {
|
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
Ok(listener) => listener,
|
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
|
Err(err) => panic!("bind fake redis: {err}"),
|
|
};
|
|
let addr = listener.local_addr().expect("listener addr");
|
|
tokio::spawn(run_fake_redis_server(listener, false));
|
|
|
|
let mut args = base_args();
|
|
args.url = Url::parse(&format!("redis://{}:{}/0", addr.ip(), addr.port())).unwrap();
|
|
|
|
let target = RedisTarget::<String>::new("redis:test".to_string(), args).expect("target should build");
|
|
target.connected.store(false, Ordering::SeqCst);
|
|
|
|
assert!(target.is_active().await.expect("ping should succeed"));
|
|
assert!(target.connected.load(Ordering::SeqCst));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn is_active_returns_error_when_ping_fails() {
|
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
Ok(listener) => listener,
|
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
|
Err(err) => panic!("bind fake redis: {err}"),
|
|
};
|
|
let addr = listener.local_addr().expect("listener addr");
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let Ok((socket, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
drop(socket);
|
|
}
|
|
});
|
|
|
|
let mut args = base_args();
|
|
args.url = Url::parse(&format!("redis://{}:{}/0", addr.ip(), addr.port())).unwrap();
|
|
|
|
let target = RedisTarget::<String>::new("redis:test".to_string(), args).expect("target should build");
|
|
target.connected.store(true, Ordering::SeqCst);
|
|
|
|
let err = target.is_active().await.expect_err("ping should fail");
|
|
assert!(matches!(
|
|
err,
|
|
TargetError::NotConnected | TargetError::Network(_) | TargetError::Timeout(_)
|
|
));
|
|
assert!(!target.connected.load(Ordering::SeqCst));
|
|
assert!(target.publisher.lock().await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn is_active_returns_false_when_disabled() {
|
|
let target = RedisTarget::<String>::new(
|
|
"redis:test".to_string(),
|
|
RedisArgs {
|
|
enable: false,
|
|
..base_args()
|
|
},
|
|
)
|
|
.expect("target should build");
|
|
|
|
assert!(!target.is_active().await.expect("disabled target should not probe"));
|
|
}
|
|
|
|
#[test]
|
|
fn compute_retry_delay_is_bounded() {
|
|
let min = Duration::from_millis(100);
|
|
let max = Duration::from_secs(2);
|
|
|
|
assert_eq!(compute_retry_delay(1, min, max), min);
|
|
assert!(compute_retry_delay(5, min, max) <= max);
|
|
assert_eq!(compute_retry_delay(50, min, max), max);
|
|
}
|
|
|
|
#[test]
|
|
fn queued_payload_uses_event_data_in_records() {
|
|
let payload = build_queued_payload(&EntityTarget {
|
|
object_name: "greeting+file+%282%29.csv".to_string(),
|
|
bucket_name: "bucket".to_string(),
|
|
event_name: rustfs_s3_types::EventName::ObjectCreatedPut,
|
|
data: "payload-data".to_string(),
|
|
})
|
|
.expect("payload should build");
|
|
|
|
let value: serde_json::Value = serde_json::from_slice(&payload.body).expect("payload JSON");
|
|
assert_eq!(value["Key"], "bucket/greeting file (2).csv");
|
|
assert_eq!(value["Records"][0], "payload-data");
|
|
}
|
|
|
|
fn parse_resp_array(input: &[u8]) -> Option<(Vec<String>, usize)> {
|
|
if input.first()? != &b'*' {
|
|
return None;
|
|
}
|
|
|
|
let mut index = 1;
|
|
let len_end = input[index..].windows(2).position(|w| w == b"\r\n")? + index;
|
|
let items: usize = std::str::from_utf8(&input[index..len_end]).ok()?.parse().ok()?;
|
|
index = len_end + 2;
|
|
|
|
let mut out = Vec::with_capacity(items);
|
|
for _ in 0..items {
|
|
if input.get(index)? != &b'$' {
|
|
return None;
|
|
}
|
|
index += 1;
|
|
let bulk_end = input[index..].windows(2).position(|w| w == b"\r\n")? + index;
|
|
let bulk_len: usize = std::str::from_utf8(&input[index..bulk_end]).ok()?.parse().ok()?;
|
|
index = bulk_end + 2;
|
|
|
|
let data_end = index.checked_add(bulk_len)?;
|
|
let data = std::str::from_utf8(input.get(index..data_end)?).ok()?.to_string();
|
|
out.push(data);
|
|
index = data_end + 2;
|
|
}
|
|
|
|
Some((out, index))
|
|
}
|
|
|
|
async fn run_fake_redis_server(listener: TcpListener, close_first_connection: bool) {
|
|
let mut first = close_first_connection;
|
|
loop {
|
|
let Ok((mut socket, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
|
|
if first {
|
|
first = false;
|
|
drop(socket);
|
|
continue;
|
|
}
|
|
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0_u8; 4096];
|
|
let mut pending = Vec::new();
|
|
|
|
loop {
|
|
let Ok(read) = socket.read(&mut buf).await else {
|
|
return;
|
|
};
|
|
if read == 0 {
|
|
return;
|
|
}
|
|
|
|
pending.extend_from_slice(&buf[..read]);
|
|
|
|
while let Some((command, consumed)) = parse_resp_array(&pending) {
|
|
pending.drain(..consumed);
|
|
let response = match command.first().map(|s| s.as_str()) {
|
|
Some("PING") => b"+PONG\r\n".as_slice(),
|
|
Some("PUBLISH") => b":1\r\n".as_slice(),
|
|
Some("CLIENT") => b"+OK\r\n".as_slice(),
|
|
Some("AUTH") => b"+OK\r\n".as_slice(),
|
|
Some("SELECT") => b"+OK\r\n".as_slice(),
|
|
Some("HELLO") => b"%1\r\n+server\r\n+redis\r\n".as_slice(),
|
|
_ => b"+OK\r\n".as_slice(),
|
|
};
|
|
|
|
if socket.write_all(response).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_body_keeps_connected_true_when_retryable_error_eventually_recovers() {
|
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
Ok(listener) => listener,
|
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
|
Err(err) => panic!("bind fake redis: {err}"),
|
|
};
|
|
let addr = listener.local_addr().expect("listener addr");
|
|
tokio::spawn(run_fake_redis_server(listener, true));
|
|
|
|
let mut args = base_args();
|
|
args.url = Url::parse(&format!("redis://{}:{}/0", addr.ip(), addr.port())).unwrap();
|
|
args.max_retry_attempts = 3;
|
|
args.reconnect_retry_attempts = Some(0);
|
|
args.min_retry_delay = Some(Duration::from_millis(10));
|
|
args.max_retry_delay = Some(Duration::from_millis(20));
|
|
|
|
let target = RedisTarget::<String>::new("redis:test".to_string(), args).expect("target should build");
|
|
let meta = QueuedPayloadMeta::new(
|
|
rustfs_s3_types::EventName::ObjectCreatedPut,
|
|
"bucket".to_string(),
|
|
"object".to_string(),
|
|
"application/json",
|
|
2,
|
|
);
|
|
|
|
target.connected.store(true, Ordering::SeqCst);
|
|
target
|
|
.send_body(b"{}".to_vec(), &meta)
|
|
.await
|
|
.expect("eventual retry should succeed");
|
|
|
|
assert!(target.connected.load(Ordering::SeqCst));
|
|
assert_eq!(target.delivery_snapshot().total_messages, 1);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn delivery_budget_respects_response_timeout_longer_than_sixty_seconds() {
|
|
let mut args = base_args();
|
|
args.max_retry_attempts = 1;
|
|
args.response_timeout = Some(Duration::from_secs(90));
|
|
let manager_config = build_redis_connection_manager_config(&args);
|
|
|
|
assert_eq!(manager_config.response_timeout(), Some(Duration::from_secs(90)));
|
|
assert_eq!(redis_total_delivery_timeout(&args), Duration::from_secs(97));
|
|
with_delivery_deadline(redis_total_delivery_timeout(&args), "Redis delivery", async {
|
|
tokio::time::sleep(Duration::from_secs(70)).await;
|
|
Ok::<_, TargetError>(())
|
|
})
|
|
.await
|
|
.expect("the configured delivery budget must not impose a fixed sixty-second cap");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_body_sets_connected_false_after_retry_exhaustion() {
|
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
Ok(listener) => listener,
|
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
|
Err(err) => panic!("bind fake redis: {err}"),
|
|
};
|
|
let addr = listener.local_addr().expect("listener addr");
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let Ok((socket, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
drop(socket);
|
|
}
|
|
});
|
|
|
|
let mut args = base_args();
|
|
args.url = Url::parse(&format!("redis://{}:{}/0", addr.ip(), addr.port())).unwrap();
|
|
args.max_retry_attempts = 2;
|
|
args.reconnect_retry_attempts = Some(0);
|
|
args.min_retry_delay = Some(Duration::from_millis(10));
|
|
args.max_retry_delay = Some(Duration::from_millis(20));
|
|
|
|
let target = RedisTarget::<String>::new("redis:test".to_string(), args).expect("target should build");
|
|
let meta = QueuedPayloadMeta::new(
|
|
rustfs_s3_types::EventName::ObjectCreatedPut,
|
|
"bucket".to_string(),
|
|
"object".to_string(),
|
|
"application/json",
|
|
2,
|
|
);
|
|
|
|
let err = target
|
|
.send_body(b"{}".to_vec(), &meta)
|
|
.await
|
|
.expect_err("all retries should fail");
|
|
assert!(matches!(
|
|
err,
|
|
TargetError::NotConnected | TargetError::Network(_) | TargetError::Timeout(_)
|
|
));
|
|
assert!(!target.connected.load(Ordering::SeqCst));
|
|
assert_eq!(target.delivery_snapshot().total_messages, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_raw_from_store_failure_does_not_count_as_success() {
|
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
Ok(listener) => listener,
|
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
|
Err(err) => panic!("bind fake redis: {err}"),
|
|
};
|
|
let addr = listener.local_addr().expect("listener addr");
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let Ok((socket, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
drop(socket);
|
|
}
|
|
});
|
|
|
|
let mut args = base_args();
|
|
args.url = Url::parse(&format!("redis://{}:{}/0", addr.ip(), addr.port())).unwrap();
|
|
args.max_retry_attempts = 1;
|
|
args.reconnect_retry_attempts = Some(0);
|
|
|
|
let target = RedisTarget::<String>::new("redis:test".to_string(), args).expect("target should build");
|
|
let meta = QueuedPayloadMeta::new(
|
|
rustfs_s3_types::EventName::ObjectCreatedPut,
|
|
"bucket".to_string(),
|
|
"object".to_string(),
|
|
"application/json",
|
|
2,
|
|
);
|
|
|
|
let err = target
|
|
.send_raw_from_store(
|
|
Key {
|
|
name: "key".to_string(),
|
|
extension: String::new(),
|
|
item_count: 1,
|
|
compress: false,
|
|
},
|
|
b"{}".to_vec(),
|
|
meta,
|
|
)
|
|
.await
|
|
.expect_err("send from store should fail");
|
|
|
|
assert!(matches!(
|
|
err,
|
|
TargetError::NotConnected | TargetError::Network(_) | TargetError::Timeout(_)
|
|
));
|
|
assert_eq!(target.delivery_snapshot().total_messages, 0);
|
|
}
|
|
}
|