1use sqlx::{
13 mysql::{MySqlPool, MySqlPoolOptions},
14 postgres::{PgPool, PgPoolOptions},
15 sqlite::{SqlitePool, SqlitePoolOptions},
16 Database, MySql, Pool, Postgres, Sqlite, Transaction,
17};
18use std::{
19 error::Error,
20 fmt,
21 future::Future,
22 hash::Hash,
23 sync::{
24 atomic::{AtomicU64, Ordering},
25 Mutex,
26 },
27 time::{Duration, Instant},
28};
29
30use crate::{
31 cache::jittered_ttl, CacheStats, CounterVec, HistogramOptions, HistogramVec, MemoryCache,
32 Metrics, MetricsError, SingleFlight, SingleFlightError, VectorOptions,
33};
34
35#[cfg(feature = "telemetry")]
36use crate::{TelemetrySpan, TelemetrySpanKind};
37
38#[derive(Debug)]
40pub enum SqlStoreError {
41 Database(sqlx::Error),
42 NotFound { entity: String },
43 InvalidBatchSize,
44}
45
46impl SqlStoreError {
47 pub fn not_found(entity: impl Into<String>) -> Self {
48 Self::NotFound {
49 entity: entity.into(),
50 }
51 }
52
53 pub fn is_not_found(&self) -> bool {
54 matches!(self, Self::NotFound { .. })
55 }
56}
57
58impl fmt::Display for SqlStoreError {
59 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match self {
61 Self::Database(error) => write!(formatter, "SQL operation failed: {error}"),
62 Self::NotFound { entity } => write!(formatter, "{entity} not found"),
63 Self::InvalidBatchSize => formatter.write_str("SQL bulk batch size must be positive"),
64 }
65 }
66}
67
68impl Error for SqlStoreError {
69 fn source(&self) -> Option<&(dyn Error + 'static)> {
70 match self {
71 Self::Database(error) => Some(error),
72 Self::NotFound { .. } | Self::InvalidBatchSize => None,
73 }
74 }
75}
76
77impl From<sqlx::Error> for SqlStoreError {
78 fn from(error: sqlx::Error) -> Self {
79 Self::Database(error)
80 }
81}
82
83#[derive(Clone)]
85pub struct SqlStoreMetrics {
86 operations: CounterVec,
87 duration: HistogramVec,
88}
89
90impl SqlStoreMetrics {
91 pub fn register(metrics: &Metrics) -> Result<Self, MetricsError> {
92 let labels = ["operation", "kind", "outcome"];
93 Ok(Self {
94 operations: metrics.counter_vec(
95 VectorOptions::new("operations_total", "Completed SQL store operations")
96 .with_namespace("rust_zero")
97 .with_subsystem("sql")
98 .with_labels(labels),
99 )?,
100 duration: metrics.histogram_vec(
101 HistogramOptions::new("operation_duration_seconds", "SQL store operation latency")
102 .with_vector_options(
103 VectorOptions::new(
104 "operation_duration_seconds",
105 "SQL store operation latency",
106 )
107 .with_namespace("rust_zero")
108 .with_subsystem("sql")
109 .with_labels(labels),
110 ),
111 )?,
112 })
113 }
114
115 fn observe(&self, operation: &str, kind: SqlOperationKind, outcome: &str, elapsed: Duration) {
116 let labels = [operation, kind.as_str(), outcome];
117 let _ = self.operations.inc(&labels);
120 let _ = self.duration.observe(elapsed.as_secs_f64(), &labels);
121 }
122}
123
124#[derive(Debug, Clone, Copy)]
125enum SqlOperationKind {
126 Query,
127 Execute,
128 BulkInsert,
129}
130
131impl SqlOperationKind {
132 fn as_str(self) -> &'static str {
133 match self {
134 Self::Query => "query",
135 Self::Execute => "execute",
136 Self::BulkInsert => "bulk_insert",
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct SqlStoreConfig {
143 pub url: String,
144 pub min_connections: u32,
145 pub max_connections: u32,
146 pub acquire_timeout: Duration,
147 pub idle_timeout: Option<Duration>,
148 pub max_lifetime: Option<Duration>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct SqlCacheConfig {
154 pub capacity: usize,
155 pub ttl: Duration,
156 pub not_found_ttl: Option<Duration>,
157 pub ttl_jitter: Duration,
158}
159
160impl SqlCacheConfig {
161 pub fn new(capacity: usize, ttl: Duration) -> Self {
162 assert!(capacity > 0, "SQL cache capacity must be positive");
163 assert!(!ttl.is_zero(), "SQL cache TTL must be positive");
164 Self {
165 capacity,
166 ttl,
167 not_found_ttl: Some(ttl),
168 ttl_jitter: Duration::ZERO,
169 }
170 }
171
172 pub fn with_not_found_ttl(mut self, ttl: Option<Duration>) -> Self {
174 assert!(
175 ttl.is_none_or(|ttl| !ttl.is_zero()),
176 "SQL not-found cache TTL must be positive"
177 );
178 self.not_found_ttl = ttl;
179 self
180 }
181
182 pub fn with_ttl_jitter(mut self, jitter: Duration) -> Self {
184 self.ttl_jitter = jitter;
185 self
186 }
187}
188
189impl SqlStoreConfig {
190 pub fn new(url: impl Into<String>) -> Self {
191 Self {
192 url: url.into(),
193 min_connections: 0,
194 max_connections: 10,
195 acquire_timeout: Duration::from_secs(3),
196 idle_timeout: Some(Duration::from_secs(10 * 60)),
197 max_lifetime: Some(Duration::from_secs(30 * 60)),
198 }
199 }
200
201 pub fn with_pool_size(mut self, min: u32, max: u32) -> Self {
202 assert!(max > 0, "SQL maximum pool size must be positive");
203 assert!(min <= max, "SQL minimum pool size cannot exceed maximum");
204 self.min_connections = min;
205 self.max_connections = max;
206 self
207 }
208
209 pub fn with_acquire_timeout(mut self, timeout: Duration) -> Self {
210 assert!(!timeout.is_zero(), "SQL acquire timeout must be positive");
211 self.acquire_timeout = timeout;
212 self
213 }
214}
215
216#[derive(Clone)]
221pub struct SqlStore<DB: Database> {
222 pool: Pool<DB>,
223 metrics: Option<SqlStoreMetrics>,
224}
225
226impl<DB: Database> fmt::Debug for SqlStore<DB> {
227 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
228 formatter
229 .debug_struct("SqlStore")
230 .field("pool", &self.pool)
231 .field("instrumented", &self.metrics.is_some())
232 .finish()
233 }
234}
235
236pub struct CachedSqlStore<DB, K, V, E, I = K>
243where
244 DB: Database,
245{
246 store: SqlStore<DB>,
247 cache: MemoryCache<K, Option<V>>,
248 indexes: MemoryCache<I, Option<K>>,
249 flights: SingleFlight<K, Option<V>, E>,
250 index_flights: SingleFlight<I, Option<(K, V)>, E>,
251 ttl: Duration,
252 not_found_ttl: Option<Duration>,
253 ttl_jitter: Duration,
254 expiry_sequence: AtomicU64,
255 generation: AtomicU64,
256 cache_gate: Mutex<()>,
257}
258
259impl<DB, K, V, E, I> CachedSqlStore<DB, K, V, E, I>
260where
261 DB: Database,
262 K: Clone + Eq + Hash,
263 I: Clone + Eq + Hash,
264 V: Clone,
265{
266 pub fn new(store: SqlStore<DB>, config: SqlCacheConfig) -> Self {
267 Self {
268 store,
269 cache: MemoryCache::new(config.capacity),
270 indexes: MemoryCache::new(config.capacity),
271 flights: SingleFlight::new(),
272 index_flights: SingleFlight::new(),
273 ttl: config.ttl,
274 not_found_ttl: config.not_found_ttl,
275 ttl_jitter: config.ttl_jitter,
276 expiry_sequence: AtomicU64::new(0),
277 generation: AtomicU64::new(0),
278 cache_gate: Mutex::new(()),
279 }
280 }
281
282 pub fn store(&self) -> &SqlStore<DB> {
283 &self.store
284 }
285
286 pub fn pool(&self) -> &Pool<DB> {
287 self.store.pool()
288 }
289
290 pub async fn find<F, Fut>(&self, key: K, query: F) -> Result<Option<V>, SingleFlightError<E>>
295 where
296 F: FnOnce(Pool<DB>) -> Fut,
297 Fut: Future<Output = Result<Option<V>, E>>,
298 {
299 if let Some(value) = self.cache.get(&key) {
300 return Ok(value);
301 }
302
303 self.flights
304 .execute(key.clone(), || async {
305 if let Some(value) = self.cache.get(&key) {
306 return Ok(value);
307 }
308
309 let generation = self.generation.load(Ordering::Acquire);
310 let value = query(self.store.pool().clone()).await?;
311
312 let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
315 if self.generation.load(Ordering::Acquire) == generation {
316 let base_ttl = if value.is_some() {
317 Some(self.ttl)
318 } else {
319 self.not_found_ttl
320 };
321 if let Some(base_ttl) = base_ttl {
322 let sequence = self.expiry_sequence.fetch_add(1, Ordering::Relaxed);
323 self.cache.insert(
324 key,
325 value.clone(),
326 jittered_ttl(base_ttl, self.ttl_jitter, sequence),
327 );
328 }
329 }
330 Ok(value)
331 })
332 .await
333 }
334
335 pub async fn find_by_index<F, Fut>(
341 &self,
342 index: I,
343 query: F,
344 ) -> Result<Option<V>, SingleFlightError<E>>
345 where
346 F: FnOnce(Pool<DB>) -> Fut,
347 Fut: Future<Output = Result<Option<(K, V)>, E>>,
348 {
349 if let Some(primary) = self.indexes.get(&index) {
350 match primary {
351 Some(primary) => {
352 if let Some(value) = self.cache.get(&primary) {
353 return Ok(value);
354 }
355 }
356 None => return Ok(None),
357 }
358 }
359
360 let loaded = self
361 .index_flights
362 .execute(index.clone(), || async {
363 if let Some(primary) = self.indexes.get(&index) {
364 match primary {
365 Some(primary) => {
366 if let Some(Some(value)) = self.cache.get(&primary) {
367 return Ok(Some((primary, value)));
368 }
369 }
370 None => return Ok(None),
371 }
372 }
373
374 let generation = self.generation.load(Ordering::Acquire);
375 let value = query(self.store.pool().clone()).await?;
376 let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
377 if self.generation.load(Ordering::Acquire) == generation {
378 let base_ttl = if value.is_some() {
379 Some(self.ttl)
380 } else {
381 self.not_found_ttl
382 };
383 if let Some(base_ttl) = base_ttl {
384 let sequence = self.expiry_sequence.fetch_add(1, Ordering::Relaxed);
385 let ttl = jittered_ttl(base_ttl, self.ttl_jitter, sequence);
386 let primary = value.as_ref().map(|(primary, _)| primary.clone());
387 self.indexes.insert(index, primary, ttl);
388 if let Some((primary, value)) = &value {
389 self.cache.insert(primary.clone(), Some(value.clone()), ttl);
390 }
391 }
392 }
393 Ok(value)
394 })
395 .await?;
396 Ok(loaded.map(|(_, value)| value))
397 }
398
399 pub async fn execute<PI, F, Fut, R>(&self, keys: PI, operation: F) -> Result<R, E>
401 where
402 PI: IntoIterator<Item = K>,
403 F: FnOnce(Pool<DB>) -> Fut,
404 Fut: Future<Output = Result<R, E>>,
405 {
406 let result = operation(self.store.pool().clone()).await?;
407 self.invalidate_many(keys);
408 Ok(result)
409 }
410
411 pub async fn execute_indexed<PI, SI, F, Fut, R>(
417 &self,
418 primary_keys: PI,
419 index_keys: SI,
420 operation: F,
421 ) -> Result<R, E>
422 where
423 PI: IntoIterator<Item = K>,
424 SI: IntoIterator<Item = I>,
425 F: FnOnce(Pool<DB>) -> Fut,
426 Fut: Future<Output = Result<R, E>>,
427 {
428 let result = operation(self.store.pool().clone()).await?;
429 self.invalidate_related(primary_keys, index_keys);
430 Ok(result)
431 }
432
433 pub fn invalidate(&self, key: &K) -> bool {
435 let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
436 self.generation.fetch_add(1, Ordering::AcqRel);
437 let removed = self.cache.remove(key).is_some();
438 self.indexes
439 .remove_where(|_, primary| primary.as_ref() == Some(key));
440 removed
441 }
442
443 pub fn invalidate_many<PI>(&self, keys: PI)
444 where
445 PI: IntoIterator<Item = K>,
446 {
447 let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
448 self.generation.fetch_add(1, Ordering::AcqRel);
449 let keys: std::collections::HashSet<_> = keys.into_iter().collect();
450 for key in &keys {
451 self.cache.remove(key);
452 }
453 self.indexes
454 .remove_where(|_, primary| primary.as_ref().is_some_and(|key| keys.contains(key)));
455 }
456
457 pub fn invalidate_index(&self, index: &I) -> bool {
458 let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
459 self.generation.fetch_add(1, Ordering::AcqRel);
460 self.indexes.remove(index).is_some()
461 }
462
463 pub fn invalidate_related<PI, SI>(&self, primary_keys: PI, index_keys: SI)
464 where
465 PI: IntoIterator<Item = K>,
466 SI: IntoIterator<Item = I>,
467 {
468 let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
469 self.generation.fetch_add(1, Ordering::AcqRel);
470 let primary_keys: std::collections::HashSet<_> = primary_keys.into_iter().collect();
471 for key in &primary_keys {
472 self.cache.remove(key);
473 }
474 self.indexes.remove_where(|_, primary| {
475 primary
476 .as_ref()
477 .is_some_and(|key| primary_keys.contains(key))
478 });
479 for index in index_keys {
480 self.indexes.remove(&index);
481 }
482 }
483
484 pub fn cache_stats(&self) -> CacheStats {
485 self.cache.stats()
486 }
487
488 pub fn index_cache_stats(&self) -> CacheStats {
489 self.indexes.stats()
490 }
491}
492
493impl<DB: Database> SqlStore<DB> {
494 pub fn from_pool(pool: Pool<DB>) -> Self {
495 Self {
496 pool,
497 metrics: None,
498 }
499 }
500
501 pub fn with_metrics(mut self, metrics: SqlStoreMetrics) -> Self {
503 self.metrics = Some(metrics);
504 self
505 }
506
507 pub fn pool(&self) -> &Pool<DB> {
508 &self.pool
509 }
510
511 pub async fn begin(&self) -> Result<Transaction<'static, DB>, sqlx::Error> {
512 self.pool.begin().await
513 }
514
515 pub async fn query<T, F, Fut>(
517 &self,
518 operation: &'static str,
519 query: F,
520 ) -> Result<T, SqlStoreError>
521 where
522 F: FnOnce(Pool<DB>) -> Fut,
523 Fut: Future<Output = Result<T, sqlx::Error>>,
524 {
525 let pool = self.pool.clone();
526 self.instrument(operation, SqlOperationKind::Query, async move {
527 query(pool).await.map_err(SqlStoreError::Database)
528 })
529 .await
530 }
531
532 pub async fn query_one<T, F, Fut>(
534 &self,
535 operation: &'static str,
536 entity: impl Into<String>,
537 query: F,
538 ) -> Result<T, SqlStoreError>
539 where
540 F: FnOnce(Pool<DB>) -> Fut,
541 Fut: Future<Output = Result<Option<T>, sqlx::Error>>,
542 {
543 let pool = self.pool.clone();
544 let entity = entity.into();
545 self.instrument(operation, SqlOperationKind::Query, async move {
546 query(pool)
547 .await
548 .map_err(SqlStoreError::Database)?
549 .ok_or_else(|| SqlStoreError::not_found(entity))
550 })
551 .await
552 }
553
554 pub async fn execute<T, F, Fut>(
556 &self,
557 operation: &'static str,
558 execute: F,
559 ) -> Result<T, SqlStoreError>
560 where
561 F: FnOnce(Pool<DB>) -> Fut,
562 Fut: Future<Output = Result<T, sqlx::Error>>,
563 {
564 let pool = self.pool.clone();
565 self.instrument(operation, SqlOperationKind::Execute, async move {
566 execute(pool).await.map_err(SqlStoreError::Database)
567 })
568 .await
569 }
570
571 pub async fn bulk_insert<T, R, F, Fut>(
577 &self,
578 operation: &'static str,
579 items: impl IntoIterator<Item = T>,
580 batch_size: usize,
581 mut insert_batch: F,
582 ) -> Result<Vec<R>, SqlStoreError>
583 where
584 F: FnMut(Pool<DB>, Vec<T>) -> Fut,
585 Fut: Future<Output = Result<R, sqlx::Error>>,
586 {
587 if batch_size == 0 {
588 return Err(SqlStoreError::InvalidBatchSize);
589 }
590
591 let started = Instant::now();
592 #[cfg(feature = "telemetry")]
593 let span = TelemetrySpan::start(
594 format!("sql.{operation}"),
595 TelemetrySpanKind::Client,
596 None,
597 [
598 ("db.operation.name", operation.to_owned()),
599 (
600 "rust_zero.sql.kind",
601 SqlOperationKind::BulkInsert.as_str().to_owned(),
602 ),
603 ],
604 );
605 let mut results = Vec::new();
606 let mut batch = Vec::with_capacity(batch_size);
607 let mut items = items.into_iter();
608 let result = loop {
609 batch.extend(items.by_ref().take(batch_size));
610 if batch.is_empty() {
611 break Ok(results);
612 }
613 let current = std::mem::replace(&mut batch, Vec::with_capacity(batch_size));
614 match insert_batch(self.pool.clone(), current).await {
615 Ok(result) => results.push(result),
616 Err(error) => break Err(SqlStoreError::Database(error)),
617 }
618 };
619 let outcome = sql_outcome(&result);
620 if let Some(metrics) = &self.metrics {
621 metrics.observe(
622 operation,
623 SqlOperationKind::BulkInsert,
624 outcome,
625 started.elapsed(),
626 );
627 }
628 #[cfg(feature = "telemetry")]
629 if let Err(error) = &result {
630 span.set_error(error.to_string());
631 }
632 result
633 }
634
635 async fn instrument<T, Fut>(
636 &self,
637 operation: &'static str,
638 kind: SqlOperationKind,
639 future: Fut,
640 ) -> Result<T, SqlStoreError>
641 where
642 Fut: Future<Output = Result<T, SqlStoreError>>,
643 {
644 let started = Instant::now();
645 #[cfg(feature = "telemetry")]
646 let span = TelemetrySpan::start(
647 format!("sql.{operation}"),
648 TelemetrySpanKind::Client,
649 None,
650 [
651 ("db.operation.name", operation.to_owned()),
652 ("rust_zero.sql.kind", kind.as_str().to_owned()),
653 ],
654 );
655 let result = future.await;
656 let outcome = sql_outcome(&result);
657 if let Some(metrics) = &self.metrics {
658 metrics.observe(operation, kind, outcome, started.elapsed());
659 }
660 #[cfg(feature = "telemetry")]
661 if let Err(error) = &result {
662 span.set_error(error.to_string());
663 }
664 result
665 }
666
667 pub async fn close(&self) {
668 self.pool.close().await;
669 }
670}
671
672fn sql_outcome<T>(result: &Result<T, SqlStoreError>) -> &'static str {
673 match result {
674 Ok(_) => "success",
675 Err(SqlStoreError::NotFound { .. }) => "not_found",
676 Err(SqlStoreError::Database(_) | SqlStoreError::InvalidBatchSize) => "error",
677 }
678}
679
680impl SqlStore<Sqlite> {
681 pub async fn connect_sqlite(config: SqlStoreConfig) -> Result<Self, sqlx::Error> {
682 let pool = configure_sqlite(&config).connect(&config.url).await?;
683 Ok(Self::from_pool(pool))
684 }
685
686 pub async fn health_check(&self) -> Result<(), sqlx::Error> {
687 sqlx::query("SELECT 1").execute(&self.pool).await?;
688 Ok(())
689 }
690}
691
692impl SqlStore<Postgres> {
693 pub async fn connect_postgres(config: SqlStoreConfig) -> Result<Self, sqlx::Error> {
694 let pool = configure_postgres(&config).connect(&config.url).await?;
695 Ok(Self::from_pool(pool))
696 }
697
698 pub async fn health_check(&self) -> Result<(), sqlx::Error> {
699 sqlx::query("SELECT 1").execute(&self.pool).await?;
700 Ok(())
701 }
702}
703
704impl SqlStore<MySql> {
705 pub async fn connect_mysql(config: SqlStoreConfig) -> Result<Self, sqlx::Error> {
706 let pool = configure_mysql(&config).connect(&config.url).await?;
707 Ok(Self::from_pool(pool))
708 }
709
710 pub async fn health_check(&self) -> Result<(), sqlx::Error> {
711 sqlx::query("SELECT 1").execute(&self.pool).await?;
712 Ok(())
713 }
714}
715
716fn configure_sqlite(config: &SqlStoreConfig) -> SqlitePoolOptions {
717 SqlitePoolOptions::new()
718 .min_connections(config.min_connections)
719 .max_connections(config.max_connections)
720 .acquire_timeout(config.acquire_timeout)
721 .idle_timeout(config.idle_timeout)
722 .max_lifetime(config.max_lifetime)
723}
724
725fn configure_postgres(config: &SqlStoreConfig) -> PgPoolOptions {
726 PgPoolOptions::new()
727 .min_connections(config.min_connections)
728 .max_connections(config.max_connections)
729 .acquire_timeout(config.acquire_timeout)
730 .idle_timeout(config.idle_timeout)
731 .max_lifetime(config.max_lifetime)
732}
733
734fn configure_mysql(config: &SqlStoreConfig) -> MySqlPoolOptions {
735 MySqlPoolOptions::new()
736 .min_connections(config.min_connections)
737 .max_connections(config.max_connections)
738 .acquire_timeout(config.acquire_timeout)
739 .idle_timeout(config.idle_timeout)
740 .max_lifetime(config.max_lifetime)
741}
742
743pub type SqliteStore = SqlStore<Sqlite>;
744pub type PostgresStore = SqlStore<Postgres>;
745pub type MySqlStore = SqlStore<MySql>;
746
747const _: Option<(SqlitePool, PgPool, MySqlPool)> = None;
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753 use sqlx::Row;
754 use std::sync::{
755 atomic::{AtomicUsize, Ordering},
756 Arc,
757 };
758 use tokio::sync::Notify;
759
760 #[tokio::test]
761 async fn sqlite_supports_queries_and_commit_or_rollback_transactions() {
762 let store = SqliteStore::connect_sqlite(
763 SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
764 )
765 .await
766 .unwrap();
767 store.health_check().await.unwrap();
768 sqlx::query("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
769 .execute(store.pool())
770 .await
771 .unwrap();
772
773 let mut transaction = store.begin().await.unwrap();
774 sqlx::query("INSERT INTO users (name) VALUES (?)")
775 .bind("Ada")
776 .execute(&mut *transaction)
777 .await
778 .unwrap();
779 transaction.commit().await.unwrap();
780
781 let row = sqlx::query("SELECT name FROM users")
782 .fetch_one(store.pool())
783 .await
784 .unwrap();
785 assert_eq!(row.try_get::<String, _>("name").unwrap(), "Ada");
786
787 let mut transaction = store.begin().await.unwrap();
788 sqlx::query("DELETE FROM users")
789 .execute(&mut *transaction)
790 .await
791 .unwrap();
792 transaction.rollback().await.unwrap();
793 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
794 .fetch_one(store.pool())
795 .await
796 .unwrap();
797 assert_eq!(count, 1);
798 }
799
800 #[tokio::test]
801 async fn typed_helpers_batch_inserts_standardize_not_found_and_emit_metrics() {
802 let registry = Metrics::new();
803 let metrics = SqlStoreMetrics::register(®istry).unwrap();
804 let store = SqliteStore::connect_sqlite(
805 SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
806 )
807 .await
808 .unwrap()
809 .with_metrics(metrics);
810
811 store
812 .execute("create_users", |pool| async move {
813 sqlx::query("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
814 .execute(&pool)
815 .await
816 })
817 .await
818 .unwrap();
819
820 let batches = store
821 .bulk_insert(
822 "insert_users",
823 (1_i64..=5).map(|id| (id, format!("user-{id}"))),
824 2,
825 |pool, users| async move {
826 let mut query =
827 sqlx::QueryBuilder::<Sqlite>::new("INSERT INTO users (id, name) ");
828 query.push_values(users, |mut row, (id, name)| {
829 row.push_bind(id).push_bind(name);
830 });
831 query
832 .build()
833 .execute(&pool)
834 .await
835 .map(|result| result.rows_affected())
836 },
837 )
838 .await
839 .unwrap();
840 assert_eq!(batches, vec![2, 2, 1]);
841
842 let name: String = store
843 .query_one("find_user", "user", |pool| async move {
844 sqlx::query_scalar("SELECT name FROM users WHERE id = ?")
845 .bind(3_i64)
846 .fetch_optional(&pool)
847 .await
848 })
849 .await
850 .unwrap();
851 assert_eq!(name, "user-3");
852
853 let missing = store
854 .query_one::<String, _, _>("find_user", "user", |pool| async move {
855 sqlx::query_scalar("SELECT name FROM users WHERE id = ?")
856 .bind(404_i64)
857 .fetch_optional(&pool)
858 .await
859 })
860 .await
861 .unwrap_err();
862 assert!(missing.is_not_found());
863 assert_eq!(missing.to_string(), "user not found");
864
865 let rendered = registry.render();
866 assert!(rendered.contains(
867 "rust_zero_sql_operations_total{operation=\"insert_users\",kind=\"bulk_insert\",outcome=\"success\"} 1"
868 ));
869 assert!(rendered.contains(
870 "rust_zero_sql_operations_total{operation=\"find_user\",kind=\"query\",outcome=\"not_found\"} 1"
871 ));
872 assert!(matches!(
873 store
874 .bulk_insert::<i64, (), _, _>("invalid", [], 0, |_pool, _items| async { Ok(()) })
875 .await,
876 Err(SqlStoreError::InvalidBatchSize)
877 ));
878 }
879
880 #[test]
881 #[should_panic(expected = "minimum pool size")]
882 fn rejects_invalid_pool_bounds() {
883 let _ = SqlStoreConfig::new("sqlite::memory:").with_pool_size(2, 1);
884 }
885
886 #[tokio::test]
887 async fn cached_store_caches_records_and_missing_rows_then_invalidates_mutations() {
888 let store = SqliteStore::connect_sqlite(
889 SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
890 )
891 .await
892 .unwrap();
893 sqlx::query("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
894 .execute(store.pool())
895 .await
896 .unwrap();
897 sqlx::query("INSERT INTO users (id, name) VALUES (1, 'Ada')")
898 .execute(store.pool())
899 .await
900 .unwrap();
901
902 let cached = CachedSqlStore::<Sqlite, i64, String, sqlx::Error>::new(
903 store,
904 SqlCacheConfig::new(10, Duration::from_secs(30)),
905 );
906 let queries = AtomicUsize::new(0);
907
908 for _ in 0..2 {
909 let queries = &queries;
910 let name = cached
911 .find(1, move |pool| async move {
912 queries.fetch_add(1, Ordering::SeqCst);
913 sqlx::query_scalar("SELECT name FROM users WHERE id = ?")
914 .bind(1_i64)
915 .fetch_optional(&pool)
916 .await
917 })
918 .await
919 .unwrap();
920 assert_eq!(name.as_deref(), Some("Ada"));
921 }
922 for _ in 0..2 {
923 let queries = &queries;
924 assert_eq!(
925 cached
926 .find(404, move |pool| async move {
927 queries.fetch_add(1, Ordering::SeqCst);
928 sqlx::query_scalar("SELECT name FROM users WHERE id = ?")
929 .bind(404_i64)
930 .fetch_optional(&pool)
931 .await
932 })
933 .await
934 .unwrap(),
935 None
936 );
937 }
938 assert_eq!(queries.load(Ordering::SeqCst), 2);
939
940 cached
941 .execute([1], |pool| async move {
942 sqlx::query("UPDATE users SET name = 'Grace' WHERE id = 1")
943 .execute(&pool)
944 .await
945 })
946 .await
947 .unwrap();
948 let name = cached
949 .find(1, |pool| async move {
950 sqlx::query_scalar("SELECT name FROM users WHERE id = 1")
951 .fetch_optional(&pool)
952 .await
953 })
954 .await
955 .unwrap();
956 assert_eq!(name.as_deref(), Some("Grace"));
957 }
958
959 #[tokio::test]
960 async fn secondary_indexes_share_primary_records_and_are_invalidated_with_mutations() {
961 let store = SqliteStore::connect_sqlite(
962 SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
963 )
964 .await
965 .unwrap();
966 sqlx::query(
967 "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, name TEXT NOT NULL)",
968 )
969 .execute(store.pool())
970 .await
971 .unwrap();
972 sqlx::query("INSERT INTO users (id, email, name) VALUES (1, 'ada@test', 'Ada')")
973 .execute(store.pool())
974 .await
975 .unwrap();
976
977 let cached = CachedSqlStore::<Sqlite, i64, String, sqlx::Error, String>::new(
978 store,
979 SqlCacheConfig::new(10, Duration::from_secs(30)),
980 );
981 let queries = AtomicUsize::new(0);
982
983 for _ in 0..2 {
984 let queries = &queries;
985 let user = cached
986 .find_by_index("ada@test".to_owned(), move |pool| async move {
987 queries.fetch_add(1, Ordering::SeqCst);
988 sqlx::query_as::<_, (i64, String)>(
989 "SELECT id, name FROM users WHERE email = 'ada@test'",
990 )
991 .fetch_optional(&pool)
992 .await
993 })
994 .await
995 .unwrap();
996 assert_eq!(user.as_deref(), Some("Ada"));
997 }
998 assert_eq!(queries.load(Ordering::SeqCst), 1);
999
1000 let primary = cached
1002 .find(1, |_pool| async { Ok(Some("uncached".to_owned())) })
1003 .await
1004 .unwrap();
1005 assert_eq!(primary.as_deref(), Some("Ada"));
1006
1007 assert!(cached
1009 .find_by_index("grace@test".to_owned(), |pool| async move {
1010 sqlx::query_as::<_, (i64, String)>(
1011 "SELECT id, name FROM users WHERE email = 'grace@test'",
1012 )
1013 .fetch_optional(&pool)
1014 .await
1015 })
1016 .await
1017 .unwrap()
1018 .is_none());
1019
1020 cached
1021 .execute_indexed(
1022 [1],
1023 ["ada@test".to_owned(), "grace@test".to_owned()],
1024 |pool| async move {
1025 sqlx::query(
1026 "UPDATE users SET email = 'grace@test', name = 'Grace' WHERE id = 1",
1027 )
1028 .execute(&pool)
1029 .await
1030 },
1031 )
1032 .await
1033 .unwrap();
1034
1035 let old = cached
1036 .find_by_index("ada@test".to_owned(), |pool| async move {
1037 sqlx::query_as::<_, (i64, String)>(
1038 "SELECT id, name FROM users WHERE email = 'ada@test'",
1039 )
1040 .fetch_optional(&pool)
1041 .await
1042 })
1043 .await
1044 .unwrap();
1045 assert_eq!(old, None);
1046 let renamed = cached
1047 .find_by_index("grace@test".to_owned(), |pool| async move {
1048 sqlx::query_as::<_, (i64, String)>(
1049 "SELECT id, name FROM users WHERE email = 'grace@test'",
1050 )
1051 .fetch_optional(&pool)
1052 .await
1053 })
1054 .await
1055 .unwrap();
1056 assert_eq!(renamed.as_deref(), Some("Grace"));
1057 assert!(cached.index_cache_stats().insertions >= 4);
1058 }
1059
1060 #[tokio::test]
1061 async fn cached_store_can_disable_or_shorten_not_found_caching() {
1062 let store = SqliteStore::connect_sqlite(
1063 SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
1064 )
1065 .await
1066 .unwrap();
1067 let cached = CachedSqlStore::<Sqlite, i64, String, sqlx::Error>::new(
1068 store,
1069 SqlCacheConfig::new(10, Duration::from_secs(30))
1070 .with_not_found_ttl(None)
1071 .with_ttl_jitter(Duration::from_secs(5)),
1072 );
1073 let queries = AtomicUsize::new(0);
1074
1075 for _ in 0..2 {
1076 let queries = &queries;
1077 let value = cached
1078 .find(404, move |_pool| async move {
1079 queries.fetch_add(1, Ordering::SeqCst);
1080 Ok(None)
1081 })
1082 .await
1083 .unwrap();
1084 assert_eq!(value, None);
1085 }
1086
1087 assert_eq!(queries.load(Ordering::SeqCst), 2);
1088 assert_eq!(cached.cache_stats().insertions, 0);
1089 }
1090
1091 #[tokio::test]
1092 async fn mutation_prevents_an_inflight_read_from_repopulating_stale_data() {
1093 let store = SqliteStore::connect_sqlite(
1094 SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
1095 )
1096 .await
1097 .unwrap();
1098 let cached = Arc::new(CachedSqlStore::<Sqlite, i64, String, sqlx::Error>::new(
1099 store,
1100 SqlCacheConfig::new(10, Duration::from_secs(30)),
1101 ));
1102 let query_started = Arc::new(Notify::new());
1103 let release_query = Arc::new(Notify::new());
1104
1105 let stale_read = {
1106 let cached = Arc::clone(&cached);
1107 let query_started = Arc::clone(&query_started);
1108 let release_query = Arc::clone(&release_query);
1109 tokio::spawn(async move {
1110 cached
1111 .find(1, move |_pool| async move {
1112 query_started.notify_one();
1113 release_query.notified().await;
1114 Ok(Some("stale".to_owned()))
1115 })
1116 .await
1117 })
1118 };
1119 query_started.notified().await;
1120 cached
1121 .execute([1], |_pool| async { Ok::<_, sqlx::Error>(()) })
1122 .await
1123 .unwrap();
1124 release_query.notify_one();
1125 assert_eq!(stale_read.await.unwrap().unwrap().as_deref(), Some("stale"));
1126 assert_eq!(cached.cache.get(&1), None);
1127 }
1128
1129 #[tokio::test]
1130 async fn postgres_integration_covers_health_crud_and_transactions() {
1131 let Ok(url) = std::env::var("RUST_ZERO_POSTGRES_URL") else {
1132 return;
1133 };
1134 let store = PostgresStore::connect_postgres(SqlStoreConfig::new(url).with_pool_size(1, 1))
1135 .await
1136 .unwrap();
1137 store.health_check().await.unwrap();
1138 sqlx::query(
1139 "CREATE TEMPORARY TABLE rust_zero_users (id BIGINT PRIMARY KEY, name TEXT NOT NULL)",
1140 )
1141 .execute(store.pool())
1142 .await
1143 .unwrap();
1144 let mut transaction = store.begin().await.unwrap();
1145 sqlx::query("INSERT INTO rust_zero_users (id, name) VALUES ($1, $2)")
1146 .bind(1_i64)
1147 .bind("Ada")
1148 .execute(&mut *transaction)
1149 .await
1150 .unwrap();
1151 transaction.commit().await.unwrap();
1152 let name: String = sqlx::query_scalar("SELECT name FROM rust_zero_users WHERE id = $1")
1153 .bind(1_i64)
1154 .fetch_one(store.pool())
1155 .await
1156 .unwrap();
1157 assert_eq!(name, "Ada");
1158 }
1159
1160 #[tokio::test]
1161 async fn mysql_integration_covers_health_crud_and_transactions() {
1162 let Ok(url) = std::env::var("RUST_ZERO_MYSQL_URL") else {
1163 return;
1164 };
1165 let store = MySqlStore::connect_mysql(SqlStoreConfig::new(url).with_pool_size(1, 1))
1166 .await
1167 .unwrap();
1168 store.health_check().await.unwrap();
1169 sqlx::query(
1170 "CREATE TEMPORARY TABLE rust_zero_users (id BIGINT PRIMARY KEY, name TEXT NOT NULL)",
1171 )
1172 .execute(store.pool())
1173 .await
1174 .unwrap();
1175 let mut transaction = store.begin().await.unwrap();
1176 sqlx::query("INSERT INTO rust_zero_users (id, name) VALUES (?, ?)")
1177 .bind(1_i64)
1178 .bind("Ada")
1179 .execute(&mut *transaction)
1180 .await
1181 .unwrap();
1182 transaction.commit().await.unwrap();
1183 let name: String = sqlx::query_scalar("SELECT name FROM rust_zero_users WHERE id = ?")
1184 .bind(1_i64)
1185 .fetch_one(store.pool())
1186 .await
1187 .unwrap();
1188 assert_eq!(name, "Ada");
1189 }
1190}