#![deny(missing_docs)]
use nidus_core::NidusError;
use thiserror::Error;
pub type Result<T> = std::result::Result<T, SqlxError>;
#[derive(Debug, Error)]
pub enum SqlxError {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error(transparent)]
Nidus(#[from] NidusError),
#[cfg(feature = "nidus-config")]
#[error(transparent)]
Config(#[from] nidus_config::ConfigError),
}
#[cfg(any(feature = "mysql", feature = "cockroach"))]
fn configuration_error(message: &'static str) -> SqlxError {
sqlx::Error::InvalidArgument(format!("invalid SQLx adapter configuration: {message}")).into()
}
#[cfg(feature = "sqlite")]
mod sqlite {
#[cfg(feature = "observability")]
use std::time::Instant;
use super::Result;
use nidus_core::Container;
#[derive(Clone, Eq, PartialEq)]
pub struct SqlitePoolConfig {
database_url: String,
max_connections: Option<u32>,
}
impl SqlitePoolConfig {
pub fn new(database_url: impl Into<String>) -> Self {
Self {
database_url: database_url.into(),
max_connections: None,
}
}
pub fn with_max_connections(mut self, max_connections: u32) -> Self {
self.max_connections = Some(max_connections);
self
}
pub fn database_url(&self) -> &str {
&self.database_url
}
pub fn max_connections(&self) -> Option<u32> {
self.max_connections
}
#[cfg(feature = "nidus-config")]
pub fn from_config_path<I, S>(config: &nidus_config::Config, path: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
#[derive(serde::Deserialize)]
struct RawConfig {
url: String,
max_connections: Option<u32>,
}
let raw: RawConfig = config.get_required_path_typed(path)?;
let mut settings = Self::new(raw.url);
if let Some(max_connections) = raw.max_connections {
settings = settings.with_max_connections(max_connections);
}
Ok(settings)
}
}
impl std::fmt::Debug for SqlitePoolConfig {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("SqlitePoolConfig")
.field("database_url", &"<redacted>")
.field("max_connections", &self.max_connections)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct SqlitePoolBuilder {
config: SqlitePoolConfig,
#[cfg(feature = "observability")]
observer: Option<nidus_observability::ObservabilityAdapterObserver>,
}
impl SqlitePoolBuilder {
pub fn new() -> Self {
Self {
config: SqlitePoolConfig::new("sqlite::memory:"),
#[cfg(feature = "observability")]
observer: None,
}
}
pub fn config(mut self, config: SqlitePoolConfig) -> Self {
self.config = config;
self
}
pub fn database_url(mut self, database_url: impl Into<String>) -> Self {
self.config.database_url = database_url.into();
self
}
pub fn max_connections(mut self, max_connections: u32) -> Self {
self.config.max_connections = Some(max_connections);
self
}
#[cfg(feature = "observability")]
pub fn observability(
mut self,
observer: nidus_observability::ObservabilityAdapterObserver,
) -> Self {
self.observer = Some(observer);
self
}
pub async fn connect(self) -> Result<SqlitePoolProvider> {
#[cfg(feature = "observability")]
let observer = self.observer;
let mut options = sqlx::sqlite::SqlitePoolOptions::new();
if let Some(max_connections) = self.config.max_connections {
options = options.max_connections(max_connections);
}
#[cfg(feature = "observability")]
let started_at = Instant::now();
let pool = options.connect(&self.config.database_url).await;
#[cfg(feature = "observability")]
record_adapter_operation(
&observer,
"connect",
nidus_observability::OperationStatus::from(pool.is_ok()),
started_at,
);
let pool = pool?;
Ok(SqlitePoolProvider {
pool,
#[cfg(feature = "observability")]
observer,
})
}
pub async fn register(self, container: &mut Container) -> Result<()> {
let provider = self.connect().await?;
container.register_singleton(provider)?;
Ok(())
}
}
impl Default for SqlitePoolBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct SqlitePoolProvider {
pool: sqlx::SqlitePool,
#[cfg(feature = "observability")]
observer: Option<nidus_observability::ObservabilityAdapterObserver>,
}
impl SqlitePoolProvider {
pub fn builder() -> SqlitePoolBuilder {
SqlitePoolBuilder::new()
}
pub fn from_pool(pool: sqlx::SqlitePool) -> Self {
Self {
pool,
#[cfg(feature = "observability")]
observer: None,
}
}
pub fn pool(&self) -> &sqlx::SqlitePool {
&self.pool
}
pub fn into_pool(self) -> sqlx::SqlitePool {
self.pool
}
#[cfg(feature = "health")]
pub async fn health_status(&self) -> nidus_http::health::HealthStatus {
#[cfg(feature = "observability")]
let started_at = Instant::now();
let result = sqlx::query("SELECT 1").execute(&self.pool).await;
#[cfg(feature = "observability")]
record_adapter_operation(
&self.observer,
"health",
nidus_observability::OperationStatus::from(result.is_ok()),
started_at,
);
match result {
Ok(_) => nidus_http::health::HealthStatus::up(),
Err(error) => nidus_http::health::HealthStatus::down(error.to_string()),
}
}
#[cfg(feature = "health")]
pub fn register_ready_check(
self: std::sync::Arc<Self>,
registry: nidus_http::health::HealthRegistry,
name: impl Into<String>,
) -> nidus_http::health::HealthRegistry {
registry.ready_check(name, move || {
let provider = std::sync::Arc::clone(&self);
async move { provider.health_status().await }
})
}
}
#[async_trait::async_trait]
impl nidus_core::LifecycleHook for SqlitePoolProvider {
async fn on_shutdown(&self) -> nidus_core::Result<()> {
self.pool.close().await;
Ok(())
}
}
#[cfg(feature = "observability")]
fn record_adapter_operation(
observer: &Option<nidus_observability::ObservabilityAdapterObserver>,
operation: &'static str,
status: nidus_observability::OperationStatus,
started_at: Instant,
) {
if let Some(observer) = observer {
observer.record("nidus-sqlx", operation, status, started_at.elapsed());
}
}
}
#[cfg(feature = "sqlite")]
pub use sqlite::{SqlitePoolBuilder, SqlitePoolConfig, SqlitePoolProvider};
#[cfg(feature = "postgres")]
mod postgres {
#[cfg(feature = "observability")]
use std::time::Instant;
use super::Result;
use nidus_core::Container;
#[derive(Clone, Eq, PartialEq)]
pub struct PostgresPoolConfig {
database_url: String,
max_connections: Option<u32>,
min_connections: Option<u32>,
}
impl PostgresPoolConfig {
pub fn new(database_url: impl Into<String>) -> Self {
Self {
database_url: database_url.into(),
max_connections: None,
min_connections: None,
}
}
pub fn with_max_connections(mut self, max_connections: u32) -> Self {
self.max_connections = Some(max_connections);
self
}
pub fn with_min_connections(mut self, min_connections: u32) -> Self {
self.min_connections = Some(min_connections);
self
}
pub fn database_url(&self) -> &str {
&self.database_url
}
pub fn max_connections(&self) -> Option<u32> {
self.max_connections
}
pub fn min_connections(&self) -> Option<u32> {
self.min_connections
}
#[cfg(feature = "nidus-config")]
pub fn from_config_path<I, S>(config: &nidus_config::Config, path: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
#[derive(serde::Deserialize)]
struct RawConfig {
url: String,
max_connections: Option<u32>,
min_connections: Option<u32>,
}
let raw: RawConfig = config.get_required_path_typed(path)?;
let mut settings = Self::new(raw.url);
if let Some(max_connections) = raw.max_connections {
settings = settings.with_max_connections(max_connections);
}
if let Some(min_connections) = raw.min_connections {
settings = settings.with_min_connections(min_connections);
}
Ok(settings)
}
}
impl std::fmt::Debug for PostgresPoolConfig {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PostgresPoolConfig")
.field("database_url", &"<redacted>")
.field("max_connections", &self.max_connections)
.field("min_connections", &self.min_connections)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct PostgresPoolBuilder {
config: PostgresPoolConfig,
#[cfg(feature = "observability")]
observer: Option<nidus_observability::ObservabilityAdapterObserver>,
}
impl PostgresPoolBuilder {
pub fn new(database_url: impl Into<String>) -> Self {
Self {
config: PostgresPoolConfig::new(database_url),
#[cfg(feature = "observability")]
observer: None,
}
}
pub fn config(mut self, config: PostgresPoolConfig) -> Self {
self.config = config;
self
}
pub fn database_url(mut self, database_url: impl Into<String>) -> Self {
self.config.database_url = database_url.into();
self
}
pub fn max_connections(mut self, max_connections: u32) -> Self {
self.config.max_connections = Some(max_connections);
self
}
pub fn min_connections(mut self, min_connections: u32) -> Self {
self.config.min_connections = Some(min_connections);
self
}
#[cfg(feature = "observability")]
pub fn observability(
mut self,
observer: nidus_observability::ObservabilityAdapterObserver,
) -> Self {
self.observer = Some(observer);
self
}
pub async fn connect(self) -> Result<PostgresPoolProvider> {
#[cfg(feature = "observability")]
let observer = self.observer;
let mut options = sqlx::postgres::PgPoolOptions::new();
if let Some(max_connections) = self.config.max_connections {
options = options.max_connections(max_connections);
}
if let Some(min_connections) = self.config.min_connections {
options = options.min_connections(min_connections);
}
#[cfg(feature = "observability")]
let started_at = Instant::now();
let pool = options.connect(&self.config.database_url).await;
#[cfg(feature = "observability")]
record_adapter_operation(
&observer,
"connect",
nidus_observability::OperationStatus::from(pool.is_ok()),
started_at,
);
let pool = pool?;
Ok(PostgresPoolProvider {
pool,
#[cfg(feature = "observability")]
observer,
})
}
pub async fn register(self, container: &mut Container) -> Result<()> {
let provider = self.connect().await?;
container.register_singleton(provider)?;
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct PostgresPoolProvider {
pool: sqlx::PgPool,
#[cfg(feature = "observability")]
observer: Option<nidus_observability::ObservabilityAdapterObserver>,
}
impl PostgresPoolProvider {
pub fn builder(database_url: impl Into<String>) -> PostgresPoolBuilder {
PostgresPoolBuilder::new(database_url)
}
pub fn from_pool(pool: sqlx::PgPool) -> Self {
Self {
pool,
#[cfg(feature = "observability")]
observer: None,
}
}
pub fn pool(&self) -> &sqlx::PgPool {
&self.pool
}
pub fn into_pool(self) -> sqlx::PgPool {
self.pool
}
#[cfg(feature = "health")]
pub async fn health_status(&self) -> nidus_http::health::HealthStatus {
#[cfg(feature = "observability")]
let started_at = Instant::now();
let result = sqlx::query("SELECT 1").execute(&self.pool).await;
#[cfg(feature = "observability")]
record_adapter_operation(
&self.observer,
"health",
nidus_observability::OperationStatus::from(result.is_ok()),
started_at,
);
match result {
Ok(_) => nidus_http::health::HealthStatus::up(),
Err(error) => nidus_http::health::HealthStatus::down(error.to_string()),
}
}
#[cfg(feature = "health")]
pub fn register_ready_check(
self: std::sync::Arc<Self>,
registry: nidus_http::health::HealthRegistry,
name: impl Into<String>,
) -> nidus_http::health::HealthRegistry {
registry.ready_check(name, move || {
let provider = std::sync::Arc::clone(&self);
async move { provider.health_status().await }
})
}
}
#[async_trait::async_trait]
impl nidus_core::LifecycleHook for PostgresPoolProvider {
async fn on_shutdown(&self) -> nidus_core::Result<()> {
self.pool.close().await;
Ok(())
}
}
#[cfg(feature = "observability")]
fn record_adapter_operation(
observer: &Option<nidus_observability::ObservabilityAdapterObserver>,
operation: &'static str,
status: nidus_observability::OperationStatus,
started_at: Instant,
) {
if let Some(observer) = observer {
observer.record("nidus-sqlx", operation, status, started_at.elapsed());
}
}
}
#[cfg(feature = "postgres")]
pub use postgres::{PostgresPoolBuilder, PostgresPoolConfig, PostgresPoolProvider};
#[cfg(feature = "mysql")]
mod mysql {
use std::{str::FromStr, time::Instant};
use nidus_core::Container;
use nidus_integrations::{IntegrationEvent, IntegrationStatus, IntegrationTelemetry};
use super::Result;
#[derive(Clone, Eq, PartialEq)]
pub struct MySqlPoolConfig {
database_url: String,
max_connections: Option<u32>,
min_connections: Option<u32>,
allow_insecure_local: bool,
}
impl MySqlPoolConfig {
pub fn new(database_url: impl Into<String>) -> Self {
Self {
database_url: database_url.into(),
max_connections: None,
min_connections: None,
allow_insecure_local: false,
}
}
pub fn with_max_connections(mut self, max_connections: u32) -> Self {
self.max_connections = Some(max_connections);
self
}
pub fn with_min_connections(mut self, min_connections: u32) -> Self {
self.min_connections = Some(min_connections);
self
}
pub fn allow_insecure_for_local_development(mut self) -> Self {
self.allow_insecure_local = true;
self
}
pub fn database_url(&self) -> &str {
&self.database_url
}
pub fn max_connections(&self) -> Option<u32> {
self.max_connections
}
pub fn min_connections(&self) -> Option<u32> {
self.min_connections
}
pub fn validate(&self) -> Result<()> {
if self.max_connections == Some(0) {
return Err(super::configuration_error(
"MySQL max_connections must be greater than zero",
));
}
if let (Some(minimum), Some(maximum)) = (self.min_connections, self.max_connections)
&& minimum > maximum
{
return Err(super::configuration_error(
"MySQL min_connections cannot exceed max_connections",
));
}
let options = sqlx::mysql::MySqlConnectOptions::from_str(&self.database_url)?;
let verifies_identity = matches!(
options.get_ssl_mode(),
sqlx::mysql::MySqlSslMode::VerifyIdentity
);
if !verifies_identity
&& (!self.allow_insecure_local
|| !matches!(options.get_host(), "localhost" | "127.0.0.1" | "::1"))
{
return Err(super::configuration_error(
"MySQL requires ssl-mode=VERIFY_IDENTITY except for explicit loopback development",
));
}
Ok(())
}
#[cfg(feature = "nidus-config")]
pub fn from_config_path<I, S>(config: &nidus_config::Config, path: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
#[derive(serde::Deserialize)]
struct RawConfig {
url: String,
max_connections: Option<u32>,
min_connections: Option<u32>,
allow_insecure_local: Option<bool>,
}
let raw: RawConfig = config.get_required_path_typed(path)?;
let mut settings = Self::new(raw.url);
if let Some(value) = raw.max_connections {
settings = settings.with_max_connections(value);
}
if let Some(value) = raw.min_connections {
settings = settings.with_min_connections(value);
}
if raw.allow_insecure_local == Some(true) {
settings = settings.allow_insecure_for_local_development();
}
settings.validate()?;
Ok(settings)
}
}
impl std::fmt::Debug for MySqlPoolConfig {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("MySqlPoolConfig")
.field("database_url", &"<redacted>")
.field("max_connections", &self.max_connections)
.field("min_connections", &self.min_connections)
.field("allow_insecure_local", &self.allow_insecure_local)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct MySqlPoolBuilder {
config: MySqlPoolConfig,
telemetry: IntegrationTelemetry,
}
impl MySqlPoolBuilder {
pub fn new(database_url: impl Into<String>) -> Self {
Self {
config: MySqlPoolConfig::new(database_url),
telemetry: IntegrationTelemetry::new(),
}
}
pub fn config(mut self, config: MySqlPoolConfig) -> Self {
self.config = config;
self
}
pub fn database_url(mut self, database_url: impl Into<String>) -> Self {
self.config.database_url = database_url.into();
self
}
pub fn max_connections(mut self, max_connections: u32) -> Self {
self.config.max_connections = Some(max_connections);
self
}
pub fn min_connections(mut self, min_connections: u32) -> Self {
self.config.min_connections = Some(min_connections);
self
}
pub fn telemetry(mut self, telemetry: IntegrationTelemetry) -> Self {
self.telemetry = telemetry;
self
}
#[cfg(feature = "observability")]
pub fn observability(
mut self,
observer: nidus_observability::ObservabilityAdapterObserver,
) -> Self {
self.telemetry = self.telemetry.observability(observer);
self
}
pub async fn connect(self) -> Result<MySqlPoolProvider> {
self.config.validate()?;
let started_at = Instant::now();
let mut options = sqlx::mysql::MySqlPoolOptions::new();
if let Some(value) = self.config.max_connections {
options = options.max_connections(value);
}
if let Some(value) = self.config.min_connections {
options = options.min_connections(value);
}
let pool = options.connect(&self.config.database_url).await;
self.telemetry
.record(&IntegrationEvent::new(
"nidus-sqlx-mysql",
"connect",
if pool.is_ok() {
IntegrationStatus::Success
} else {
IntegrationStatus::Failure
},
started_at.elapsed(),
))
.await;
Ok(MySqlPoolProvider {
pool: pool?,
telemetry: self.telemetry,
})
}
pub async fn register(self, container: &mut Container) -> Result<()> {
container.register_singleton(self.connect().await?)?;
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct MySqlPoolProvider {
pool: sqlx::MySqlPool,
telemetry: IntegrationTelemetry,
}
impl MySqlPoolProvider {
pub fn builder(database_url: impl Into<String>) -> MySqlPoolBuilder {
MySqlPoolBuilder::new(database_url)
}
pub fn from_pool(pool: sqlx::MySqlPool) -> Self {
Self {
pool,
telemetry: IntegrationTelemetry::new(),
}
}
pub fn pool(&self) -> &sqlx::MySqlPool {
&self.pool
}
pub const fn telemetry(&self) -> &IntegrationTelemetry {
&self.telemetry
}
pub fn into_pool(self) -> sqlx::MySqlPool {
self.pool
}
#[cfg(feature = "health")]
pub async fn health_status(&self) -> nidus_http::health::HealthStatus {
let started_at = Instant::now();
let result = sqlx::query("SELECT 1").execute(&self.pool).await;
self.telemetry
.record(&IntegrationEvent::new(
"nidus-sqlx-mysql",
"health",
if result.is_ok() {
IntegrationStatus::Success
} else {
IntegrationStatus::Failure
},
started_at.elapsed(),
))
.await;
if result.is_ok() {
nidus_http::health::HealthStatus::up()
} else {
nidus_http::health::HealthStatus::down("mysql readiness check failed")
}
}
#[cfg(feature = "health")]
pub fn register_ready_check(
self: std::sync::Arc<Self>,
registry: nidus_http::health::HealthRegistry,
name: impl Into<String>,
) -> nidus_http::health::HealthRegistry {
registry.ready_check(name, move || {
let provider = std::sync::Arc::clone(&self);
async move { provider.health_status().await }
})
}
}
#[async_trait::async_trait]
impl nidus_core::LifecycleHook for MySqlPoolProvider {
async fn on_shutdown(&self) -> nidus_core::Result<()> {
self.pool.close().await;
Ok(())
}
}
}
#[cfg(feature = "mysql")]
pub use mysql::{MySqlPoolBuilder, MySqlPoolConfig, MySqlPoolProvider};
#[cfg(feature = "cockroach")]
mod cockroach {
use std::{
fmt,
str::FromStr,
time::{Duration, Instant},
};
use futures_util::future::BoxFuture;
use nidus_core::Container;
use nidus_integrations::{IntegrationEvent, IntegrationStatus, IntegrationTelemetry};
use sqlx::Acquire;
use super::Result;
pub type CockroachTransactionResult<T> = std::result::Result<T, CockroachTransactionError>;
#[derive(Debug, thiserror::Error)]
pub enum CockroachTransactionError {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error("CockroachDB transaction exhausted {attempts} attempts")]
RetryExhausted {
attempts: usize,
#[source]
source: sqlx::Error,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CockroachRetryPolicy {
max_attempts: usize,
initial_backoff: Duration,
max_backoff: Duration,
jitter: bool,
}
impl CockroachRetryPolicy {
pub fn new() -> Self {
Self {
max_attempts: 5,
initial_backoff: Duration::from_millis(25),
max_backoff: Duration::from_secs(2),
jitter: true,
}
}
pub fn with_max_attempts(mut self, max_attempts: usize) -> Self {
self.max_attempts = max_attempts;
self
}
pub fn with_backoff(mut self, initial: Duration, maximum: Duration) -> Self {
self.initial_backoff = initial;
self.max_backoff = maximum;
self
}
pub fn without_jitter(mut self) -> Self {
self.jitter = false;
self
}
pub const fn max_attempts(&self) -> usize {
self.max_attempts
}
pub fn maximum_delay_for_retry(&self, retry: usize) -> Duration {
let shift = retry.saturating_sub(1).min(31) as u32;
self.initial_backoff
.saturating_mul(1_u32 << shift)
.min(self.max_backoff)
}
fn validate(&self) -> Result<()> {
if self.max_attempts == 0 {
return Err(super::configuration_error(
"CockroachDB max_attempts must be greater than zero",
));
}
if self.initial_backoff.is_zero() || self.max_backoff < self.initial_backoff {
return Err(super::configuration_error(
"CockroachDB retry backoff must be positive and ordered",
));
}
Ok(())
}
fn delay_for_retry(&self, retry: usize) -> Duration {
let maximum = self.maximum_delay_for_retry(retry);
if !self.jitter {
return maximum;
}
let upper = maximum.as_nanos().min(u128::from(u64::MAX)) as u64;
Duration::from_nanos(fastrand::u64(0..=upper))
}
}
impl Default for CockroachRetryPolicy {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct CockroachPoolConfig {
database_url: String,
max_connections: Option<u32>,
min_connections: Option<u32>,
acquire_timeout: Duration,
allow_insecure_local: bool,
retry_policy: CockroachRetryPolicy,
}
impl CockroachPoolConfig {
pub fn new(database_url: impl Into<String>) -> Self {
Self {
database_url: database_url.into(),
max_connections: None,
min_connections: None,
acquire_timeout: Duration::from_secs(5),
allow_insecure_local: false,
retry_policy: CockroachRetryPolicy::new(),
}
}
pub fn with_max_connections(mut self, value: u32) -> Self {
self.max_connections = Some(value);
self
}
pub fn with_min_connections(mut self, value: u32) -> Self {
self.min_connections = Some(value);
self
}
pub fn with_acquire_timeout(mut self, value: Duration) -> Self {
self.acquire_timeout = value;
self
}
pub fn with_retry_policy(mut self, value: CockroachRetryPolicy) -> Self {
self.retry_policy = value;
self
}
pub fn allow_insecure_for_local_development(mut self) -> Self {
self.allow_insecure_local = true;
self
}
pub fn database_url(&self) -> &str {
&self.database_url
}
pub fn retry_policy(&self) -> &CockroachRetryPolicy {
&self.retry_policy
}
pub fn validate(&self) -> Result<()> {
self.retry_policy.validate()?;
if self.max_connections == Some(0) {
return Err(super::configuration_error(
"CockroachDB max_connections must be greater than zero",
));
}
if self.acquire_timeout.is_zero() {
return Err(super::configuration_error(
"CockroachDB acquire timeout must be greater than zero",
));
}
if let (Some(minimum), Some(maximum)) = (self.min_connections, self.max_connections)
&& minimum > maximum
{
return Err(super::configuration_error(
"CockroachDB min_connections cannot exceed max_connections",
));
}
let options = sqlx::postgres::PgConnectOptions::from_str(&self.database_url)?;
let verify_full = matches!(
options.get_ssl_mode(),
sqlx::postgres::PgSslMode::VerifyFull
);
if !verify_full
&& (!self.allow_insecure_local
|| !matches!(options.get_host(), "localhost" | "127.0.0.1" | "::1"))
{
return Err(super::configuration_error(
"CockroachDB requires sslmode=verify-full except for explicit loopback development",
));
}
Ok(())
}
#[cfg(feature = "nidus-config")]
pub fn from_config_path<I, S>(config: &nidus_config::Config, path: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
#[derive(serde::Deserialize)]
struct RawConfig {
url: String,
max_connections: Option<u32>,
min_connections: Option<u32>,
acquire_timeout_ms: Option<u64>,
max_attempts: Option<usize>,
allow_insecure_local: Option<bool>,
}
let raw: RawConfig = config.get_required_path_typed(path)?;
let mut settings = Self::new(raw.url);
if let Some(value) = raw.max_connections {
settings = settings.with_max_connections(value);
}
if let Some(value) = raw.min_connections {
settings = settings.with_min_connections(value);
}
if let Some(value) = raw.acquire_timeout_ms {
settings = settings.with_acquire_timeout(Duration::from_millis(value));
}
if let Some(value) = raw.max_attempts {
settings.retry_policy = settings.retry_policy.with_max_attempts(value);
}
if raw.allow_insecure_local == Some(true) {
settings = settings.allow_insecure_for_local_development();
}
settings.validate()?;
Ok(settings)
}
}
impl fmt::Debug for CockroachPoolConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CockroachPoolConfig")
.field("database_url", &"<redacted>")
.field("max_connections", &self.max_connections)
.field("min_connections", &self.min_connections)
.field("acquire_timeout", &self.acquire_timeout)
.field("allow_insecure_local", &self.allow_insecure_local)
.field("retry_policy", &self.retry_policy)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct CockroachPoolBuilder {
config: CockroachPoolConfig,
telemetry: IntegrationTelemetry,
}
impl CockroachPoolBuilder {
pub fn new(database_url: impl Into<String>) -> Self {
Self {
config: CockroachPoolConfig::new(database_url),
telemetry: IntegrationTelemetry::new(),
}
}
pub fn config(mut self, config: CockroachPoolConfig) -> Self {
self.config = config;
self
}
pub fn telemetry(mut self, telemetry: IntegrationTelemetry) -> Self {
self.telemetry = telemetry;
self
}
#[cfg(feature = "observability")]
pub fn observability(
mut self,
observer: nidus_observability::ObservabilityAdapterObserver,
) -> Self {
self.telemetry = self.telemetry.observability(observer);
self
}
pub async fn connect(self) -> Result<CockroachPoolProvider> {
self.config.validate()?;
let started_at = Instant::now();
let connect_options =
sqlx::postgres::PgConnectOptions::from_str(&self.config.database_url)?;
let mut pool_options =
sqlx::postgres::PgPoolOptions::new().acquire_timeout(self.config.acquire_timeout);
if let Some(value) = self.config.max_connections {
pool_options = pool_options.max_connections(value);
}
if let Some(value) = self.config.min_connections {
pool_options = pool_options.min_connections(value);
}
let pool = pool_options.connect_with(connect_options).await;
self.telemetry
.record(&IntegrationEvent::new(
"nidus-sqlx-cockroach",
"connect",
if pool.is_ok() {
IntegrationStatus::Success
} else {
IntegrationStatus::Failure
},
started_at.elapsed(),
))
.await;
Ok(CockroachPoolProvider {
pool: pool?,
retry_policy: self.config.retry_policy,
telemetry: self.telemetry,
})
}
pub async fn register(self, container: &mut Container) -> Result<()> {
container.register_singleton(self.connect().await?)?;
Ok(())
}
}
pub type CockroachTransactionFuture<'connection, T> =
BoxFuture<'connection, std::result::Result<T, sqlx::Error>>;
#[derive(Clone, Debug)]
pub struct CockroachPoolProvider {
pool: sqlx::PgPool,
retry_policy: CockroachRetryPolicy,
telemetry: IntegrationTelemetry,
}
impl CockroachPoolProvider {
pub fn builder(database_url: impl Into<String>) -> CockroachPoolBuilder {
CockroachPoolBuilder::new(database_url)
}
pub fn from_pool(pool: sqlx::PgPool, retry_policy: CockroachRetryPolicy) -> Result<Self> {
retry_policy.validate()?;
Ok(Self {
pool,
retry_policy,
telemetry: IntegrationTelemetry::new(),
})
}
pub fn pool(&self) -> &sqlx::PgPool {
&self.pool
}
pub fn retry_policy(&self) -> &CockroachRetryPolicy {
&self.retry_policy
}
pub async fn transaction_with_retry<T, F>(
&self,
mut operation: F,
) -> CockroachTransactionResult<T>
where
T: Send,
F: for<'connection> FnMut(
&'connection mut sqlx::PgConnection,
) -> CockroachTransactionFuture<'connection, T>,
{
let started_at = Instant::now();
let mut connection = self.pool.acquire().await?;
for attempt in 1..=self.retry_policy.max_attempts {
let mut transaction = connection.begin().await?;
let outcome = operation(&mut transaction).await;
let final_outcome: std::result::Result<(), sqlx::Error> = match outcome {
Ok(value) => match transaction.commit().await {
Ok(()) => {
self.record_transaction(true, started_at).await;
return Ok(value);
}
Err(error) => Err(error),
},
Err(error) => {
let _ = transaction.rollback().await;
Err(error)
}
};
let error = final_outcome.unwrap_err();
if !is_retryable_serialization_error(&error) {
self.record_transaction(false, started_at).await;
return Err(error.into());
}
if attempt == self.retry_policy.max_attempts {
self.record_transaction(false, started_at).await;
return Err(CockroachTransactionError::RetryExhausted {
attempts: attempt,
source: error,
});
}
tokio::time::sleep(self.retry_policy.delay_for_retry(attempt)).await;
}
unreachable!("validated CockroachDB retry policy always attempts at least once")
}
#[cfg(feature = "health")]
pub async fn health_status(&self) -> nidus_http::health::HealthStatus {
let started_at = Instant::now();
let result = sqlx::query("SELECT 1").execute(&self.pool).await;
self.telemetry
.record(&IntegrationEvent::new(
"nidus-sqlx-cockroach",
"health",
if result.is_ok() {
IntegrationStatus::Success
} else {
IntegrationStatus::Failure
},
started_at.elapsed(),
))
.await;
if result.is_ok() {
nidus_http::health::HealthStatus::up()
} else {
nidus_http::health::HealthStatus::down("cockroachdb readiness check failed")
}
}
#[cfg(feature = "health")]
pub fn register_ready_check(
self: std::sync::Arc<Self>,
registry: nidus_http::health::HealthRegistry,
name: impl Into<String>,
) -> nidus_http::health::HealthRegistry {
registry.ready_check(name, move || {
let provider = std::sync::Arc::clone(&self);
async move { provider.health_status().await }
})
}
async fn record_transaction(&self, success: bool, started_at: Instant) {
self.telemetry
.record(&IntegrationEvent::new(
"nidus-sqlx-cockroach",
"transaction",
if success {
IntegrationStatus::Success
} else {
IntegrationStatus::Failure
},
started_at.elapsed(),
))
.await;
}
}
#[async_trait::async_trait]
impl nidus_core::LifecycleHook for CockroachPoolProvider {
async fn on_shutdown(&self) -> nidus_core::Result<()> {
self.pool.close().await;
Ok(())
}
}
fn is_retryable_serialization_error(error: &sqlx::Error) -> bool {
match error {
sqlx::Error::Database(database) => database.code().as_deref() == Some("40001"),
_ => false,
}
}
}
#[cfg(feature = "cockroach")]
pub use cockroach::{
CockroachPoolBuilder, CockroachPoolConfig, CockroachPoolProvider, CockroachRetryPolicy,
CockroachTransactionError, CockroachTransactionFuture, CockroachTransactionResult,
};