uri_register/postgres.rs
1// Copyright TELICENT LTD
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::cache::{create_cache, Cache, CacheStrategy};
16use crate::error::{ConfigurationError, Result};
17use crate::service::UriService;
18use async_trait::async_trait;
19use deadpool_postgres::{ManagerConfig, Pool, RecyclingMethod, Runtime};
20use rustls::RootCertStore;
21use rustls_pki_types::pem::PemObject;
22use std::sync::Arc;
23use tokio_postgres::{Config, NoTls};
24use tokio_postgres_rustls::MakeRustlsConnect;
25use tracing::{debug, info, instrument, trace, warn};
26use url::Url;
27
28/// PostgreSQL-based URI register implementation with configurable caching
29///
30/// This implementation uses a PostgreSQL table to store URI-to-ID mappings
31/// with an in-memory cache (W-TinyLFU by default, or LRU) to reduce database round-trips.
32/// It's designed for high concurrency with connection pooling and batch operations.
33///
34/// ## Prerequisites
35///
36/// The database schema must be initialized before using this service.
37/// See `schema.sql` for the DDL statements.
38///
39/// ## URI Validation
40///
41/// All URIs are validated before registration to ensure they conform to RFC 3986.
42/// Invalid URIs will return an error.
43///
44/// ## Performance
45///
46/// With default logged tables on typical hardware:
47/// - Batch insert: ~10K-50K URIs/sec
48/// - Batch lookup (cached): ~100K-1M+ URIs/sec (no DB round-trip)
49/// - Batch lookup (uncached): ~100K-200K URIs/sec
50/// - Query overhead: ~2-10ms per query (2 round-trips)
51///
52/// The cache (W-TinyLFU or LRU) significantly improves performance for repeated URI lookups.
53/// Cache strategy and size are configurable when creating the register instance.
54///
55/// For faster writes at the cost of durability, the table can be configured
56/// as UNLOGGED (see `schema.sql` for options).
57pub struct PostgresUriRegister {
58 pool: Pool,
59 /// Cache for URI-to-ID mappings (W-TinyLFU or LRU)
60 cache: Arc<dyn Cache>,
61 /// Name of the database table to use
62 table_name: String,
63}
64
65impl PostgresUriRegister {
66 /// Create a new PostgreSQL URI register service with configurable cache
67 ///
68 /// # Arguments
69 ///
70 /// * `database_url` - PostgreSQL connection string (e.g., "postgres://user:password@host:port/database")
71 /// * `table_name` - Name of the database table to use (must be a valid SQL identifier, default: "uri_register")
72 /// * `max_connections` - Maximum number of connections in the pool (recommended: 10-50)
73 /// * `cache_size` - Number of URI-to-ID mappings to cache in memory (recommended: 1,000-100,000)
74 /// * `cache_strategy` - Cache strategy to use (Moka/W-TinyLFU is default and recommended for most workloads)
75 ///
76 /// # Prerequisites
77 ///
78 /// The database schema must be initialized before using this service.
79 /// See the `schema.sql` file and README.md for setup instructions.
80 ///
81 /// # Example
82 ///
83 /// ```rust,no_run
84 /// use uri_register::PostgresUriRegister;
85 ///
86 /// #[tokio::main]
87 /// async fn main() -> uri_register::Result<()> {
88 /// let register = PostgresUriRegister::new(
89 /// "postgres://localhost/mydb",
90 /// "uri_register", // table name
91 /// 20, // max connections
92 /// 10_000 // cache size (defaults to Moka/W-TinyLFU)
93 /// ).await?;
94 /// Ok(())
95 /// }
96 /// ```
97 pub async fn new(
98 database_url: &str,
99 table_name: &str,
100 max_connections: u32,
101 cache_size: usize,
102 ) -> Result<Self> {
103 Self::new_with_cache_strategy(
104 database_url,
105 table_name,
106 max_connections,
107 cache_size,
108 None, // Default to Moka
109 None, // Default to no TLS
110 None, // No custom CA cert
111 )
112 .await
113 }
114
115 /// Create a new PostgreSQL URI register with a specific cache strategy and TLS
116 ///
117 /// This is identical to `new()` but allows specifying a cache strategy and TLS option.
118 /// Most users should use `new()` which defaults to the recommended Moka (W-TinyLFU) cache and no TLS.
119 ///
120 /// # Arguments
121 ///
122 /// * `cache_strategy` - Optional cache strategy (None = Moka default, or specify CacheStrategy::Lru)
123 /// * `use_tls` - Optional TLS flag (None/false = no TLS, true = TLS with webpki root certificates)
124 /// * `ca_cert_path` - Optional path to a PEM-encoded CA certificate file for verifying
125 /// connections to servers using certificates signed by a private/internal CA.
126 /// When provided, `use_tls` is automatically enabled.
127 ///
128 /// # Example
129 ///
130 /// ```rust,no_run
131 /// use uri_register::{CacheStrategy, PostgresUriRegister};
132 ///
133 /// #[tokio::main]
134 /// async fn main() -> uri_register::Result<()> {
135 /// // Use LRU instead of default Moka, with TLS enabled
136 /// let register = PostgresUriRegister::new_with_cache_strategy(
137 /// "postgres://localhost/mydb",
138 /// "uri_register",
139 /// 20,
140 /// 10_000,
141 /// Some(CacheStrategy::Lru),
142 /// Some(true), // Enable TLS
143 /// None, // No custom CA cert
144 /// ).await?;
145 /// Ok(())
146 /// }
147 /// ```
148 pub async fn new_with_cache_strategy(
149 database_url: &str,
150 table_name: &str,
151 max_connections: u32,
152 cache_size: usize,
153 cache_strategy: Option<CacheStrategy>,
154 use_tls: Option<bool>,
155 ca_cert_path: Option<&str>,
156 ) -> Result<Self> {
157 // Validate inputs
158 if cache_size == 0 {
159 return Err(ConfigurationError::InvalidCacheSize(cache_size).into());
160 }
161
162 if max_connections == 0 {
163 return Err(ConfigurationError::InvalidMaxConnections(max_connections).into());
164 }
165
166 // Validate table name as SQL identifier
167 Self::validate_table_name(table_name)?;
168
169 // Parse the database URL into tokio-postgres Config
170 let pg_config: Config = database_url.parse().map_err(|e| {
171 ConfigurationError::InvalidBackoff(format!("Failed to parse database URL: {}", e))
172 })?;
173
174 // Create deadpool configuration
175 let mut cfg = deadpool_postgres::Config::new();
176 cfg.dbname = pg_config.get_dbname().map(|s| s.to_string());
177 cfg.host = pg_config.get_hosts().first().map(|h| match h {
178 tokio_postgres::config::Host::Tcp(s) => s.to_string(),
179 #[cfg(unix)]
180 tokio_postgres::config::Host::Unix(p) => p.to_str().unwrap_or_default().to_string(),
181 });
182 cfg.port = pg_config.get_ports().first().copied();
183 cfg.user = pg_config.get_user().map(|s| s.to_string());
184 cfg.password = pg_config
185 .get_password()
186 .map(|p| std::str::from_utf8(p).unwrap_or_default().to_string());
187 cfg.manager = Some(ManagerConfig {
188 recycling_method: RecyclingMethod::Fast,
189 });
190 cfg.pool = Some(deadpool_postgres::PoolConfig {
191 max_size: max_connections as usize,
192 timeouts: deadpool_postgres::Timeouts {
193 wait: Some(std::time::Duration::from_secs(10)),
194 create: Some(std::time::Duration::from_secs(10)),
195 recycle: Some(std::time::Duration::from_secs(10)),
196 },
197 ..Default::default()
198 });
199
200 // If a CA cert path is provided, TLS is implicitly enabled
201 let effective_tls = use_tls.unwrap_or(false) || ca_cert_path.is_some();
202
203 // Security logging for connection configuration
204 if !effective_tls {
205 warn!(
206 "TLS is DISABLED for database connection - data will be transmitted in plaintext. \
207 This is not recommended for production environments."
208 );
209 }
210
211 if let Some(password) = pg_config.get_password() {
212 if password.is_empty() {
213 warn!("Database connection configured with an empty password.");
214 }
215 } else {
216 warn!("Database connection configured without a password.");
217 }
218
219 let pool = if effective_tls {
220 let mut root_store = RootCertStore::empty();
221
222 // Always include the public webpki root certificates
223 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
224
225 // Load custom CA certificate if provided (for private/internal CAs)
226 if let Some(cert_path) = ca_cert_path {
227 info!(
228 ca_cert_path = cert_path,
229 "Loading custom CA certificate for TLS verification"
230 );
231
232 let pem_data = std::fs::read(cert_path).map_err(|e| {
233 ConfigurationError::InvalidBackoff(format!(
234 "Failed to read CA certificate file '{}': {}",
235 cert_path, e
236 ))
237 })?;
238
239 let certs: Vec<_> = rustls_pki_types::CertificateDer::pem_slice_iter(&pem_data)
240 .collect::<std::result::Result<Vec<_>, _>>()
241 .map_err(|e| {
242 ConfigurationError::InvalidBackoff(format!(
243 "Failed to parse PEM certificates from '{}': {}",
244 cert_path, e
245 ))
246 })?;
247
248 if certs.is_empty() {
249 return Err(ConfigurationError::InvalidBackoff(format!(
250 "No valid certificates found in CA certificate file '{}'",
251 cert_path
252 ))
253 .into());
254 }
255
256 warn!(
257 cert_count = certs.len(),
258 ca_cert_path = cert_path,
259 "Custom CA certificate(s) loaded - connections will trust certificates signed \
260 by this CA in addition to public CAs. Ensure this CA certificate is from a \
261 trusted source."
262 );
263
264 let (added, _ignored) = root_store.add_parsable_certificates(certs);
265 if added == 0 {
266 return Err(ConfigurationError::InvalidBackoff(format!(
267 "None of the certificates in '{}' could be added to the trust store",
268 cert_path
269 ))
270 .into());
271 }
272 info!(
273 added_certs = added,
274 "Custom CA certificates added to trust store"
275 );
276 }
277
278 let tls_config = rustls::ClientConfig::builder()
279 .with_root_certificates(root_store)
280 .with_no_client_auth();
281
282 let tls = MakeRustlsConnect::new(tls_config);
283
284 cfg.create_pool(Some(Runtime::Tokio1), tls).map_err(|e| {
285 ConfigurationError::InvalidBackoff(format!(
286 "Failed to create connection pool with TLS: {}",
287 e
288 ))
289 })?
290 } else {
291 cfg.create_pool(Some(Runtime::Tokio1), NoTls).map_err(|e| {
292 ConfigurationError::InvalidBackoff(format!(
293 "Failed to create connection pool: {}",
294 e
295 ))
296 })?
297 };
298
299 let cache = create_cache(cache_strategy.unwrap_or_default(), cache_size);
300
301 info!(
302 table = table_name,
303 max_connections,
304 cache_size,
305 tls = effective_tls,
306 custom_ca = ca_cert_path.is_some(),
307 "URI register connected"
308 );
309
310 Ok(Self {
311 pool,
312 cache,
313 table_name: table_name.to_string(),
314 })
315 }
316
317 /// Validate that a table name is a valid SQL identifier
318 ///
319 /// Prevents SQL injection by ensuring the table name only contains
320 /// alphanumeric characters and underscores, and doesn't start with a digit.
321 fn validate_table_name(name: &str) -> Result<()> {
322 if name.is_empty() {
323 return Err(ConfigurationError::InvalidTableName(
324 "table name cannot be empty".to_string(),
325 )
326 .into());
327 }
328
329 if name.len() > 63 {
330 return Err(ConfigurationError::InvalidTableName(format!(
331 "table name too long (max 63 characters): '{}'",
332 name
333 ))
334 .into());
335 }
336
337 // First character must be a letter or underscore
338 let first_char = name.chars().next().unwrap();
339 if !first_char.is_ascii_alphabetic() && first_char != '_' {
340 return Err(ConfigurationError::InvalidTableName(format!(
341 "table name must start with a letter or underscore: '{}'",
342 name
343 ))
344 .into());
345 }
346
347 // All characters must be alphanumeric or underscore
348 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
349 return Err(ConfigurationError::InvalidTableName(format!(
350 "table name can only contain letters, numbers, and underscores: '{}'",
351 name
352 ))
353 .into());
354 }
355
356 Ok(())
357 }
358
359 /// Get statistics about the URI register
360 ///
361 /// Returns the total number of URIs and the storage size.
362 ///
363 /// # Example
364 ///
365 /// ```rust,no_run
366 /// use uri_register::PostgresUriRegister;
367 ///
368 /// #[tokio::main]
369 /// async fn main() -> uri_register::Result<()> {
370 /// let register = PostgresUriRegister::new(
371 /// "postgres://localhost/mydb",
372 /// "uri_register",
373 /// 20,
374 /// 10_000
375 /// ).await?;
376 /// let stats = register.stats().await?;
377 /// println!("Total URIs: {}", stats.total_uris);
378 /// println!("Size: {} bytes", stats.size_bytes);
379 /// Ok(())
380 /// }
381 /// ```
382 pub async fn stats(&self) -> Result<RegisterStats> {
383 // Build query with validated table name (safe from SQL injection)
384 let query = format!(
385 r#"
386 SELECT
387 COUNT(*)::bigint as count,
388 pg_total_relation_size('{}')::bigint as size_bytes
389 FROM {}
390 "#,
391 self.table_name, self.table_name
392 );
393
394 // Execute with retry logic
395 let client = self.pool.get().await.map_err(|e| {
396 crate::error::Error::Database(format!("Failed to get database connection: {}", e))
397 })?;
398
399 let rows = client
400 .query(&query, &[])
401 .await
402 .map_err(|e| crate::error::Error::Database(e.to_string()))?;
403
404 let row = rows.into_iter().next().ok_or_else(|| {
405 crate::error::Error::Database("No rows returned from stats query".to_string())
406 })?;
407
408 // Get cache statistics
409 let cache_stats = self.cache.stats();
410
411 // Get connection pool statistics
412 let status = self.pool.status();
413 let pool_stats = PoolStats {
414 connections_active: (status.size - status.available) as u32,
415 connections_idle: status.available as u32,
416 connections_max: status.max_size as u32,
417 };
418
419 Ok(RegisterStats {
420 total_uris: row.get::<_, i64>("count") as u64,
421 size_bytes: row.get::<_, i64>("size_bytes") as u64,
422 cache: cache_stats,
423 pool: pool_stats,
424 })
425 }
426
427 /// Clone the register instance (shares pool and cache)
428 ///
429 /// This is a shallow clone that shares both the connection pool and cache.
430 /// Both pool and cache use Arc internally, so this clone is cheap and shares
431 /// the underlying resources.
432 ///
433 /// This method is primarily used for Python bindings where we need to move
434 /// data into async closures.
435 #[cfg(feature = "python")]
436 pub(crate) fn clone_inner(&self) -> Self {
437 PostgresUriRegister {
438 pool: self.pool.clone(),
439 cache: self.cache.clone(), // Clone the Arc, shares the same cache
440 table_name: self.table_name.clone(),
441 }
442 }
443
444 /// Validate that a string is a valid URI according to RFC 3986
445 fn validate_uri(uri: &str) -> Result<()> {
446 Url::parse(uri).map_err(|e| {
447 crate::error::Error::InvalidUri(format!("Invalid URI '{}': {}", uri, e))
448 })?;
449 Ok(())
450 }
451}
452
453#[async_trait]
454impl UriService for PostgresUriRegister {
455 #[instrument(skip(self), fields(table = %self.table_name))]
456 async fn register_uri(&self, uri: &str) -> Result<u64> {
457 // Validate URI first
458 Self::validate_uri(uri)?;
459
460 // Check cache first
461 if let Some(id) = self.cache.get(uri) {
462 trace!(id, "cache hit");
463 return Ok(id);
464 }
465 trace!("cache miss, querying database");
466
467 // Insert and return ID (ON CONFLICT handles race conditions and existing URIs)
468 // Build query with validated table name (safe from SQL injection)
469 let query = format!(
470 r#"
471 INSERT INTO {} (uri)
472 VALUES ($1)
473 ON CONFLICT (uri_hash) DO UPDATE SET uri = EXCLUDED.uri
474 RETURNING id
475 "#,
476 self.table_name
477 );
478
479 // Execute with retry logic
480 let client = self.pool.get().await.map_err(|e| {
481 crate::error::Error::Database(format!("Failed to get database connection: {}", e))
482 })?;
483
484 let rows = client
485 .query(&query, &[&uri])
486 .await
487 .map_err(|e| crate::error::Error::Database(e.to_string()))?;
488
489 let result = rows.into_iter().next().ok_or_else(|| {
490 crate::error::Error::Database("No rows returned from register_uri query".to_string())
491 })?;
492
493 let id = result.get::<_, i64>("id") as u64;
494
495 // Update cache
496 self.cache.put(uri.to_string(), id);
497
498 Ok(id)
499 }
500
501 #[instrument(skip(self, uris), fields(table = %self.table_name, batch_size = uris.len()))]
502 async fn register_uri_batch(&self, uris: &[String]) -> Result<Vec<u64>> {
503 if uris.is_empty() {
504 trace!("empty batch, returning early");
505 return Ok(Vec::new());
506 }
507
508 // Validate all URIs first
509 for uri in uris {
510 Self::validate_uri(uri)?;
511 }
512
513 // CORRECTNESS GUARANTEE: Order preservation
514 // We maintain strict correspondence between input URIs and output IDs
515 // by tracking the original index of each URI and using URI strings
516 // (not SQL result order) to map IDs back to their positions.
517
518 let mut result_ids = vec![None; uris.len()];
519 let mut uncached_indices = Vec::new();
520 let mut uncached_uris_dedup = Vec::new();
521 let mut seen_uncached = std::collections::HashMap::new();
522
523 // Step 1: Check cache for all URIs
524 for (idx, uri) in uris.iter().enumerate() {
525 if let Some(id) = self.cache.get(uri) {
526 result_ids[idx] = Some(id);
527 } else {
528 uncached_indices.push(idx);
529 // Deduplicate uncached URIs for DB query
530 if !seen_uncached.contains_key(uri) {
531 seen_uncached.insert(uri.clone(), uncached_uris_dedup.len());
532 uncached_uris_dedup.push(uri.clone());
533 }
534 }
535 }
536
537 // If everything was cached, return early
538 if uncached_uris_dedup.is_empty() {
539 debug!(cached = uris.len(), "all URIs found in cache");
540 return Ok(result_ids.into_iter().map(|id| id.unwrap()).collect());
541 }
542
543 let cached_count = uris.len() - uncached_indices.len();
544 debug!(
545 cached = cached_count,
546 uncached = uncached_uris_dedup.len(),
547 "cache lookup complete, querying database"
548 );
549
550 // Step 2: Register deduplicated uncached URIs in batch
551 // IMPORTANT: SQL may return results in ANY order (not guaranteed to match input order)
552 // We use "RETURNING id, uri" to get BOTH values together, then map by URI string
553 // Build query with validated table name (safe from SQL injection)
554 let query = format!(
555 r#"
556 INSERT INTO {} (uri)
557 SELECT unnest($1::text[])
558 ON CONFLICT (uri_hash) DO UPDATE SET uri = EXCLUDED.uri
559 RETURNING id, uri
560 "#,
561 self.table_name
562 );
563
564 // Execute with retry logic
565 let client = self.pool.get().await.map_err(|e| {
566 crate::error::Error::Database(format!("Failed to get database connection: {}", e))
567 })?;
568
569 let rows = client
570 .query(&query, &[&uncached_uris_dedup])
571 .await
572 .map_err(|e| crate::error::Error::Database(e.to_string()))?;
573
574 // Build a map of URI -> ID from database results
575 // This allows us to look up IDs by URI string (order-independent)
576 let mut uri_to_id = std::collections::HashMap::new();
577 for row in rows {
578 let uri: String = row.get("uri");
579 let id: i64 = row.get("id");
580 uri_to_id.insert(uri, id as u64);
581 }
582
583 // Step 3: Fill in the result vector and update cache
584 // CORRECTNESS: We use the saved indices and look up by URI string,
585 // guaranteeing that result_ids[i] corresponds to uris[i]
586 for idx in uncached_indices {
587 let uri = &uris[idx]; // Get URI from original position
588 if let Some(&id) = uri_to_id.get(uri) {
589 // Look up ID by URI string
590 result_ids[idx] = Some(id); // Store at original index
591 self.cache.put(uri.clone(), id);
592 }
593 }
594
595 // Convert Option<u64> to u64 (all should be Some at this point)
596 Ok(result_ids
597 .into_iter()
598 .map(|id| id.expect("All URIs should have IDs"))
599 .collect())
600 }
601
602 #[instrument(skip(self, uris), fields(table = %self.table_name, batch_size = uris.len()))]
603 async fn register_uri_batch_hashmap(
604 &self,
605 uris: &[String],
606 ) -> Result<std::collections::HashMap<String, u64>> {
607 if uris.is_empty() {
608 trace!("empty batch, returning early");
609 return Ok(std::collections::HashMap::new());
610 }
611
612 // Validate all URIs first
613 for uri in uris {
614 Self::validate_uri(uri)?;
615 }
616
617 // CORRECTNESS GUARANTEE: URI-to-ID mapping accuracy
618 // Each URI in the result HashMap is guaranteed to map to its correct ID
619 // because SQL returns both 'id' and 'uri' together in each row (RETURNING id, uri).
620 // We never rely on positional correspondence, eliminating ordering errors.
621
622 let mut result = std::collections::HashMap::new();
623 let mut uncached_uris = Vec::new();
624
625 // Step 1: Deduplicate input and check cache
626 let unique_uris: std::collections::HashSet<_> = uris.iter().collect();
627
628 for uri in unique_uris {
629 if let Some(id) = self.cache.get(uri) {
630 result.insert(uri.clone(), id);
631 } else {
632 uncached_uris.push(uri.clone());
633 }
634 }
635
636 // If everything was cached, return early
637 if uncached_uris.is_empty() {
638 debug!(cached = result.len(), "all URIs found in cache");
639 return Ok(result);
640 }
641
642 debug!(
643 cached = result.len(),
644 uncached = uncached_uris.len(),
645 "cache lookup complete, querying database"
646 );
647
648 // Step 2: Register uncached URIs in batch
649 // Build query with validated table name (safe from SQL injection)
650 let query = format!(
651 r#"
652 INSERT INTO {} (uri)
653 SELECT unnest($1::text[])
654 ON CONFLICT (uri_hash) DO UPDATE SET uri = EXCLUDED.uri
655 RETURNING id, uri
656 "#,
657 self.table_name
658 );
659
660 // Execute with retry logic
661 let client = self.pool.get().await.map_err(|e| {
662 crate::error::Error::Database(format!("Failed to get database connection: {}", e))
663 })?;
664
665 let rows = client
666 .query(&query, &[&uncached_uris])
667 .await
668 .map_err(|e| crate::error::Error::Database(e.to_string()))?;
669
670 // Step 3: Add database results to result map and update cache
671 // CORRECTNESS: Each row contains both URI and ID from same DB row,
672 // guaranteeing correct mapping (no opportunity for misalignment)
673 for row in rows {
674 let uri: String = row.get("uri");
675 let id: i64 = row.get("id");
676 let id_u64 = id as u64;
677
678 result.insert(uri.clone(), id_u64); // URI and ID are from same row
679 self.cache.put(uri, id_u64);
680 }
681
682 Ok(result)
683 }
684}
685
686/// Statistics about the URI register for observability and OpenTelemetry
687#[derive(Debug, Clone)]
688pub struct RegisterStats {
689 /// Total number of URIs in the register
690 pub total_uris: u64,
691 /// Total storage size in bytes (includes indexes)
692 pub size_bytes: u64,
693 /// Cache performance metrics
694 pub cache: crate::cache::CacheStats,
695 /// Connection pool metrics
696 pub pool: PoolStats,
697}
698
699/// Connection pool statistics for observability
700#[derive(Debug, Clone)]
701pub struct PoolStats {
702 /// Number of connections currently being used
703 pub connections_active: u32,
704 /// Number of idle connections in the pool
705 pub connections_idle: u32,
706 /// Maximum number of connections allowed in the pool
707 pub connections_max: u32,
708}