use crate::cache::{create_cache, Cache, CacheStrategy};
use crate::error::{ConfigurationError, Result};
use crate::service::UriService;
use async_trait::async_trait;
use deadpool_postgres::{ManagerConfig, Pool, RecyclingMethod, Runtime};
use rustls::RootCertStore;
use rustls_pki_types::pem::PemObject;
use std::sync::Arc;
use tokio_postgres::{Config, NoTls};
use tokio_postgres_rustls::MakeRustlsConnect;
use tracing::{debug, info, instrument, trace, warn};
use url::Url;
pub struct PostgresUriRegister {
pool: Pool,
cache: Arc<dyn Cache>,
table_name: String,
}
impl PostgresUriRegister {
pub async fn new(
database_url: &str,
table_name: &str,
max_connections: u32,
cache_size: usize,
) -> Result<Self> {
Self::new_with_cache_strategy(
database_url,
table_name,
max_connections,
cache_size,
None, None, None, )
.await
}
pub async fn new_with_cache_strategy(
database_url: &str,
table_name: &str,
max_connections: u32,
cache_size: usize,
cache_strategy: Option<CacheStrategy>,
use_tls: Option<bool>,
ca_cert_path: Option<&str>,
) -> Result<Self> {
if cache_size == 0 {
return Err(ConfigurationError::InvalidCacheSize(cache_size).into());
}
if max_connections == 0 {
return Err(ConfigurationError::InvalidMaxConnections(max_connections).into());
}
Self::validate_table_name(table_name)?;
let pg_config: Config = database_url.parse().map_err(|e| {
ConfigurationError::InvalidBackoff(format!("Failed to parse database URL: {}", e))
})?;
let mut cfg = deadpool_postgres::Config::new();
cfg.dbname = pg_config.get_dbname().map(|s| s.to_string());
cfg.host = pg_config.get_hosts().first().map(|h| match h {
tokio_postgres::config::Host::Tcp(s) => s.to_string(),
#[cfg(unix)]
tokio_postgres::config::Host::Unix(p) => p.to_str().unwrap_or_default().to_string(),
});
cfg.port = pg_config.get_ports().first().copied();
cfg.user = pg_config.get_user().map(|s| s.to_string());
cfg.password = pg_config
.get_password()
.map(|p| std::str::from_utf8(p).unwrap_or_default().to_string());
cfg.manager = Some(ManagerConfig {
recycling_method: RecyclingMethod::Fast,
});
cfg.pool = Some(deadpool_postgres::PoolConfig {
max_size: max_connections as usize,
timeouts: deadpool_postgres::Timeouts {
wait: Some(std::time::Duration::from_secs(10)),
create: Some(std::time::Duration::from_secs(10)),
recycle: Some(std::time::Duration::from_secs(10)),
},
..Default::default()
});
let effective_tls = use_tls.unwrap_or(false) || ca_cert_path.is_some();
if !effective_tls {
warn!(
"TLS is DISABLED for database connection - data will be transmitted in plaintext. \
This is not recommended for production environments."
);
}
if let Some(password) = pg_config.get_password() {
if password.is_empty() {
warn!("Database connection configured with an empty password.");
}
} else {
warn!("Database connection configured without a password.");
}
let pool = if effective_tls {
let mut root_store = RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
if let Some(cert_path) = ca_cert_path {
info!(
ca_cert_path = cert_path,
"Loading custom CA certificate for TLS verification"
);
let pem_data = std::fs::read(cert_path).map_err(|e| {
ConfigurationError::InvalidBackoff(format!(
"Failed to read CA certificate file '{}': {}",
cert_path, e
))
})?;
let certs: Vec<_> = rustls_pki_types::CertificateDer::pem_slice_iter(&pem_data)
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| {
ConfigurationError::InvalidBackoff(format!(
"Failed to parse PEM certificates from '{}': {}",
cert_path, e
))
})?;
if certs.is_empty() {
return Err(ConfigurationError::InvalidBackoff(format!(
"No valid certificates found in CA certificate file '{}'",
cert_path
))
.into());
}
warn!(
cert_count = certs.len(),
ca_cert_path = cert_path,
"Custom CA certificate(s) loaded - connections will trust certificates signed \
by this CA in addition to public CAs. Ensure this CA certificate is from a \
trusted source."
);
let (added, _ignored) = root_store.add_parsable_certificates(certs);
if added == 0 {
return Err(ConfigurationError::InvalidBackoff(format!(
"None of the certificates in '{}' could be added to the trust store",
cert_path
))
.into());
}
info!(
added_certs = added,
"Custom CA certificates added to trust store"
);
}
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let tls = MakeRustlsConnect::new(tls_config);
cfg.create_pool(Some(Runtime::Tokio1), tls).map_err(|e| {
ConfigurationError::InvalidBackoff(format!(
"Failed to create connection pool with TLS: {}",
e
))
})?
} else {
cfg.create_pool(Some(Runtime::Tokio1), NoTls).map_err(|e| {
ConfigurationError::InvalidBackoff(format!(
"Failed to create connection pool: {}",
e
))
})?
};
let cache = create_cache(cache_strategy.unwrap_or_default(), cache_size);
info!(
table = table_name,
max_connections,
cache_size,
tls = effective_tls,
custom_ca = ca_cert_path.is_some(),
"URI register connected"
);
Ok(Self {
pool,
cache,
table_name: table_name.to_string(),
})
}
fn validate_table_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(ConfigurationError::InvalidTableName(
"table name cannot be empty".to_string(),
)
.into());
}
if name.len() > 63 {
return Err(ConfigurationError::InvalidTableName(format!(
"table name too long (max 63 characters): '{}'",
name
))
.into());
}
let first_char = name.chars().next().unwrap();
if !first_char.is_ascii_alphabetic() && first_char != '_' {
return Err(ConfigurationError::InvalidTableName(format!(
"table name must start with a letter or underscore: '{}'",
name
))
.into());
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(ConfigurationError::InvalidTableName(format!(
"table name can only contain letters, numbers, and underscores: '{}'",
name
))
.into());
}
Ok(())
}
pub async fn stats(&self) -> Result<RegisterStats> {
let query = format!(
r#"
SELECT
COUNT(*)::bigint as count,
pg_total_relation_size('{}')::bigint as size_bytes
FROM {}
"#,
self.table_name, self.table_name
);
let client = self.pool.get().await.map_err(|e| {
crate::error::Error::Database(format!("Failed to get database connection: {}", e))
})?;
let rows = client
.query(&query, &[])
.await
.map_err(|e| crate::error::Error::Database(e.to_string()))?;
let row = rows.into_iter().next().ok_or_else(|| {
crate::error::Error::Database("No rows returned from stats query".to_string())
})?;
let cache_stats = self.cache.stats();
let status = self.pool.status();
let pool_stats = PoolStats {
connections_active: (status.size - status.available) as u32,
connections_idle: status.available as u32,
connections_max: status.max_size as u32,
};
Ok(RegisterStats {
total_uris: row.get::<_, i64>("count") as u64,
size_bytes: row.get::<_, i64>("size_bytes") as u64,
cache: cache_stats,
pool: pool_stats,
})
}
#[cfg(feature = "python")]
pub(crate) fn clone_inner(&self) -> Self {
PostgresUriRegister {
pool: self.pool.clone(),
cache: self.cache.clone(), table_name: self.table_name.clone(),
}
}
fn validate_uri(uri: &str) -> Result<()> {
Url::parse(uri).map_err(|e| {
crate::error::Error::InvalidUri(format!("Invalid URI '{}': {}", uri, e))
})?;
Ok(())
}
}
#[async_trait]
impl UriService for PostgresUriRegister {
#[instrument(skip(self), fields(table = %self.table_name))]
async fn register_uri(&self, uri: &str) -> Result<u64> {
Self::validate_uri(uri)?;
if let Some(id) = self.cache.get(uri) {
trace!(id, "cache hit");
return Ok(id);
}
trace!("cache miss, querying database");
let query = format!(
r#"
INSERT INTO {} (uri)
VALUES ($1)
ON CONFLICT (uri_hash) DO UPDATE SET uri = EXCLUDED.uri
RETURNING id
"#,
self.table_name
);
let client = self.pool.get().await.map_err(|e| {
crate::error::Error::Database(format!("Failed to get database connection: {}", e))
})?;
let rows = client
.query(&query, &[&uri])
.await
.map_err(|e| crate::error::Error::Database(e.to_string()))?;
let result = rows.into_iter().next().ok_or_else(|| {
crate::error::Error::Database("No rows returned from register_uri query".to_string())
})?;
let id = result.get::<_, i64>("id") as u64;
self.cache.put(uri.to_string(), id);
Ok(id)
}
#[instrument(skip(self, uris), fields(table = %self.table_name, batch_size = uris.len()))]
async fn register_uri_batch(&self, uris: &[String]) -> Result<Vec<u64>> {
if uris.is_empty() {
trace!("empty batch, returning early");
return Ok(Vec::new());
}
for uri in uris {
Self::validate_uri(uri)?;
}
let mut result_ids = vec![None; uris.len()];
let mut uncached_indices = Vec::new();
let mut uncached_uris_dedup = Vec::new();
let mut seen_uncached = std::collections::HashMap::new();
for (idx, uri) in uris.iter().enumerate() {
if let Some(id) = self.cache.get(uri) {
result_ids[idx] = Some(id);
} else {
uncached_indices.push(idx);
if !seen_uncached.contains_key(uri) {
seen_uncached.insert(uri.clone(), uncached_uris_dedup.len());
uncached_uris_dedup.push(uri.clone());
}
}
}
if uncached_uris_dedup.is_empty() {
debug!(cached = uris.len(), "all URIs found in cache");
return Ok(result_ids.into_iter().map(|id| id.unwrap()).collect());
}
let cached_count = uris.len() - uncached_indices.len();
debug!(
cached = cached_count,
uncached = uncached_uris_dedup.len(),
"cache lookup complete, querying database"
);
let query = format!(
r#"
INSERT INTO {} (uri)
SELECT unnest($1::text[])
ON CONFLICT (uri_hash) DO UPDATE SET uri = EXCLUDED.uri
RETURNING id, uri
"#,
self.table_name
);
let client = self.pool.get().await.map_err(|e| {
crate::error::Error::Database(format!("Failed to get database connection: {}", e))
})?;
let rows = client
.query(&query, &[&uncached_uris_dedup])
.await
.map_err(|e| crate::error::Error::Database(e.to_string()))?;
let mut uri_to_id = std::collections::HashMap::new();
for row in rows {
let uri: String = row.get("uri");
let id: i64 = row.get("id");
uri_to_id.insert(uri, id as u64);
}
for idx in uncached_indices {
let uri = &uris[idx]; if let Some(&id) = uri_to_id.get(uri) {
result_ids[idx] = Some(id); self.cache.put(uri.clone(), id);
}
}
Ok(result_ids
.into_iter()
.map(|id| id.expect("All URIs should have IDs"))
.collect())
}
#[instrument(skip(self, uris), fields(table = %self.table_name, batch_size = uris.len()))]
async fn register_uri_batch_hashmap(
&self,
uris: &[String],
) -> Result<std::collections::HashMap<String, u64>> {
if uris.is_empty() {
trace!("empty batch, returning early");
return Ok(std::collections::HashMap::new());
}
for uri in uris {
Self::validate_uri(uri)?;
}
let mut result = std::collections::HashMap::new();
let mut uncached_uris = Vec::new();
let unique_uris: std::collections::HashSet<_> = uris.iter().collect();
for uri in unique_uris {
if let Some(id) = self.cache.get(uri) {
result.insert(uri.clone(), id);
} else {
uncached_uris.push(uri.clone());
}
}
if uncached_uris.is_empty() {
debug!(cached = result.len(), "all URIs found in cache");
return Ok(result);
}
debug!(
cached = result.len(),
uncached = uncached_uris.len(),
"cache lookup complete, querying database"
);
let query = format!(
r#"
INSERT INTO {} (uri)
SELECT unnest($1::text[])
ON CONFLICT (uri_hash) DO UPDATE SET uri = EXCLUDED.uri
RETURNING id, uri
"#,
self.table_name
);
let client = self.pool.get().await.map_err(|e| {
crate::error::Error::Database(format!("Failed to get database connection: {}", e))
})?;
let rows = client
.query(&query, &[&uncached_uris])
.await
.map_err(|e| crate::error::Error::Database(e.to_string()))?;
for row in rows {
let uri: String = row.get("uri");
let id: i64 = row.get("id");
let id_u64 = id as u64;
result.insert(uri.clone(), id_u64); self.cache.put(uri, id_u64);
}
Ok(result)
}
}
#[derive(Debug, Clone)]
pub struct RegisterStats {
pub total_uris: u64,
pub size_bytes: u64,
pub cache: crate::cache::CacheStats,
pub pool: PoolStats,
}
#[derive(Debug, Clone)]
pub struct PoolStats {
pub connections_active: u32,
pub connections_idle: u32,
pub connections_max: u32,
}