1#![deny(missing_docs)]
2
3use nidus_core::NidusError;
9use thiserror::Error;
10
11pub type Result<T> = std::result::Result<T, SqlxError>;
13
14#[derive(Debug, Error)]
16pub enum SqlxError {
17 #[error(transparent)]
19 Sqlx(#[from] sqlx::Error),
20
21 #[error(transparent)]
23 Nidus(#[from] NidusError),
24
25 #[cfg(feature = "nidus-config")]
27 #[error(transparent)]
28 Config(#[from] nidus_config::ConfigError),
29}
30
31#[cfg(any(feature = "mysql", feature = "cockroach"))]
32fn configuration_error(message: &'static str) -> SqlxError {
33 sqlx::Error::InvalidArgument(format!("invalid SQLx adapter configuration: {message}")).into()
34}
35
36#[cfg(feature = "sqlite")]
37mod sqlite {
38 #[cfg(feature = "observability")]
39 use std::time::Instant;
40
41 use super::Result;
42 use nidus_core::Container;
43
44 #[derive(Clone, Eq, PartialEq)]
46 pub struct SqlitePoolConfig {
47 database_url: String,
48 max_connections: Option<u32>,
49 }
50
51 impl SqlitePoolConfig {
52 pub fn new(database_url: impl Into<String>) -> Self {
54 Self {
55 database_url: database_url.into(),
56 max_connections: None,
57 }
58 }
59
60 pub fn with_max_connections(mut self, max_connections: u32) -> Self {
62 self.max_connections = Some(max_connections);
63 self
64 }
65
66 pub fn database_url(&self) -> &str {
68 &self.database_url
69 }
70
71 pub fn max_connections(&self) -> Option<u32> {
73 self.max_connections
74 }
75
76 #[cfg(feature = "nidus-config")]
78 pub fn from_config_path<I, S>(config: &nidus_config::Config, path: I) -> Result<Self>
79 where
80 I: IntoIterator<Item = S>,
81 S: AsRef<str>,
82 {
83 #[derive(serde::Deserialize)]
84 struct RawConfig {
85 url: String,
86 max_connections: Option<u32>,
87 }
88
89 let raw: RawConfig = config.get_required_path_typed(path)?;
90 let mut settings = Self::new(raw.url);
91 if let Some(max_connections) = raw.max_connections {
92 settings = settings.with_max_connections(max_connections);
93 }
94 Ok(settings)
95 }
96 }
97
98 impl std::fmt::Debug for SqlitePoolConfig {
99 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 formatter
101 .debug_struct("SqlitePoolConfig")
102 .field("database_url", &"<redacted>")
103 .field("max_connections", &self.max_connections)
104 .finish()
105 }
106 }
107
108 #[derive(Clone, Debug)]
110 pub struct SqlitePoolBuilder {
111 config: SqlitePoolConfig,
112 #[cfg(feature = "observability")]
113 observer: Option<nidus_observability::ObservabilityAdapterObserver>,
114 }
115
116 impl SqlitePoolBuilder {
117 pub fn new() -> Self {
119 Self {
120 config: SqlitePoolConfig::new("sqlite::memory:"),
121 #[cfg(feature = "observability")]
122 observer: None,
123 }
124 }
125
126 pub fn config(mut self, config: SqlitePoolConfig) -> Self {
128 self.config = config;
129 self
130 }
131
132 pub fn database_url(mut self, database_url: impl Into<String>) -> Self {
134 self.config.database_url = database_url.into();
135 self
136 }
137
138 pub fn max_connections(mut self, max_connections: u32) -> Self {
140 self.config.max_connections = Some(max_connections);
141 self
142 }
143
144 #[cfg(feature = "observability")]
146 pub fn observability(
147 mut self,
148 observer: nidus_observability::ObservabilityAdapterObserver,
149 ) -> Self {
150 self.observer = Some(observer);
151 self
152 }
153
154 pub async fn connect(self) -> Result<SqlitePoolProvider> {
156 #[cfg(feature = "observability")]
157 let observer = self.observer;
158 let mut options = sqlx::sqlite::SqlitePoolOptions::new();
159 if let Some(max_connections) = self.config.max_connections {
160 options = options.max_connections(max_connections);
161 }
162 #[cfg(feature = "observability")]
163 let started_at = Instant::now();
164 let pool = options.connect(&self.config.database_url).await;
165 #[cfg(feature = "observability")]
166 record_adapter_operation(
167 &observer,
168 "connect",
169 nidus_observability::OperationStatus::from(pool.is_ok()),
170 started_at,
171 );
172 let pool = pool?;
173 Ok(SqlitePoolProvider {
174 pool,
175 #[cfg(feature = "observability")]
176 observer,
177 })
178 }
179
180 pub async fn register(self, container: &mut Container) -> Result<()> {
182 let provider = self.connect().await?;
183 container.register_singleton(provider)?;
184 Ok(())
185 }
186 }
187
188 impl Default for SqlitePoolBuilder {
189 fn default() -> Self {
190 Self::new()
191 }
192 }
193
194 #[derive(Clone, Debug)]
196 pub struct SqlitePoolProvider {
197 pool: sqlx::SqlitePool,
198 #[cfg(feature = "observability")]
199 observer: Option<nidus_observability::ObservabilityAdapterObserver>,
200 }
201
202 impl SqlitePoolProvider {
203 pub fn builder() -> SqlitePoolBuilder {
205 SqlitePoolBuilder::new()
206 }
207
208 pub fn from_pool(pool: sqlx::SqlitePool) -> Self {
210 Self {
211 pool,
212 #[cfg(feature = "observability")]
213 observer: None,
214 }
215 }
216
217 pub fn pool(&self) -> &sqlx::SqlitePool {
219 &self.pool
220 }
221
222 pub fn into_pool(self) -> sqlx::SqlitePool {
224 self.pool
225 }
226
227 #[cfg(feature = "health")]
229 pub async fn health_status(&self) -> nidus_http::health::HealthStatus {
230 #[cfg(feature = "observability")]
231 let started_at = Instant::now();
232 let result = sqlx::query("SELECT 1").execute(&self.pool).await;
233 #[cfg(feature = "observability")]
234 record_adapter_operation(
235 &self.observer,
236 "health",
237 nidus_observability::OperationStatus::from(result.is_ok()),
238 started_at,
239 );
240 match result {
241 Ok(_) => nidus_http::health::HealthStatus::up(),
242 Err(error) => nidus_http::health::HealthStatus::down(error.to_string()),
243 }
244 }
245
246 #[cfg(feature = "health")]
252 pub fn register_ready_check(
253 self: std::sync::Arc<Self>,
254 registry: nidus_http::health::HealthRegistry,
255 name: impl Into<String>,
256 ) -> nidus_http::health::HealthRegistry {
257 registry.ready_check(name, move || {
258 let provider = std::sync::Arc::clone(&self);
259 async move { provider.health_status().await }
260 })
261 }
262 }
263
264 #[async_trait::async_trait]
265 impl nidus_core::LifecycleHook for SqlitePoolProvider {
266 async fn on_shutdown(&self) -> nidus_core::Result<()> {
267 self.pool.close().await;
268 Ok(())
269 }
270 }
271
272 #[cfg(feature = "observability")]
273 fn record_adapter_operation(
274 observer: &Option<nidus_observability::ObservabilityAdapterObserver>,
275 operation: &'static str,
276 status: nidus_observability::OperationStatus,
277 started_at: Instant,
278 ) {
279 if let Some(observer) = observer {
280 observer.record("nidus-sqlx", operation, status, started_at.elapsed());
281 }
282 }
283}
284
285#[cfg(feature = "sqlite")]
286pub use sqlite::{SqlitePoolBuilder, SqlitePoolConfig, SqlitePoolProvider};
287
288#[cfg(feature = "postgres")]
289mod postgres {
290 #[cfg(feature = "observability")]
291 use std::time::Instant;
292
293 use super::Result;
294 use nidus_core::Container;
295
296 #[derive(Clone, Eq, PartialEq)]
298 pub struct PostgresPoolConfig {
299 database_url: String,
300 max_connections: Option<u32>,
301 min_connections: Option<u32>,
302 }
303
304 impl PostgresPoolConfig {
305 pub fn new(database_url: impl Into<String>) -> Self {
307 Self {
308 database_url: database_url.into(),
309 max_connections: None,
310 min_connections: None,
311 }
312 }
313
314 pub fn with_max_connections(mut self, max_connections: u32) -> Self {
316 self.max_connections = Some(max_connections);
317 self
318 }
319
320 pub fn with_min_connections(mut self, min_connections: u32) -> Self {
322 self.min_connections = Some(min_connections);
323 self
324 }
325
326 pub fn database_url(&self) -> &str {
328 &self.database_url
329 }
330
331 pub fn max_connections(&self) -> Option<u32> {
333 self.max_connections
334 }
335
336 pub fn min_connections(&self) -> Option<u32> {
338 self.min_connections
339 }
340
341 #[cfg(feature = "nidus-config")]
343 pub fn from_config_path<I, S>(config: &nidus_config::Config, path: I) -> Result<Self>
344 where
345 I: IntoIterator<Item = S>,
346 S: AsRef<str>,
347 {
348 #[derive(serde::Deserialize)]
349 struct RawConfig {
350 url: String,
351 max_connections: Option<u32>,
352 min_connections: Option<u32>,
353 }
354
355 let raw: RawConfig = config.get_required_path_typed(path)?;
356 let mut settings = Self::new(raw.url);
357 if let Some(max_connections) = raw.max_connections {
358 settings = settings.with_max_connections(max_connections);
359 }
360 if let Some(min_connections) = raw.min_connections {
361 settings = settings.with_min_connections(min_connections);
362 }
363 Ok(settings)
364 }
365 }
366
367 impl std::fmt::Debug for PostgresPoolConfig {
368 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369 formatter
370 .debug_struct("PostgresPoolConfig")
371 .field("database_url", &"<redacted>")
372 .field("max_connections", &self.max_connections)
373 .field("min_connections", &self.min_connections)
374 .finish()
375 }
376 }
377
378 #[derive(Clone, Debug)]
380 pub struct PostgresPoolBuilder {
381 config: PostgresPoolConfig,
382 #[cfg(feature = "observability")]
383 observer: Option<nidus_observability::ObservabilityAdapterObserver>,
384 }
385
386 impl PostgresPoolBuilder {
387 pub fn new(database_url: impl Into<String>) -> Self {
389 Self {
390 config: PostgresPoolConfig::new(database_url),
391 #[cfg(feature = "observability")]
392 observer: None,
393 }
394 }
395
396 pub fn config(mut self, config: PostgresPoolConfig) -> Self {
398 self.config = config;
399 self
400 }
401
402 pub fn database_url(mut self, database_url: impl Into<String>) -> Self {
404 self.config.database_url = database_url.into();
405 self
406 }
407
408 pub fn max_connections(mut self, max_connections: u32) -> Self {
410 self.config.max_connections = Some(max_connections);
411 self
412 }
413
414 pub fn min_connections(mut self, min_connections: u32) -> Self {
416 self.config.min_connections = Some(min_connections);
417 self
418 }
419
420 #[cfg(feature = "observability")]
422 pub fn observability(
423 mut self,
424 observer: nidus_observability::ObservabilityAdapterObserver,
425 ) -> Self {
426 self.observer = Some(observer);
427 self
428 }
429
430 pub async fn connect(self) -> Result<PostgresPoolProvider> {
432 #[cfg(feature = "observability")]
433 let observer = self.observer;
434 let mut options = sqlx::postgres::PgPoolOptions::new();
435 if let Some(max_connections) = self.config.max_connections {
436 options = options.max_connections(max_connections);
437 }
438 if let Some(min_connections) = self.config.min_connections {
439 options = options.min_connections(min_connections);
440 }
441 #[cfg(feature = "observability")]
442 let started_at = Instant::now();
443 let pool = options.connect(&self.config.database_url).await;
444 #[cfg(feature = "observability")]
445 record_adapter_operation(
446 &observer,
447 "connect",
448 nidus_observability::OperationStatus::from(pool.is_ok()),
449 started_at,
450 );
451 let pool = pool?;
452 Ok(PostgresPoolProvider {
453 pool,
454 #[cfg(feature = "observability")]
455 observer,
456 })
457 }
458
459 pub async fn register(self, container: &mut Container) -> Result<()> {
461 let provider = self.connect().await?;
462 container.register_singleton(provider)?;
463 Ok(())
464 }
465 }
466
467 #[derive(Clone, Debug)]
469 pub struct PostgresPoolProvider {
470 pool: sqlx::PgPool,
471 #[cfg(feature = "observability")]
472 observer: Option<nidus_observability::ObservabilityAdapterObserver>,
473 }
474
475 impl PostgresPoolProvider {
476 pub fn builder(database_url: impl Into<String>) -> PostgresPoolBuilder {
478 PostgresPoolBuilder::new(database_url)
479 }
480
481 pub fn from_pool(pool: sqlx::PgPool) -> Self {
483 Self {
484 pool,
485 #[cfg(feature = "observability")]
486 observer: None,
487 }
488 }
489
490 pub fn pool(&self) -> &sqlx::PgPool {
492 &self.pool
493 }
494
495 pub fn into_pool(self) -> sqlx::PgPool {
497 self.pool
498 }
499
500 #[cfg(feature = "health")]
502 pub async fn health_status(&self) -> nidus_http::health::HealthStatus {
503 #[cfg(feature = "observability")]
504 let started_at = Instant::now();
505 let result = sqlx::query("SELECT 1").execute(&self.pool).await;
506 #[cfg(feature = "observability")]
507 record_adapter_operation(
508 &self.observer,
509 "health",
510 nidus_observability::OperationStatus::from(result.is_ok()),
511 started_at,
512 );
513 match result {
514 Ok(_) => nidus_http::health::HealthStatus::up(),
515 Err(error) => nidus_http::health::HealthStatus::down(error.to_string()),
516 }
517 }
518
519 #[cfg(feature = "health")]
525 pub fn register_ready_check(
526 self: std::sync::Arc<Self>,
527 registry: nidus_http::health::HealthRegistry,
528 name: impl Into<String>,
529 ) -> nidus_http::health::HealthRegistry {
530 registry.ready_check(name, move || {
531 let provider = std::sync::Arc::clone(&self);
532 async move { provider.health_status().await }
533 })
534 }
535 }
536
537 #[async_trait::async_trait]
538 impl nidus_core::LifecycleHook for PostgresPoolProvider {
539 async fn on_shutdown(&self) -> nidus_core::Result<()> {
540 self.pool.close().await;
541 Ok(())
542 }
543 }
544
545 #[cfg(feature = "observability")]
546 fn record_adapter_operation(
547 observer: &Option<nidus_observability::ObservabilityAdapterObserver>,
548 operation: &'static str,
549 status: nidus_observability::OperationStatus,
550 started_at: Instant,
551 ) {
552 if let Some(observer) = observer {
553 observer.record("nidus-sqlx", operation, status, started_at.elapsed());
554 }
555 }
556}
557
558#[cfg(feature = "postgres")]
559pub use postgres::{PostgresPoolBuilder, PostgresPoolConfig, PostgresPoolProvider};
560
561#[cfg(feature = "mysql")]
562mod mysql {
563 use std::{str::FromStr, time::Instant};
564
565 use nidus_core::Container;
566 use nidus_integrations::{IntegrationEvent, IntegrationStatus, IntegrationTelemetry};
567
568 use super::Result;
569
570 #[derive(Clone, Eq, PartialEq)]
572 pub struct MySqlPoolConfig {
573 database_url: String,
574 max_connections: Option<u32>,
575 min_connections: Option<u32>,
576 allow_insecure_local: bool,
577 }
578
579 impl MySqlPoolConfig {
580 pub fn new(database_url: impl Into<String>) -> Self {
582 Self {
583 database_url: database_url.into(),
584 max_connections: None,
585 min_connections: None,
586 allow_insecure_local: false,
587 }
588 }
589
590 pub fn with_max_connections(mut self, max_connections: u32) -> Self {
592 self.max_connections = Some(max_connections);
593 self
594 }
595
596 pub fn with_min_connections(mut self, min_connections: u32) -> Self {
598 self.min_connections = Some(min_connections);
599 self
600 }
601
602 pub fn allow_insecure_for_local_development(mut self) -> Self {
604 self.allow_insecure_local = true;
605 self
606 }
607
608 pub fn database_url(&self) -> &str {
610 &self.database_url
611 }
612
613 pub fn max_connections(&self) -> Option<u32> {
615 self.max_connections
616 }
617
618 pub fn min_connections(&self) -> Option<u32> {
620 self.min_connections
621 }
622
623 pub fn validate(&self) -> Result<()> {
625 if self.max_connections == Some(0) {
626 return Err(super::configuration_error(
627 "MySQL max_connections must be greater than zero",
628 ));
629 }
630 if let (Some(minimum), Some(maximum)) = (self.min_connections, self.max_connections)
631 && minimum > maximum
632 {
633 return Err(super::configuration_error(
634 "MySQL min_connections cannot exceed max_connections",
635 ));
636 }
637 let options = sqlx::mysql::MySqlConnectOptions::from_str(&self.database_url)?;
638 let verifies_identity = matches!(
639 options.get_ssl_mode(),
640 sqlx::mysql::MySqlSslMode::VerifyIdentity
641 );
642 if !verifies_identity
643 && (!self.allow_insecure_local
644 || !matches!(options.get_host(), "localhost" | "127.0.0.1" | "::1"))
645 {
646 return Err(super::configuration_error(
647 "MySQL requires ssl-mode=VERIFY_IDENTITY except for explicit loopback development",
648 ));
649 }
650 Ok(())
651 }
652
653 #[cfg(feature = "nidus-config")]
655 pub fn from_config_path<I, S>(config: &nidus_config::Config, path: I) -> Result<Self>
656 where
657 I: IntoIterator<Item = S>,
658 S: AsRef<str>,
659 {
660 #[derive(serde::Deserialize)]
661 struct RawConfig {
662 url: String,
663 max_connections: Option<u32>,
664 min_connections: Option<u32>,
665 allow_insecure_local: Option<bool>,
666 }
667
668 let raw: RawConfig = config.get_required_path_typed(path)?;
669 let mut settings = Self::new(raw.url);
670 if let Some(value) = raw.max_connections {
671 settings = settings.with_max_connections(value);
672 }
673 if let Some(value) = raw.min_connections {
674 settings = settings.with_min_connections(value);
675 }
676 if raw.allow_insecure_local == Some(true) {
677 settings = settings.allow_insecure_for_local_development();
678 }
679 settings.validate()?;
680 Ok(settings)
681 }
682 }
683
684 impl std::fmt::Debug for MySqlPoolConfig {
685 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686 formatter
687 .debug_struct("MySqlPoolConfig")
688 .field("database_url", &"<redacted>")
689 .field("max_connections", &self.max_connections)
690 .field("min_connections", &self.min_connections)
691 .field("allow_insecure_local", &self.allow_insecure_local)
692 .finish()
693 }
694 }
695
696 #[derive(Clone, Debug)]
698 pub struct MySqlPoolBuilder {
699 config: MySqlPoolConfig,
700 telemetry: IntegrationTelemetry,
701 }
702
703 impl MySqlPoolBuilder {
704 pub fn new(database_url: impl Into<String>) -> Self {
706 Self {
707 config: MySqlPoolConfig::new(database_url),
708 telemetry: IntegrationTelemetry::new(),
709 }
710 }
711
712 pub fn config(mut self, config: MySqlPoolConfig) -> Self {
714 self.config = config;
715 self
716 }
717
718 pub fn database_url(mut self, database_url: impl Into<String>) -> Self {
720 self.config.database_url = database_url.into();
721 self
722 }
723
724 pub fn max_connections(mut self, max_connections: u32) -> Self {
726 self.config.max_connections = Some(max_connections);
727 self
728 }
729
730 pub fn min_connections(mut self, min_connections: u32) -> Self {
732 self.config.min_connections = Some(min_connections);
733 self
734 }
735
736 pub fn telemetry(mut self, telemetry: IntegrationTelemetry) -> Self {
738 self.telemetry = telemetry;
739 self
740 }
741
742 #[cfg(feature = "observability")]
744 pub fn observability(
745 mut self,
746 observer: nidus_observability::ObservabilityAdapterObserver,
747 ) -> Self {
748 self.telemetry = self.telemetry.observability(observer);
749 self
750 }
751
752 pub async fn connect(self) -> Result<MySqlPoolProvider> {
754 self.config.validate()?;
755 let started_at = Instant::now();
756 let mut options = sqlx::mysql::MySqlPoolOptions::new();
757 if let Some(value) = self.config.max_connections {
758 options = options.max_connections(value);
759 }
760 if let Some(value) = self.config.min_connections {
761 options = options.min_connections(value);
762 }
763 let pool = options.connect(&self.config.database_url).await;
764 self.telemetry
765 .record(&IntegrationEvent::new(
766 "nidus-sqlx-mysql",
767 "connect",
768 if pool.is_ok() {
769 IntegrationStatus::Success
770 } else {
771 IntegrationStatus::Failure
772 },
773 started_at.elapsed(),
774 ))
775 .await;
776 Ok(MySqlPoolProvider {
777 pool: pool?,
778 telemetry: self.telemetry,
779 })
780 }
781
782 pub async fn register(self, container: &mut Container) -> Result<()> {
784 container.register_singleton(self.connect().await?)?;
785 Ok(())
786 }
787 }
788
789 #[derive(Clone, Debug)]
791 pub struct MySqlPoolProvider {
792 pool: sqlx::MySqlPool,
793 telemetry: IntegrationTelemetry,
794 }
795
796 impl MySqlPoolProvider {
797 pub fn builder(database_url: impl Into<String>) -> MySqlPoolBuilder {
799 MySqlPoolBuilder::new(database_url)
800 }
801
802 pub fn from_pool(pool: sqlx::MySqlPool) -> Self {
804 Self {
805 pool,
806 telemetry: IntegrationTelemetry::new(),
807 }
808 }
809
810 pub fn pool(&self) -> &sqlx::MySqlPool {
812 &self.pool
813 }
814
815 pub const fn telemetry(&self) -> &IntegrationTelemetry {
817 &self.telemetry
818 }
819
820 pub fn into_pool(self) -> sqlx::MySqlPool {
822 self.pool
823 }
824
825 #[cfg(feature = "health")]
827 pub async fn health_status(&self) -> nidus_http::health::HealthStatus {
828 let started_at = Instant::now();
829 let result = sqlx::query("SELECT 1").execute(&self.pool).await;
830 self.telemetry
831 .record(&IntegrationEvent::new(
832 "nidus-sqlx-mysql",
833 "health",
834 if result.is_ok() {
835 IntegrationStatus::Success
836 } else {
837 IntegrationStatus::Failure
838 },
839 started_at.elapsed(),
840 ))
841 .await;
842 if result.is_ok() {
843 nidus_http::health::HealthStatus::up()
844 } else {
845 nidus_http::health::HealthStatus::down("mysql readiness check failed")
846 }
847 }
848
849 #[cfg(feature = "health")]
851 pub fn register_ready_check(
852 self: std::sync::Arc<Self>,
853 registry: nidus_http::health::HealthRegistry,
854 name: impl Into<String>,
855 ) -> nidus_http::health::HealthRegistry {
856 registry.ready_check(name, move || {
857 let provider = std::sync::Arc::clone(&self);
858 async move { provider.health_status().await }
859 })
860 }
861 }
862
863 #[async_trait::async_trait]
864 impl nidus_core::LifecycleHook for MySqlPoolProvider {
865 async fn on_shutdown(&self) -> nidus_core::Result<()> {
866 self.pool.close().await;
867 Ok(())
868 }
869 }
870}
871
872#[cfg(feature = "mysql")]
873pub use mysql::{MySqlPoolBuilder, MySqlPoolConfig, MySqlPoolProvider};
874
875#[cfg(feature = "cockroach")]
876mod cockroach {
877 use std::{
878 fmt,
879 str::FromStr,
880 time::{Duration, Instant},
881 };
882
883 use futures_util::future::BoxFuture;
884 use nidus_core::Container;
885 use nidus_integrations::{IntegrationEvent, IntegrationStatus, IntegrationTelemetry};
886 use sqlx::Acquire;
887
888 use super::Result;
889
890 pub type CockroachTransactionResult<T> = std::result::Result<T, CockroachTransactionError>;
892
893 #[derive(Debug, thiserror::Error)]
895 pub enum CockroachTransactionError {
896 #[error(transparent)]
898 Sqlx(#[from] sqlx::Error),
899 #[error("CockroachDB transaction exhausted {attempts} attempts")]
901 RetryExhausted {
902 attempts: usize,
904 #[source]
906 source: sqlx::Error,
907 },
908 }
909
910 #[derive(Clone, Debug, Eq, PartialEq)]
912 pub struct CockroachRetryPolicy {
913 max_attempts: usize,
914 initial_backoff: Duration,
915 max_backoff: Duration,
916 jitter: bool,
917 }
918
919 impl CockroachRetryPolicy {
920 pub fn new() -> Self {
922 Self {
923 max_attempts: 5,
924 initial_backoff: Duration::from_millis(25),
925 max_backoff: Duration::from_secs(2),
926 jitter: true,
927 }
928 }
929
930 pub fn with_max_attempts(mut self, max_attempts: usize) -> Self {
932 self.max_attempts = max_attempts;
933 self
934 }
935
936 pub fn with_backoff(mut self, initial: Duration, maximum: Duration) -> Self {
938 self.initial_backoff = initial;
939 self.max_backoff = maximum;
940 self
941 }
942
943 pub fn without_jitter(mut self) -> Self {
945 self.jitter = false;
946 self
947 }
948
949 pub const fn max_attempts(&self) -> usize {
951 self.max_attempts
952 }
953
954 pub fn maximum_delay_for_retry(&self, retry: usize) -> Duration {
956 let shift = retry.saturating_sub(1).min(31) as u32;
957 self.initial_backoff
958 .saturating_mul(1_u32 << shift)
959 .min(self.max_backoff)
960 }
961
962 fn validate(&self) -> Result<()> {
963 if self.max_attempts == 0 {
964 return Err(super::configuration_error(
965 "CockroachDB max_attempts must be greater than zero",
966 ));
967 }
968 if self.initial_backoff.is_zero() || self.max_backoff < self.initial_backoff {
969 return Err(super::configuration_error(
970 "CockroachDB retry backoff must be positive and ordered",
971 ));
972 }
973 Ok(())
974 }
975
976 fn delay_for_retry(&self, retry: usize) -> Duration {
977 let maximum = self.maximum_delay_for_retry(retry);
978 if !self.jitter {
979 return maximum;
980 }
981 let upper = maximum.as_nanos().min(u128::from(u64::MAX)) as u64;
982 Duration::from_nanos(fastrand::u64(0..=upper))
983 }
984 }
985
986 impl Default for CockroachRetryPolicy {
987 fn default() -> Self {
988 Self::new()
989 }
990 }
991
992 #[derive(Clone, Eq, PartialEq)]
994 pub struct CockroachPoolConfig {
995 database_url: String,
996 max_connections: Option<u32>,
997 min_connections: Option<u32>,
998 acquire_timeout: Duration,
999 allow_insecure_local: bool,
1000 retry_policy: CockroachRetryPolicy,
1001 }
1002
1003 impl CockroachPoolConfig {
1004 pub fn new(database_url: impl Into<String>) -> Self {
1006 Self {
1007 database_url: database_url.into(),
1008 max_connections: None,
1009 min_connections: None,
1010 acquire_timeout: Duration::from_secs(5),
1011 allow_insecure_local: false,
1012 retry_policy: CockroachRetryPolicy::new(),
1013 }
1014 }
1015
1016 pub fn with_max_connections(mut self, value: u32) -> Self {
1018 self.max_connections = Some(value);
1019 self
1020 }
1021
1022 pub fn with_min_connections(mut self, value: u32) -> Self {
1024 self.min_connections = Some(value);
1025 self
1026 }
1027
1028 pub fn with_acquire_timeout(mut self, value: Duration) -> Self {
1030 self.acquire_timeout = value;
1031 self
1032 }
1033
1034 pub fn with_retry_policy(mut self, value: CockroachRetryPolicy) -> Self {
1036 self.retry_policy = value;
1037 self
1038 }
1039
1040 pub fn allow_insecure_for_local_development(mut self) -> Self {
1044 self.allow_insecure_local = true;
1045 self
1046 }
1047
1048 pub fn database_url(&self) -> &str {
1050 &self.database_url
1051 }
1052
1053 pub fn retry_policy(&self) -> &CockroachRetryPolicy {
1055 &self.retry_policy
1056 }
1057
1058 pub fn validate(&self) -> Result<()> {
1060 self.retry_policy.validate()?;
1061 if self.max_connections == Some(0) {
1062 return Err(super::configuration_error(
1063 "CockroachDB max_connections must be greater than zero",
1064 ));
1065 }
1066 if self.acquire_timeout.is_zero() {
1067 return Err(super::configuration_error(
1068 "CockroachDB acquire timeout must be greater than zero",
1069 ));
1070 }
1071 if let (Some(minimum), Some(maximum)) = (self.min_connections, self.max_connections)
1072 && minimum > maximum
1073 {
1074 return Err(super::configuration_error(
1075 "CockroachDB min_connections cannot exceed max_connections",
1076 ));
1077 }
1078 let options = sqlx::postgres::PgConnectOptions::from_str(&self.database_url)?;
1079 let verify_full = matches!(
1080 options.get_ssl_mode(),
1081 sqlx::postgres::PgSslMode::VerifyFull
1082 );
1083 if !verify_full
1084 && (!self.allow_insecure_local
1085 || !matches!(options.get_host(), "localhost" | "127.0.0.1" | "::1"))
1086 {
1087 return Err(super::configuration_error(
1088 "CockroachDB requires sslmode=verify-full except for explicit loopback development",
1089 ));
1090 }
1091 Ok(())
1092 }
1093
1094 #[cfg(feature = "nidus-config")]
1096 pub fn from_config_path<I, S>(config: &nidus_config::Config, path: I) -> Result<Self>
1097 where
1098 I: IntoIterator<Item = S>,
1099 S: AsRef<str>,
1100 {
1101 #[derive(serde::Deserialize)]
1102 struct RawConfig {
1103 url: String,
1104 max_connections: Option<u32>,
1105 min_connections: Option<u32>,
1106 acquire_timeout_ms: Option<u64>,
1107 max_attempts: Option<usize>,
1108 allow_insecure_local: Option<bool>,
1109 }
1110
1111 let raw: RawConfig = config.get_required_path_typed(path)?;
1112 let mut settings = Self::new(raw.url);
1113 if let Some(value) = raw.max_connections {
1114 settings = settings.with_max_connections(value);
1115 }
1116 if let Some(value) = raw.min_connections {
1117 settings = settings.with_min_connections(value);
1118 }
1119 if let Some(value) = raw.acquire_timeout_ms {
1120 settings = settings.with_acquire_timeout(Duration::from_millis(value));
1121 }
1122 if let Some(value) = raw.max_attempts {
1123 settings.retry_policy = settings.retry_policy.with_max_attempts(value);
1124 }
1125 if raw.allow_insecure_local == Some(true) {
1126 settings = settings.allow_insecure_for_local_development();
1127 }
1128 settings.validate()?;
1129 Ok(settings)
1130 }
1131 }
1132
1133 impl fmt::Debug for CockroachPoolConfig {
1134 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1135 formatter
1136 .debug_struct("CockroachPoolConfig")
1137 .field("database_url", &"<redacted>")
1138 .field("max_connections", &self.max_connections)
1139 .field("min_connections", &self.min_connections)
1140 .field("acquire_timeout", &self.acquire_timeout)
1141 .field("allow_insecure_local", &self.allow_insecure_local)
1142 .field("retry_policy", &self.retry_policy)
1143 .finish()
1144 }
1145 }
1146
1147 #[derive(Clone, Debug)]
1149 pub struct CockroachPoolBuilder {
1150 config: CockroachPoolConfig,
1151 telemetry: IntegrationTelemetry,
1152 }
1153
1154 impl CockroachPoolBuilder {
1155 pub fn new(database_url: impl Into<String>) -> Self {
1157 Self {
1158 config: CockroachPoolConfig::new(database_url),
1159 telemetry: IntegrationTelemetry::new(),
1160 }
1161 }
1162
1163 pub fn config(mut self, config: CockroachPoolConfig) -> Self {
1165 self.config = config;
1166 self
1167 }
1168
1169 pub fn telemetry(mut self, telemetry: IntegrationTelemetry) -> Self {
1171 self.telemetry = telemetry;
1172 self
1173 }
1174
1175 #[cfg(feature = "observability")]
1177 pub fn observability(
1178 mut self,
1179 observer: nidus_observability::ObservabilityAdapterObserver,
1180 ) -> Self {
1181 self.telemetry = self.telemetry.observability(observer);
1182 self
1183 }
1184
1185 pub async fn connect(self) -> Result<CockroachPoolProvider> {
1187 self.config.validate()?;
1188 let started_at = Instant::now();
1189 let connect_options =
1190 sqlx::postgres::PgConnectOptions::from_str(&self.config.database_url)?;
1191 let mut pool_options =
1192 sqlx::postgres::PgPoolOptions::new().acquire_timeout(self.config.acquire_timeout);
1193 if let Some(value) = self.config.max_connections {
1194 pool_options = pool_options.max_connections(value);
1195 }
1196 if let Some(value) = self.config.min_connections {
1197 pool_options = pool_options.min_connections(value);
1198 }
1199 let pool = pool_options.connect_with(connect_options).await;
1200 self.telemetry
1201 .record(&IntegrationEvent::new(
1202 "nidus-sqlx-cockroach",
1203 "connect",
1204 if pool.is_ok() {
1205 IntegrationStatus::Success
1206 } else {
1207 IntegrationStatus::Failure
1208 },
1209 started_at.elapsed(),
1210 ))
1211 .await;
1212 Ok(CockroachPoolProvider {
1213 pool: pool?,
1214 retry_policy: self.config.retry_policy,
1215 telemetry: self.telemetry,
1216 })
1217 }
1218
1219 pub async fn register(self, container: &mut Container) -> Result<()> {
1221 container.register_singleton(self.connect().await?)?;
1222 Ok(())
1223 }
1224 }
1225
1226 pub type CockroachTransactionFuture<'connection, T> =
1228 BoxFuture<'connection, std::result::Result<T, sqlx::Error>>;
1229
1230 #[derive(Clone, Debug)]
1232 pub struct CockroachPoolProvider {
1233 pool: sqlx::PgPool,
1234 retry_policy: CockroachRetryPolicy,
1235 telemetry: IntegrationTelemetry,
1236 }
1237
1238 impl CockroachPoolProvider {
1239 pub fn builder(database_url: impl Into<String>) -> CockroachPoolBuilder {
1241 CockroachPoolBuilder::new(database_url)
1242 }
1243
1244 pub fn from_pool(pool: sqlx::PgPool, retry_policy: CockroachRetryPolicy) -> Result<Self> {
1246 retry_policy.validate()?;
1247 Ok(Self {
1248 pool,
1249 retry_policy,
1250 telemetry: IntegrationTelemetry::new(),
1251 })
1252 }
1253
1254 pub fn pool(&self) -> &sqlx::PgPool {
1256 &self.pool
1257 }
1258
1259 pub fn retry_policy(&self) -> &CockroachRetryPolicy {
1261 &self.retry_policy
1262 }
1263
1264 pub async fn transaction_with_retry<T, F>(
1271 &self,
1272 mut operation: F,
1273 ) -> CockroachTransactionResult<T>
1274 where
1275 T: Send,
1276 F: for<'connection> FnMut(
1277 &'connection mut sqlx::PgConnection,
1278 ) -> CockroachTransactionFuture<'connection, T>,
1279 {
1280 let started_at = Instant::now();
1281 let mut connection = self.pool.acquire().await?;
1282 for attempt in 1..=self.retry_policy.max_attempts {
1283 let mut transaction = connection.begin().await?;
1284 let outcome = operation(&mut transaction).await;
1285 let final_outcome: std::result::Result<(), sqlx::Error> = match outcome {
1286 Ok(value) => match transaction.commit().await {
1287 Ok(()) => {
1288 self.record_transaction(true, started_at).await;
1289 return Ok(value);
1290 }
1291 Err(error) => Err(error),
1292 },
1293 Err(error) => {
1294 let _ = transaction.rollback().await;
1295 Err(error)
1296 }
1297 };
1298
1299 let error = final_outcome.unwrap_err();
1300 if !is_retryable_serialization_error(&error) {
1301 self.record_transaction(false, started_at).await;
1302 return Err(error.into());
1303 }
1304 if attempt == self.retry_policy.max_attempts {
1305 self.record_transaction(false, started_at).await;
1306 return Err(CockroachTransactionError::RetryExhausted {
1307 attempts: attempt,
1308 source: error,
1309 });
1310 }
1311 tokio::time::sleep(self.retry_policy.delay_for_retry(attempt)).await;
1312 }
1313 unreachable!("validated CockroachDB retry policy always attempts at least once")
1314 }
1315
1316 #[cfg(feature = "health")]
1318 pub async fn health_status(&self) -> nidus_http::health::HealthStatus {
1319 let started_at = Instant::now();
1320 let result = sqlx::query("SELECT 1").execute(&self.pool).await;
1321 self.telemetry
1322 .record(&IntegrationEvent::new(
1323 "nidus-sqlx-cockroach",
1324 "health",
1325 if result.is_ok() {
1326 IntegrationStatus::Success
1327 } else {
1328 IntegrationStatus::Failure
1329 },
1330 started_at.elapsed(),
1331 ))
1332 .await;
1333 if result.is_ok() {
1334 nidus_http::health::HealthStatus::up()
1335 } else {
1336 nidus_http::health::HealthStatus::down("cockroachdb readiness check failed")
1337 }
1338 }
1339
1340 #[cfg(feature = "health")]
1342 pub fn register_ready_check(
1343 self: std::sync::Arc<Self>,
1344 registry: nidus_http::health::HealthRegistry,
1345 name: impl Into<String>,
1346 ) -> nidus_http::health::HealthRegistry {
1347 registry.ready_check(name, move || {
1348 let provider = std::sync::Arc::clone(&self);
1349 async move { provider.health_status().await }
1350 })
1351 }
1352
1353 async fn record_transaction(&self, success: bool, started_at: Instant) {
1354 self.telemetry
1355 .record(&IntegrationEvent::new(
1356 "nidus-sqlx-cockroach",
1357 "transaction",
1358 if success {
1359 IntegrationStatus::Success
1360 } else {
1361 IntegrationStatus::Failure
1362 },
1363 started_at.elapsed(),
1364 ))
1365 .await;
1366 }
1367 }
1368
1369 #[async_trait::async_trait]
1370 impl nidus_core::LifecycleHook for CockroachPoolProvider {
1371 async fn on_shutdown(&self) -> nidus_core::Result<()> {
1372 self.pool.close().await;
1373 Ok(())
1374 }
1375 }
1376
1377 fn is_retryable_serialization_error(error: &sqlx::Error) -> bool {
1378 match error {
1379 sqlx::Error::Database(database) => database.code().as_deref() == Some("40001"),
1380 _ => false,
1381 }
1382 }
1383}
1384
1385#[cfg(feature = "cockroach")]
1386pub use cockroach::{
1387 CockroachPoolBuilder, CockroachPoolConfig, CockroachPoolProvider, CockroachRetryPolicy,
1388 CockroachTransactionError, CockroachTransactionFuture, CockroachTransactionResult,
1389};