Files
rustfs/crates/targets/src/target/postgres.rs
T
cxymds 1655f3192e fix(notify): unify runtime lifecycle coordination (#5088)
* fix(notify): unify runtime lifecycle coordination

* fix(notify): repair lifecycle convergence checks

* fix(admin): expose effective notify state (#5097)
2026-07-22 05:01:15 +00:00

1280 lines
49 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.
//! PostgreSQL event notification target.
//!
//! Persists S3 events into a user-provided PostgreSQL table using the
//! `Target` trait. Two output formats are supported:
//!
//! - `namespace` (default): single row per object key, UPSERT on each event.
//! - `access`: append-only audit log with one row per delivered event.
//!
//! TLS is provided via `tokio-postgres-rustls` with rustls + aws-lc-rs.
//! When `tls_ca` is empty the connector loads native OS trust roots.
//! Connection pooling is delegated to `deadpool-postgres`; the pool itself
//! is `Clone`, so no `Mutex` is required around it.
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, open_target_queue_store, persist_queued_payload_to_store, redacted_optional_secret,
redacted_secret, with_delivery_deadline,
},
};
use async_trait::async_trait;
use deadpool_postgres::{Client as PooledClient, Manager, ManagerConfig, Pool, RecyclingMethod, Runtime, Timeouts};
use rustfs_config::{POSTGRES_DSN_STRING, POSTGRES_TLS_CA, POSTGRES_TLS_CLIENT_CERT, POSTGRES_TLS_CLIENT_KEY};
use rustfs_s3_types::EventName;
use rustfs_tls_runtime::{load_certs, load_private_key};
use std::fmt;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio_postgres::Config;
use tokio_postgres_rustls::MakeRustlsConnect;
use tracing::{info, instrument, warn};
use url::Url;
use uuid::Uuid;
const TARGET_LOG_KEY_FIELD: &str = "Key";
/// Bounds the underlying TCP connect + startup handshake for a new backend
/// connection so an unreachable server cannot block a pool slot forever.
const POSTGRES_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Maximum time `pool.get()` waits for a free slot before returning a timeout.
const POSTGRES_POOL_WAIT_TIMEOUT: Duration = Duration::from_secs(15);
/// Maximum time to create a brand-new pooled connection.
const POSTGRES_POOL_CREATE_TIMEOUT: Duration = Duration::from_secs(15);
/// Maximum time to recycle (health-check) an idle pooled connection.
const POSTGRES_POOL_RECYCLE_TIMEOUT: Duration = Duration::from_secs(10);
/// Absolute ceiling on a single checkout, wrapping `pool.get()` in a Tokio
/// timeout as a belt-and-suspenders guard on top of the deadpool timeouts.
const POSTGRES_POOL_CHECKOUT_HARD_LIMIT: Duration = Duration::from_secs(20);
/// Absolute ceiling for one SQL delivery, including pool checkout and execution.
const POSTGRES_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);
/// Returns `true` for any `s3:ObjectRemoved:*` event.
///
/// Used by the `namespace` format so that object deletions remove the row
/// instead of leaving stale state behind via an UPSERT.
fn is_object_removed_event(event: &EventName) -> bool {
event.as_str().starts_with("s3:ObjectRemoved")
}
/// Checks out a client from the pool, wrapping `pool.get()` in a Tokio timeout.
///
/// The deadpool wait/create timeouts already bound the checkout, but the outer
/// timeout guarantees a hard ceiling even if a lower layer misbehaves. Any
/// timeout maps to `TargetError::Timeout`, which is a connectivity error so the
/// queue store retains the payload for replay.
async fn checkout_client(pool: &Pool, context: &str) -> Result<PooledClient, TargetError> {
match tokio::time::timeout(POSTGRES_POOL_CHECKOUT_HARD_LIMIT, pool.get()).await {
Ok(Ok(client)) => Ok(client),
Ok(Err(e)) => Err(map_pool_error(e, context)),
Err(_) => Err(TargetError::Timeout(format!(
"{context}: pool checkout exceeded {}s hard limit",
POSTGRES_POOL_CHECKOUT_HARD_LIMIT.as_secs()
))),
}
}
/// Output format selection for the PostgreSQL target.
///
/// - `Namespace`: single-row UPSERT per object key (MinIO `namespace` style).
/// - `Access`: append-only insert per event (audit/compliance use case).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PostgresFormat {
Namespace,
Access,
}
impl PostgresFormat {
pub fn as_str(&self) -> &'static str {
match self {
PostgresFormat::Namespace => "namespace",
PostgresFormat::Access => "access",
}
}
}
/// Parses the `format` configuration value.
///
/// Accepts case-insensitive `"namespace"` or `"access"`. Defaults to
/// `Namespace` when the value is missing or empty.
pub fn parse_postgres_format(value: Option<&str>) -> Result<PostgresFormat, TargetError> {
let raw = value.unwrap_or("").trim();
if raw.is_empty() {
return Ok(PostgresFormat::Namespace);
}
match raw.to_ascii_lowercase().as_str() {
"namespace" => Ok(PostgresFormat::Namespace),
"access" => Ok(PostgresFormat::Access),
other => Err(TargetError::Configuration(format!(
"PostgreSQL format must be 'namespace' or 'access', got: {other}"
))),
}
}
/// Parsed representation of a PostgreSQL DSN string.
#[derive(Clone, PartialEq, Eq)]
pub struct PostgresDsn {
pub host: String,
pub port: u16,
pub user: String,
pub password: Option<String>,
pub database: String,
pub schema: String,
}
impl fmt::Debug for PostgresDsn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PostgresDsn")
.field("host", &self.host)
.field("port", &self.port)
.field("user", &self.user)
.field("password", &redacted_optional_secret(self.password.as_deref()))
.field("database", &self.database)
.field("schema", &self.schema)
.finish()
}
}
impl PostgresDsn {
/// Parses and validates PostgreSQL DSN string.
///
/// Supports canonical URL format like:
/// `postgres://user:password@host:5432/database?search_path=public`
pub fn parse(dsn_string: &str) -> Result<Self, TargetError> {
let input = dsn_string.trim();
if input.is_empty() {
return Err(TargetError::Configuration(format!("PostgreSQL {POSTGRES_DSN_STRING} cannot be empty")));
}
let url = Url::parse(input).map_err(|e| TargetError::Configuration(format!("invalid PostgreSQL dsn_string: {e}")))?;
let scheme = url.scheme().to_ascii_lowercase();
if scheme != "postgres" && scheme != "postgresql" {
return Err(TargetError::Configuration(
"invalid PostgreSQL dsn_string: URL scheme must be postgres or postgresql".to_string(),
));
}
if url.host_str().is_none() {
return Err(TargetError::Configuration(
"invalid PostgreSQL dsn_string: host cannot be empty".to_string(),
));
}
let user = url.username().trim();
if user.is_empty() {
return Err(TargetError::Configuration(
"invalid PostgreSQL dsn_string: user cannot be empty".to_string(),
));
}
let host = url.host_str().unwrap_or_default().trim();
if host.is_empty() {
return Err(TargetError::Configuration(
"invalid PostgreSQL dsn_string: host cannot be empty".to_string(),
));
}
let port = url.port().unwrap_or(5432);
let database = url.path().trim_start_matches('/').trim();
if database.is_empty() {
return Err(TargetError::Configuration(
"invalid PostgreSQL dsn_string: database cannot be empty".to_string(),
));
}
let mut schema = "public".to_string();
for (key, value) in url.query_pairs() {
if !key.eq_ignore_ascii_case("search_path") {
return Err(TargetError::Configuration(format!(
"invalid PostgreSQL dsn_string: unsupported query parameter '{key}'"
)));
}
let value = value.trim();
if value.is_empty() {
return Err(TargetError::Configuration(
"invalid PostgreSQL dsn_string: search_path cannot be empty".to_string(),
));
}
let first_schema = value
.split(',')
.next()
.map(str::trim)
.filter(|segment| !segment.is_empty())
.ok_or_else(|| {
TargetError::Configuration(
"invalid PostgreSQL dsn_string: search_path must contain at least one schema".to_string(),
)
})?;
validate_pg_identifier(first_schema, "schema")?;
schema = first_schema.to_string();
}
Ok(PostgresDsn {
host: host.to_string(),
port,
user: user.to_string(),
password: url.password().map(ToOwned::to_owned),
database: database.to_string(),
schema,
})
}
}
/// Returns a redacted version of the DSN string with the password replaced by
/// `***` while preserving non-secret connection details for diagnostics.
pub(crate) fn redact_postgres_dsn(dsn_string: &str) -> String {
let input = dsn_string.trim();
if input.is_empty() {
return String::new();
}
let mut url = match Url::parse(input) {
Ok(url) => url,
Err(_) => return "***".to_string(),
};
let scheme = url.scheme().to_ascii_lowercase();
if scheme != "postgres" && scheme != "postgresql" {
return "***".to_string();
}
if url.password().is_some() {
let _ = url.set_password(Some("***"));
}
let mut query_pairs: Vec<(String, String)> = Vec::new();
let mut has_password_param = false;
for (key, value) in url.query_pairs() {
if key.eq_ignore_ascii_case("password") {
has_password_param = true;
query_pairs.push((key.into_owned(), "***".to_string()));
} else {
query_pairs.push((key.into_owned(), value.into_owned()));
}
}
if has_password_param {
url.set_query(None);
let mut serializer = url.query_pairs_mut();
for (key, value) in query_pairs {
serializer.append_pair(&key, &value);
}
}
url.to_string()
}
/// Validates a PostgreSQL identifier (schema or table name).
///
/// Accepts only `^[A-Za-z_][A-Za-z0-9_]*$`. Quoted identifiers, dots, and
/// special characters are intentionally rejected to keep SQL string
/// construction safe without runtime escaping.
pub fn validate_pg_identifier(name: &str, kind: &str) -> Result<(), TargetError> {
if name.is_empty() {
return Err(TargetError::Configuration(format!("PostgreSQL {kind} cannot be empty")));
}
let mut chars = name.chars();
let Some(first) = chars.next() else {
return Err(TargetError::Configuration(format!("PostgreSQL {kind} cannot be empty")));
};
if !(first.is_ascii_alphabetic() || first == '_') {
return Err(TargetError::Configuration(format!(
"PostgreSQL {kind} must start with a letter or underscore"
)));
}
for c in chars {
if !(c.is_ascii_alphanumeric() || c == '_') {
return Err(TargetError::Configuration(format!(
"PostgreSQL {kind} must match ^[A-Za-z_][A-Za-z0-9_]*$"
)));
}
}
Ok(())
}
/// PostgreSQL target configuration.
///
/// Implements a manual `Debug` that redacts the DSN password to prevent secret
/// leakage through logging or `tracing::instrument` capture.
#[derive(Clone)]
pub struct PostgresArgs {
pub enable: bool,
// Connection
pub dsn_string: String,
// Schema/Table/Format
pub schema: String,
pub table: String,
pub format: PostgresFormat,
// TLS
pub tls_required: bool,
pub tls_ca: String,
pub tls_client_cert: String,
pub tls_client_key: String,
// Queue
pub queue_dir: String,
pub queue_limit: u64,
pub target_type: TargetType,
}
impl fmt::Debug for PostgresArgs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PostgresArgs")
.field("enable", &self.enable)
.field("dsn_string", &redact_postgres_dsn(&self.dsn_string))
.field("schema", &self.schema)
.field("table", &self.table)
.field("format", &self.format)
.field("tls_required", &self.tls_required)
.field("tls_ca", &self.tls_ca)
.field("tls_client_cert", &self.tls_client_cert)
.field("tls_client_key", &redacted_secret(&self.tls_client_key))
.field("queue_dir", &self.queue_dir)
.field("queue_limit", &self.queue_limit)
.field("target_type", &self.target_type)
.finish()
}
}
impl PostgresArgs {
pub fn validate(&self) -> Result<(), TargetError> {
if !self.enable {
return Ok(());
}
let parsed = PostgresDsn::parse(&self.dsn_string)?;
if self.schema.trim().is_empty() {
return Err(TargetError::Configuration("PostgreSQL schema cannot be empty".to_string()));
}
validate_pg_identifier(&self.schema, "schema")?;
if self.schema != parsed.schema {
return Err(TargetError::Configuration(format!(
"PostgreSQL schema must match DSN search_path first schema ('{}')",
parsed.schema
)));
}
validate_pg_identifier(&self.table, "table")?;
// TLS pair must be both empty or both set
if self.tls_client_cert.is_empty() != self.tls_client_key.is_empty() {
return Err(TargetError::Configuration(format!(
"PostgreSQL {POSTGRES_TLS_CLIENT_CERT} and {POSTGRES_TLS_CLIENT_KEY} must be specified together"
)));
}
// Optional TLS path values must be absolute when present
if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() {
return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CA} must be an absolute path")));
}
if !self.tls_client_cert.is_empty() && !Path::new(&self.tls_client_cert).is_absolute() {
return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CLIENT_CERT} must be an absolute path")));
}
if !self.tls_client_key.is_empty() && !Path::new(&self.tls_client_key).is_absolute() {
return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CLIENT_KEY} must be an absolute path")));
}
if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
return Err(TargetError::Configuration(
"PostgreSQL queue directory must be an absolute path".to_string(),
));
}
Ok(())
}
}
/// Returns the qualified `"schema"."table"` SQL identifier for `args`.
///
/// Both schema and table are pre-validated in `PostgresArgs::validate()` so the
/// values cannot contain quote, dot, or whitespace characters; double-quoting
/// preserves case-sensitivity for users who created their tables with quoted
/// identifiers.
pub fn qualified_table(schema: &str, table: &str) -> String {
format!(r#""{schema}"."{table}""#)
}
/// SQL for the `namespace` format. Performs UPSERT keyed on the object key.
pub fn namespace_upsert_sql(schema: &str, table: &str) -> String {
format!(
"INSERT INTO {} (key, value) VALUES ($1, $2::jsonb) \
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
qualified_table(schema, table)
)
}
/// SQL for the `namespace` format on object removal. Deletes the row keyed on
/// the object key so the `namespace` table stays consistent with the object
/// lifecycle instead of retaining stale state after a delete.
pub fn namespace_delete_sql(schema: &str, table: &str) -> String {
format!("DELETE FROM {} WHERE key = $1", qualified_table(schema, table))
}
/// SQL for the `access` format. Append-only with `event_id` as PK so that
/// store-replay scenarios silently skip duplicates while distinct events still
/// land as separate rows.
pub fn access_insert_sql(schema: &str, table: &str) -> String {
format!(
"INSERT INTO {} (event_id, event_name, key, value, queued_at_ms) \
VALUES ($1, $2, $3, $4::jsonb, $5) \
ON CONFLICT (event_id) DO NOTHING",
qualified_table(schema, table)
)
}
/// SQL used by both `init()` and the connectivity probe to verify the table
/// exists and is readable without producing rows or triggering side effects.
pub fn table_probe_sql(schema: &str, table: &str) -> String {
format!("SELECT 1 FROM {} LIMIT 0", qualified_table(schema, table))
}
/// Builds a rustls `ClientConfig` for the PostgreSQL connection.
///
/// When `tls_ca` is empty the OS native trust store is used via
/// `rustls-native-certs` (0.8 API: `CertificateResult { certs, errors }`).
/// When `tls_client_cert` and `tls_client_key` are both set the connection
/// uses mTLS authentication; otherwise no client cert is sent.
pub fn build_tls_config(args: &PostgresArgs) -> Result<rustls::ClientConfig, TargetError> {
super::ensure_rustls_provider_installed();
let mut root_store = rustls::RootCertStore::empty();
if args.tls_ca.is_empty() {
let result = rustls_native_certs::load_native_certs();
if !result.errors.is_empty() {
warn!(error_count = result.errors.len(), "some native CA certs failed to load");
}
if result.certs.is_empty() {
return Err(TargetError::Configuration(
"no native CA certs available; specify tls_ca explicitly".to_string(),
));
}
for cert in result.certs {
// Skip individual add failures; corrupted certs in the system store
// shouldn't block the rest from loading.
let _ = root_store.add(cert);
}
} else {
let certs =
load_certs(&args.tls_ca).map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CA}: {e}")))?;
for cert in certs {
root_store
.add(cert)
.map_err(|e| TargetError::Configuration(format!("failed to add CA cert: {e}")))?;
}
}
let builder = rustls::ClientConfig::builder().with_root_certificates(root_store);
let client_config = if !args.tls_client_cert.is_empty() && !args.tls_client_key.is_empty() {
let certs = load_certs(&args.tls_client_cert)
.map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CLIENT_CERT}: {e}")))?;
let key = load_private_key(&args.tls_client_key)
.map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CLIENT_KEY}: {e}")))?;
builder
.with_client_auth_cert(certs, key)
.map_err(|e| TargetError::Configuration(format!("invalid mTLS pair: {e}")))?
} else {
builder.with_no_client_auth()
};
Ok(client_config)
}
/// Builds the deadpool-postgres `Pool` used by the target.
///
/// `args.tls_required` decides whether the connection is plain TCP or wrapped
/// in rustls. The pool is `Clone` and cheap to share across `clone_box`.
pub fn build_pool(args: &PostgresArgs) -> Result<Pool, TargetError> {
let parsed = PostgresDsn::parse(&args.dsn_string)?;
let mut pg_config = Config::new();
pg_config
.host(&parsed.host)
.port(parsed.port)
.user(&parsed.user)
.dbname(&parsed.database)
// Bound the TCP connect + startup handshake so an unreachable backend
// cannot block a pool slot indefinitely.
.connect_timeout(POSTGRES_CONNECT_TIMEOUT)
.options(format!("-c search_path={}", parsed.schema));
if let Some(password) = parsed.password.as_deref()
&& !password.is_empty()
{
pg_config.password(password);
}
let manager_config = ManagerConfig {
recycling_method: RecyclingMethod::Fast,
};
let manager = if args.tls_required {
let tls_config = build_tls_config(args)?;
let connector = MakeRustlsConnect::new(tls_config);
Manager::from_config(pg_config, connector, manager_config)
} else {
Manager::from_config(pg_config, tokio_postgres::NoTls, manager_config)
};
// Explicit wait/create/recycle timeouts guarantee that `pool.get()` always
// returns within a bounded time when the broker/DB is unreachable, instead
// of blocking the delivery thread forever. A Tokio runtime is required for
// deadpool to honor these timeouts.
Pool::builder(manager)
.runtime(Runtime::Tokio1)
.timeouts(Timeouts {
wait: Some(POSTGRES_POOL_WAIT_TIMEOUT),
create: Some(POSTGRES_POOL_CREATE_TIMEOUT),
recycle: Some(POSTGRES_POOL_RECYCLE_TIMEOUT),
})
.build()
.map_err(|e| TargetError::Configuration(format!("failed to build PostgreSQL pool: {e}")))
}
/// Classifies a PostgreSQL SQLSTATE code into the proper `TargetError` variant.
///
/// Split out from [`map_pg_error`] so the SQLSTATE-to-variant mapping can be
/// unit-tested without constructing an opaque `tokio_postgres::Error`.
///
/// Classification is by SQLSTATE class (first two characters):
/// - `08` connection exception → `NotConnected` (retry, keep in store).
/// - `28` invalid authorization → `Authentication` (permanent, surfaced).
/// - `23`/`42` integrity/syntax → `Configuration` (permanent, surfaced).
/// - `40` transaction rollback (`40001` serialization_failure,
/// `40P01` deadlock_detected, …) → `Timeout`, a transient/retryable error:
/// the transaction should be retried rather than dropped.
/// - anything else → `Request` (treated as permanent/ambiguous).
fn map_pg_sqlstate(code: &str, detail: &str) -> TargetError {
match code.get(..2).unwrap_or("") {
"08" => TargetError::NotConnected,
"28" => TargetError::Authentication(detail.to_string()),
"23" | "42" => TargetError::Configuration(detail.to_string()),
"40" => TargetError::Timeout(detail.to_string()),
_ => TargetError::Request(detail.to_string()),
}
}
/// Maps a `tokio_postgres::Error` to the proper `TargetError` variant.
///
/// Connection-class errors (SQLSTATE 08, closed connection, IO) become
/// `NotConnected` so the queue store retains the payload for replay.
/// Schema and constraint problems (SQLSTATE 23, 42) become `Configuration`
/// so they are surfaced to the operator without endless retry.
/// Transaction-rollback errors (SQLSTATE class 40, e.g. serialization failure
/// or deadlock) become `Timeout` so they are retried transiently.
pub fn map_pg_error(err: &tokio_postgres::Error, context: &str) -> TargetError {
if err.is_closed() {
return TargetError::NotConnected;
}
if let Some(db_err) = err.as_db_error() {
let detail = format!("{context}: {db_err}");
return map_pg_sqlstate(db_err.code().code(), &detail);
}
TargetError::NotConnected
}
/// Maps a `deadpool_postgres::PoolError` to the proper `TargetError` variant.
pub fn map_pool_error(err: deadpool_postgres::PoolError, context: &str) -> TargetError {
match err {
deadpool_postgres::PoolError::Timeout(_) => TargetError::Timeout(format!("{context}: pool timeout")),
deadpool_postgres::PoolError::Backend(pg_err) => map_pg_error(&pg_err, context),
deadpool_postgres::PoolError::Closed => TargetError::NotConnected,
other => TargetError::Request(format!("{context}: {other}")),
}
}
fn resolve_payload_key(payload: &serde_json::Value, meta: &QueuedPayloadMeta) -> String {
payload
.get(TARGET_LOG_KEY_FIELD)
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
let decoded_object =
crate::target::decode_object_name(&meta.object_name).unwrap_or_else(|_| meta.object_name.clone());
format!("{}/{}", meta.bucket_name, decoded_object)
})
}
/// PostgreSQL notification target.
///
/// Holds a cloneable `deadpool_postgres::Pool` rather than a `Mutex<Option<Pool>>`
/// so that `clone_box` does not duplicate connection state. The optional
/// `QueueStore` provides at-least-once delivery semantics consistent with the
/// other built-in targets.
///
/// When `tls_adapter` is `Some`, the target participates in the
/// coordinated TLS hot-reload system driven by `TlsReloadAdapter`,
/// and the inline fingerprint check in `send_body` is skipped. When `None`,
/// the legacy inline fingerprint check is used as a fallback.
pub struct PostgresTarget<E>
where
E: PluginEvent,
{
id: TargetID,
args: PostgresArgs,
pool: Arc<parking_lot::Mutex<Pool>>,
tls_state: Arc<parking_lot::Mutex<super::TargetTlsState>>,
/// When present, the adapter provides coordinator-managed TLS material;
/// otherwise the inline fingerprint path is used as a fallback.
tls_adapter: Option<TlsReloadAdapter<Pool>>,
namespace_sql: String,
namespace_delete_sql: String,
access_sql: String,
store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
delivery_counters: Arc<TargetDeliveryCounters>,
_phantom: std::marker::PhantomData<E>,
}
impl<E> PostgresTarget<E>
where
E: PluginEvent,
{
pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(PostgresTarget::<E> {
id: self.id.clone(),
args: self.args.clone(),
pool: Arc::clone(&self.pool),
tls_state: Arc::clone(&self.tls_state),
tls_adapter: self.tls_adapter.clone(),
namespace_sql: self.namespace_sql.clone(),
namespace_delete_sql: self.namespace_delete_sql.clone(),
access_sql: self.access_sql.clone(),
store: self.store.as_ref().map(|s| s.boxed_clone()),
delivery_counters: Arc::clone(&self.delivery_counters),
_phantom: std::marker::PhantomData,
})
}
#[instrument(skip(args), fields(target_id_as_string = %id))]
pub fn new(id: String, args: PostgresArgs) -> Result<Self, TargetError> {
args.validate()?;
let target_id = TargetID::new(id, ChannelTargetType::Postgres.as_str().to_string());
let pool = build_pool(&args)?;
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Postgres.as_str(),
&target_id,
"Failed to open store for PostgreSQL target",
)?;
Ok(Self {
id: target_id,
namespace_sql: namespace_upsert_sql(&args.schema, &args.table),
namespace_delete_sql: namespace_delete_sql(&args.schema, &args.table),
access_sql: access_insert_sql(&args.schema, &args.table),
args,
pool: Arc::new(parking_lot::Mutex::new(pool)),
tls_state: Arc::new(parking_lot::Mutex::new(super::TargetTlsState::default())),
tls_adapter: None,
store: queue_store,
delivery_counters: Arc::new(TargetDeliveryCounters::default()),
_phantom: std::marker::PhantomData,
})
}
/// Sends a serialized event body to PostgreSQL using the configured format.
///
/// Identifier validation has already happened in `PostgresArgs::validate()`,
/// so `qualified_table` cannot produce a malformed SQL string here.
async fn send_body(&self, body: &[u8], event_id: &str, meta: &QueuedPayloadMeta) -> Result<(), TargetError> {
// When a TLS reload adapter is attached, it drives pool rebuilds in
// the background. The inline per-send fingerprint check is skipped.
if self.tls_adapter.is_none() {
let next_fingerprint =
super::build_target_tls_fingerprint(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
.await?;
let tls_changed = {
let tls_state_guard = self.tls_state.lock();
tls_state_guard.fingerprint.as_ref() != Some(&next_fingerprint)
};
if tls_changed {
let new_pool = build_pool(&self.args)?;
*self.pool.lock() = new_pool;
self.tls_state.lock().refresh(next_fingerprint);
}
}
let pool = self.pool.lock().clone();
let client = checkout_client(&pool, "PostgreSQL pool checkout failed").await?;
let payload: serde_json::Value =
serde_json::from_slice(body).map_err(|e| TargetError::Serialization(format!("Failed to parse JSON payload: {e}")))?;
let key = resolve_payload_key(&payload, meta);
with_delivery_deadline(POSTGRES_DELIVERY_TIMEOUT, "PostgreSQL delivery", async {
match self.args.format {
// For the single-row `namespace` format, an object removal must
// delete the row rather than UPSERT it, otherwise stale state
// lingers in the table after the object is gone.
PostgresFormat::Namespace if is_object_removed_event(&meta.event_name) => {
client.execute(&self.namespace_delete_sql, &[&key]).await
}
PostgresFormat::Namespace => client.execute(&self.namespace_sql, &[&key, &payload]).await,
PostgresFormat::Access => {
let event_name_str = meta.event_name.to_string();
let queued_at_ms = meta.queued_at_unix_ms as i64;
client
.execute(&self.access_sql, &[&event_id, &event_name_str, &key, &payload, &queued_at_ms])
.await
}
}
.map_err(|err| map_pg_error(&err, "PostgreSQL insert failed"))
})
.await?;
self.delivery_counters.record_success();
Ok(())
}
/// Probes the table from `init()`. Failure is non-fatal when a queue is
/// configured: events buffer in the store until the schema is fixed.
async fn probe_table(&self) -> Result<(), TargetError> {
let pool = self.pool.lock().clone();
let client = checkout_client(&pool, "PostgreSQL pool checkout failed during init probe").await?;
let sql = table_probe_sql(&self.args.schema, &self.args.table);
client
.execute(sql.as_str(), &[])
.await
.map_err(|e| map_pg_error(&e, "PostgreSQL table probe failed"))?;
Ok(())
}
}
#[async_trait]
impl<E> ReloadableTargetTls for PostgresTarget<E>
where
E: PluginEvent,
{
type Material = Pool;
fn tls_input_set(&self) -> TargetTlsInputSet {
TargetTlsInputSet {
ca_path: self.args.tls_ca.clone(),
client_cert_path: self.args.tls_client_cert.clone(),
client_key_path: self.args.tls_client_key.clone(),
target_label: format!("postgres:{}", self.id.id),
}
}
async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
build_pool(&self.args)
}
async fn apply_tls_material(
&self,
_generation: TargetTlsGeneration,
material: Arc<Self::Material>,
_mode: ReloadApplyMode,
) -> Result<(), TargetError> {
*self.pool.lock() = (*material).clone();
Ok(())
}
async fn validate_tls_files(&self) -> Result<(), TargetError> {
validate_tls_material(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
}
}
#[async_trait]
impl<E> Target<E> for PostgresTarget<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);
}
match tokio::time::timeout(Duration::from_secs(10), async {
let pool = self.pool.lock().clone();
let client = checkout_client(&pool, "PostgreSQL pool checkout failed").await?;
client
.execute("SELECT 1", &[])
.await
.map_err(|e| map_pg_error(&e, "PostgreSQL liveness probe failed"))?;
Ok::<(), TargetError>(())
})
.await
{
Ok(Ok(())) => Ok(true),
Ok(Err(err)) => Err(err),
Err(_) => Err(TargetError::Timeout("PostgreSQL liveness probe timed out after 10s".to_string())),
}
}
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);
}
Ok(())
} else {
// No queue: deliver immediately. Fresh UUID acts as the access-format
// event_id so retries from the caller produce distinct rows.
let event_id = Uuid::new_v4().to_string();
if let Err(err) = self.send_body(&queued.body, &event_id, &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> {
// Use the store key as a stable event_id so replays of the same physical
// event are idempotent under the access-format composite PK.
let event_id = key.to_string();
self.send_body(&body, &event_id, &meta).await
}
async fn close(&self) -> Result<(), TargetError> {
self.pool.lock().close();
// Adapter cleanup is done by the coordinator; no local state to reset.
info!(target_id = %self.id, "PostgreSQL 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(());
}
match self.probe_table().await {
Ok(()) => Ok(()),
Err(err) if self.store.is_some() => {
warn!(target_id = %self.id, error = %err, "PostgreSQL init probe failed; events will buffer in store");
Ok(())
}
Err(err) => Err(err),
}
}
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),
// Postgres targets record no terminal failures and keep no failed store.
0,
)
}
fn record_final_failure(&self) {
self.delivery_counters.record_final_failure();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::target::REDACTED_SECRET;
fn base_args() -> PostgresArgs {
PostgresArgs {
enable: true,
dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public".to_string(),
schema: "public".to_string(),
table: "rustfs_events_namespace".to_string(),
format: PostgresFormat::Namespace,
tls_required: false,
tls_ca: String::new(),
tls_client_cert: String::new(),
tls_client_key: String::new(),
queue_dir: String::new(),
queue_limit: 100_000,
target_type: TargetType::NotifyEvent,
}
}
#[test]
fn validate_disabled_skips_all_checks() {
let args = PostgresArgs {
enable: false,
dsn_string: String::new(),
schema: String::new(),
table: String::new(),
..base_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn validate_accepts_base_args() {
assert!(base_args().validate().is_ok());
}
#[tokio::test]
async fn is_active_returns_false_when_disabled() {
let target = PostgresTarget::<String>::new(
"postgres:test".to_string(),
PostgresArgs {
enable: false,
dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public".to_string(),
..base_args()
},
)
.expect("disabled target should still construct");
assert!(!target.is_active().await.expect("disabled target should not probe"));
}
#[test]
fn validate_rejects_empty_dsn_string() {
let args = PostgresArgs {
dsn_string: String::new(),
..base_args()
};
let err = args.validate().expect_err("empty dsn string should fail");
assert!(err.to_string().contains("dsn_string cannot be empty"));
}
#[test]
fn validate_rejects_invalid_dsn_string() {
let args = PostgresArgs {
dsn_string: "postgres://".to_string(),
..base_args()
};
let err = args.validate().expect_err("invalid dsn should fail");
assert!(err.to_string().contains("invalid PostgreSQL dsn_string"));
}
#[test]
fn validate_rejects_invalid_schema_identifier() {
let args = PostgresArgs {
schema: "public; DROP TABLE".to_string(),
..base_args()
};
let err = args.validate().expect_err("invalid schema should fail");
assert!(err.to_string().contains("schema"));
}
#[test]
fn validate_rejects_invalid_table_identifier() {
let args = PostgresArgs {
table: "events;".to_string(),
..base_args()
};
let err = args.validate().expect_err("invalid table should fail");
assert!(err.to_string().contains("table"));
}
#[test]
fn validate_rejects_table_starting_with_digit() {
let args = PostgresArgs {
table: "1events".to_string(),
..base_args()
};
let err = args.validate().expect_err("digit-leading table should fail");
assert!(err.to_string().contains("table"));
}
#[test]
fn validate_rejects_mtls_without_key() {
let args = PostgresArgs {
tls_client_cert: "/etc/ssl/client.pem".to_string(),
tls_client_key: String::new(),
..base_args()
};
let err = args.validate().expect_err("missing key should fail");
assert!(err.to_string().contains("must be specified together"));
}
#[test]
fn validate_rejects_relative_queue_dir() {
let args = PostgresArgs {
queue_dir: "relative/path".to_string(),
..base_args()
};
let err = args.validate().expect_err("relative queue_dir should fail");
assert!(err.to_string().contains("absolute path"));
}
#[test]
fn validate_rejects_relative_tls_ca() {
let args = PostgresArgs {
tls_ca: "ca.pem".to_string(),
..base_args()
};
let err = args.validate().expect_err("relative tls_ca should fail");
assert!(err.to_string().contains("absolute path"));
}
#[test]
fn parse_format_defaults_to_namespace() {
assert_eq!(parse_postgres_format(None).expect("ok"), PostgresFormat::Namespace);
assert_eq!(parse_postgres_format(Some("")).expect("ok"), PostgresFormat::Namespace);
assert_eq!(parse_postgres_format(Some(" ")).expect("ok"), PostgresFormat::Namespace);
}
#[test]
fn parse_format_accepts_variants() {
assert_eq!(parse_postgres_format(Some("namespace")).expect("ok"), PostgresFormat::Namespace);
assert_eq!(parse_postgres_format(Some("ACCESS")).expect("ok"), PostgresFormat::Access);
assert_eq!(parse_postgres_format(Some("Access")).expect("ok"), PostgresFormat::Access);
}
#[test]
fn parse_format_rejects_unknown() {
let err = parse_postgres_format(Some("structured")).expect_err("unknown format should fail");
assert!(err.to_string().contains("must be 'namespace' or 'access'"));
}
#[test]
fn parse_dsn_extracts_search_path_schema() {
let parsed = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events?search_path=audit,public")
.expect("dsn should parse");
assert_eq!(parsed.host, "localhost");
assert_eq!(parsed.port, 5432);
assert_eq!(parsed.user, "postgres");
assert_eq!(parsed.password.as_deref(), Some("secret"));
assert_eq!(parsed.database, "rustfs_events");
assert_eq!(parsed.schema, "audit");
}
#[test]
fn parse_dsn_defaults_schema_to_public() {
let parsed = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events").expect("dsn should parse");
assert_eq!(parsed.schema, "public");
}
#[test]
fn parse_dsn_rejects_invalid_scheme() {
let err = PostgresDsn::parse("mysql://user:pass@localhost:5432/db").expect_err("scheme should fail");
assert!(err.to_string().contains("scheme must be postgres or postgresql"));
}
#[test]
fn parse_dsn_rejects_invalid_search_path_identifier() {
let err = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public;drop")
.expect_err("invalid search_path should fail");
assert!(err.to_string().contains("schema"));
}
#[test]
fn validate_rejects_schema_mismatch_with_dsn_search_path() {
let args = PostgresArgs {
schema: "public".to_string(),
dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=audit".to_string(),
..base_args()
};
let err = args.validate().expect_err("schema mismatch should fail");
assert!(err.to_string().contains("schema must match DSN search_path"));
}
#[test]
fn debug_masks_password() {
let args = base_args();
let rendered = format!("{args:?}");
assert!(!rendered.contains("secret"), "password leaked: {rendered}");
assert!(rendered.contains("postgres:***@"));
}
#[test]
fn debug_masks_password_when_empty_shows_blank() {
let args = PostgresArgs {
dsn_string: "postgres://postgres@localhost:5432/rustfs_events?search_path=public".to_string(),
..base_args()
};
let rendered = format!("{args:?}");
assert!(!rendered.contains(":***@"));
}
#[test]
fn debug_redacts_postgres_dsn_password() {
let dsn = PostgresDsn::parse("postgres://postgres:pg-secret@localhost:5432/rustfs_events?search_path=public")
.expect("valid DSN");
let rendered = format!("{dsn:?}");
assert!(!rendered.contains("pg-secret"));
assert!(rendered.contains(REDACTED_SECRET));
assert!(rendered.contains("rustfs_events"));
}
#[test]
fn redact_postgres_dsn_masks_password_query_parameter() {
let redacted = redact_postgres_dsn("postgres://postgres@localhost:5432/db?search_path=public&password=secret");
assert!(!redacted.contains("secret"));
assert!(redacted.contains("password=%2A%2A%2A") || redacted.contains("password=***"));
}
#[test]
fn qualified_table_double_quotes_both_parts() {
assert_eq!(qualified_table("public", "events"), r#""public"."events""#);
assert_eq!(qualified_table("audit", "rustfs_events"), r#""audit"."rustfs_events""#);
}
#[test]
fn namespace_upsert_uses_on_conflict_update() {
let sql = namespace_upsert_sql("public", "events");
assert!(sql.contains("ON CONFLICT (key) DO UPDATE"));
assert!(sql.contains(r#""public"."events""#));
assert!(sql.contains("$2::jsonb"));
}
#[test]
fn access_insert_uses_event_id_pk_with_on_conflict_do_nothing() {
let sql = access_insert_sql("public", "events_access");
assert!(sql.contains("event_id"));
assert!(sql.contains("ON CONFLICT (event_id) DO NOTHING"));
assert!(sql.contains(r#""public"."events_access""#));
assert!(sql.contains("$4::jsonb"));
}
#[test]
fn namespace_delete_targets_row_by_key() {
let sql = namespace_delete_sql("public", "events");
assert!(sql.starts_with("DELETE FROM"));
assert!(sql.contains(r#""public"."events""#));
assert!(sql.contains("WHERE key = $1"));
}
#[test]
fn is_object_removed_event_matches_all_removed_variants() {
assert!(is_object_removed_event(&EventName::ObjectRemovedDelete));
assert!(is_object_removed_event(&EventName::ObjectRemovedDeleteMarkerCreated));
assert!(is_object_removed_event(&EventName::ObjectRemovedDeleteAllVersions));
assert!(is_object_removed_event(&EventName::ObjectRemovedAll));
assert!(!is_object_removed_event(&EventName::ObjectCreatedPut));
assert!(!is_object_removed_event(&EventName::ObjectAccessedGet));
}
#[test]
fn map_pg_sqlstate_classifies_transaction_rollback_as_transient() {
// 40001 serialization_failure and 40P01 deadlock_detected are transient
// and must be retried, not dropped as permanent failures.
assert!(matches!(map_pg_sqlstate("40001", "ctx: serialization"), TargetError::Timeout(_)));
assert!(matches!(map_pg_sqlstate("40P01", "ctx: deadlock"), TargetError::Timeout(_)));
assert!(matches!(map_pg_sqlstate("40000", "ctx: rollback"), TargetError::Timeout(_)));
}
#[test]
fn map_pg_sqlstate_classifies_connection_and_permanent_errors() {
assert!(matches!(map_pg_sqlstate("08006", "ctx"), TargetError::NotConnected));
assert!(matches!(map_pg_sqlstate("08001", "ctx"), TargetError::NotConnected));
assert!(matches!(map_pg_sqlstate("28P01", "ctx: auth"), TargetError::Authentication(_)));
assert!(matches!(map_pg_sqlstate("23505", "ctx: unique"), TargetError::Configuration(_)));
assert!(matches!(map_pg_sqlstate("42P01", "ctx: undefined_table"), TargetError::Configuration(_)));
// Unknown class stays permanent (ambiguous → surfaced as Request).
assert!(matches!(map_pg_sqlstate("22001", "ctx: data"), TargetError::Request(_)));
assert!(matches!(map_pg_sqlstate("", "ctx: empty"), TargetError::Request(_)));
}
#[test]
fn transient_pg_errors_are_connectivity_errors() {
// Transaction-rollback errors must be treated as connectivity errors so
// the queue store retains the payload for replay instead of dropping it.
assert!(crate::target::is_connectivity_error(&map_pg_sqlstate("40001", "ctx")));
assert!(crate::target::is_connectivity_error(&map_pg_sqlstate("40P01", "ctx")));
}
#[test]
fn table_probe_does_not_select_rows() {
let sql = table_probe_sql("public", "events");
assert!(sql.contains("LIMIT 0"));
assert!(sql.contains(r#""public"."events""#));
}
#[test]
fn validate_pg_identifier_accepts_alphanumerics() {
assert!(validate_pg_identifier("events", "table").is_ok());
assert!(validate_pg_identifier("rustfs_events_v2", "table").is_ok());
assert!(validate_pg_identifier("_underscored", "table").is_ok());
}
#[test]
fn validate_pg_identifier_rejects_dot_and_quote() {
assert!(validate_pg_identifier("public.events", "table").is_err());
assert!(validate_pg_identifier("events\"DROP", "table").is_err());
assert!(validate_pg_identifier("a b", "table").is_err());
}
#[test]
fn resolve_payload_key_prefers_serialized_key_field() {
let payload = serde_json::json!({
"EventName": "s3:ObjectCreated:Put",
"Key": "bucket-a/folder/object.txt",
"Records": []
});
let meta = QueuedPayloadMeta::new(
rustfs_s3_types::EventName::ObjectCreatedPut,
"bucket-a".to_string(),
"fallback%2Fvalue.txt".to_string(),
"application/json",
0,
);
assert_eq!(resolve_payload_key(&payload, &meta), "bucket-a/folder/object.txt");
}
#[test]
fn resolve_payload_key_falls_back_to_decoded_meta_key() {
let payload = serde_json::json!({
"EventName": "s3:ObjectCreated:Put",
"Records": []
});
let meta = QueuedPayloadMeta::new(
rustfs_s3_types::EventName::ObjectCreatedPut,
"bucket-a".to_string(),
"hello+world%2Ftest.txt".to_string(),
"application/json",
0,
);
assert_eq!(resolve_payload_key(&payload, &meta), "bucket-a/hello world/test.txt");
}
}