use std::fmt::Write as _;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use canton_core::{Error, Result};
use canton_daml::Contract as ContractType;
use tokio::sync::RwLock;
use tokio_postgres::types::ToSql;
use crate::query::{Param, Predicate, Query, Sql};
use crate::row::{Contract, Exercise};
#[derive(Clone, Debug)]
pub struct PqsClient {
inner: Arc<Inner>,
}
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
struct Inner {
config: tokio_postgres::Config,
tls: Tls,
timeout_ms: AtomicU64,
client: RwLock<Arc<tokio_postgres::Client>>,
}
#[allow(clippy::missing_fields_in_debug)]
impl std::fmt::Debug for Inner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PqsClient")
.field("hosts", &self.config.get_hosts())
.field("dbname", &self.config.get_dbname())
.field("user", &self.config.get_user())
.field("tls", &self.tls.is_on())
.field("timeout_ms", &self.timeout_ms.load(Ordering::Relaxed))
.finish()
}
}
#[derive(Clone)]
enum Tls {
Off,
#[cfg(feature = "tls")]
Rustls(Arc<rustls::ClientConfig>),
}
impl Tls {
fn is_on(&self) -> bool {
match self {
Tls::Off => false,
#[cfg(feature = "tls")]
Tls::Rustls(_) => true,
}
}
}
fn parse_config(config: &str) -> Result<tokio_postgres::Config> {
config.parse::<tokio_postgres::Config>().map_err(|e| {
Error::InvalidRequest(format!(
"PQS connection string is not one tokio-postgres accepts: {}",
canton_core::chain(&e)
))
})
}
impl PqsClient {
pub async fn connect(config: &str) -> Result<Self> {
Self::connect_with_timeout(config, DEFAULT_TIMEOUT).await
}
pub async fn connect_with_timeout(config: &str, timeout: Duration) -> Result<Self> {
let config = parse_config(config)?;
Self::establish(config, Tls::Off, timeout).await
}
#[cfg(feature = "tls")]
#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
pub async fn connect_tls(config: &str) -> Result<Self> {
Self::connect_tls_with_timeout(config, DEFAULT_TIMEOUT).await
}
#[cfg(feature = "tls")]
#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
pub async fn connect_tls_with_timeout(config: &str, timeout: Duration) -> Result<Self> {
let mut config = parse_config(config)?;
config.ssl_mode(tokio_postgres::config::SslMode::Require);
let mut roots = rustls::RootCertStore::empty();
let native = rustls_native_certs::load_native_certs();
if native.certs.is_empty() {
return Err(Error::InvalidRequest(
"no native root certificates were found, so a TLS connection could not be \
verified"
.to_string(),
));
}
roots.add_parsable_certificates(native.certs);
let client_config = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
Self::establish(config, Tls::Rustls(Arc::new(client_config)), timeout).await
}
#[must_use]
pub fn with_timeout(self, timeout: Duration) -> Self {
self.inner.timeout_ms.store(
u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
self
}
fn timeout(&self) -> Duration {
Duration::from_millis(self.inner.timeout_ms.load(Ordering::Relaxed))
}
async fn establish(
config: tokio_postgres::Config,
tls: Tls,
timeout: Duration,
) -> Result<Self> {
let client = open(&config, &tls, timeout).await?;
Ok(Self {
inner: Arc::new(Inner {
config,
tls,
timeout_ms: AtomicU64::new(u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)),
client: RwLock::new(Arc::new(client)),
}),
})
}
async fn reconnect(&self) -> Result<()> {
let mut guard = self.inner.client.write().await;
if !guard.is_closed() {
return Ok(());
}
let fresh = open(&self.inner.config, &self.inner.tls, self.timeout()).await?;
*guard = Arc::new(fresh);
tracing::info!("reconnected to PQS after the store closed the connection");
Ok(())
}
pub async fn run<T>(&self, query: &Query<T>) -> Result<Vec<Contract<T>>>
where
T: ContractType + serde::de::DeserializeOwned,
{
let rows = self.query(&query.compile()).await?;
rows.iter().map(Contract::from_row).collect()
}
pub async fn active<T>(&self) -> Result<Vec<Contract<T>>>
where
T: ContractType + serde::de::DeserializeOwned,
{
self.run(&Query::<T>::active()).await
}
pub async fn lookup<T>(&self, contract_id: &str) -> Result<Option<Contract<T>>>
where
T: ContractType + serde::de::DeserializeOwned,
{
let sql = Sql::raw(
"SELECT * FROM lookup_contract($1, $2)",
vec![
Param::Text(contract_id.to_string()),
Param::Text(Query::<T>::qname()),
],
);
let rows = self.query(&sql).await?;
rows.first().map(Contract::from_row).transpose()
}
pub async fn exercises<T, C>(
&self,
from: Option<i64>,
to: Option<i64>,
) -> Result<Vec<Exercise<C>>>
where
T: ContractType,
C: canton_daml::Choice<T> + serde::de::DeserializeOwned,
{
let qname = format!("{}:{}", Query::<T>::qname(), C::NAME);
let rows = self.query(&exercises_sql(qname, from, to)).await?;
rows.iter().map(Exercise::from_row).collect()
}
pub async fn latest_offset(&self) -> Result<i64> {
let rows = self
.query(&Sql::raw("SELECT latest_offset()", Vec::new()))
.await?;
let row = rows.first().ok_or_else(|| {
Error::UnexpectedResponse("PQS answered latest_offset() with no row".to_string())
})?;
row.try_get::<_, Option<i64>>(0)
.map_err(|e| {
Error::UnexpectedResponse(format!(
"PQS returned a latest offset this cannot read: {e}"
))
})?
.ok_or_else(|| {
Error::UnexpectedResponse(
"PQS reported no latest offset, so it has ingested nothing".to_string(),
)
})
}
pub async fn query(&self, sql: &Sql) -> Result<Vec<tokio_postgres::Row>> {
let values: Vec<Box<dyn ToSql + Sync + Send>> = sql
.params
.iter()
.map(|param| -> Box<dyn ToSql + Sync + Send> {
match param {
Param::Json(value) => Box::new(value.clone()),
Param::Text(value) | Param::Numeric(value) => Box::new(value.clone()),
Param::Path(segments) => Box::new(segments.clone()),
Param::Offset(value) => Box::new(*value),
}
})
.collect();
let borrowed: Vec<&(dyn ToSql + Sync)> = values
.iter()
.map(|value| value.as_ref() as &(dyn ToSql + Sync))
.collect();
tracing::debug!(sql = %sql.text, params = sql.params.len(), "PQS query");
let client = self.inner.client.read().await.clone();
match self.run_on(&client, sql, &borrowed).await {
Err(Attempt::Closed) => {
self.reconnect().await?;
let client = self.inner.client.read().await.clone();
self.run_on(&client, sql, &borrowed)
.await
.map_err(Attempt::into_error)
}
other => other.map_err(Attempt::into_error),
}
}
async fn run_on(
&self,
client: &tokio_postgres::Client,
sql: &Sql,
params: &[&(dyn ToSql + Sync)],
) -> std::result::Result<Vec<tokio_postgres::Row>, Attempt> {
match tokio::time::timeout(self.timeout(), client.query(sql.text.as_str(), params)).await {
Err(_) => Err(Attempt::Failed(Error::Timeout)),
Ok(Ok(rows)) => Ok(rows),
Ok(Err(e)) if e.is_closed() => Err(Attempt::Closed),
Ok(Err(e)) => Err(Attempt::Failed(classify(&e))),
}
}
}
#[must_use]
pub fn active_signed_by<T: ContractType>(party: &str) -> Query<T> {
Query::<T>::active().filter(Predicate::signatory(party))
}
enum Attempt {
Closed,
Failed(Error),
}
impl Attempt {
fn into_error(self) -> Error {
match self {
Attempt::Closed => Error::Connection(
"the PQS connection closed, and closed again on a fresh one".to_string(),
),
Attempt::Failed(error) => error,
}
}
}
async fn open(
config: &tokio_postgres::Config,
tls: &Tls,
timeout: Duration,
) -> Result<tokio_postgres::Client> {
match tls {
Tls::Off => {
let (client, connection) =
tokio::time::timeout(timeout, config.connect(tokio_postgres::NoTls))
.await
.map_err(|_| Error::Timeout)?
.map_err(|e| connect_error("cannot reach PQS", &e))?;
Ok(drive(client, connection))
}
#[cfg(feature = "tls")]
Tls::Rustls(client_config) => {
let connector =
tokio_postgres_rustls::MakeRustlsConnect::new(client_config.as_ref().clone());
let (client, connection) = tokio::time::timeout(timeout, config.connect(connector))
.await
.map_err(|_| Error::Timeout)?
.map_err(|e| connect_error("cannot reach PQS over TLS", &e))?;
Ok(drive(client, connection))
}
}
}
fn drive<S, T>(
client: tokio_postgres::Client,
connection: tokio_postgres::Connection<S, T>,
) -> tokio_postgres::Client
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
T: tokio_postgres::tls::TlsStream + Unpin + Send + 'static,
{
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::warn!(error = %e, "the PQS connection ended");
}
});
client
}
fn classify(error: &tokio_postgres::Error) -> Error {
let Some(code) = error.code() else {
if is_permanent_kind(error) {
return Error::InvalidRequest(format!(
"PQS query could not be made: {}",
detail(error)
));
}
return Error::Connection(format!("PQS query failed: {}", detail(error)));
};
if is_transient_sqlstate(code.code()) {
Error::Connection(format!(
"PQS is momentarily unavailable ({}): {}",
code.code(),
detail(error)
))
} else {
Error::InvalidRequest(format!("PQS rejected the query: {}", detail(error)))
}
}
fn exercises_sql(qname: String, from: Option<i64>, to: Option<i64>) -> Sql {
let mut params = vec![Param::Text(qname)];
let mut text = "SELECT * FROM exercises($1".to_string();
if from.is_some() || to.is_some() {
let from_sql = from.map_or_else(
|| "COALESCE(pruned_offset(), oldest_offset())".to_string(),
|value| {
params.push(Param::Offset(value));
format!("${}", params.len())
},
);
let to_sql = to.map_or_else(
|| "latest_offset()".to_string(),
|value| {
params.push(Param::Offset(value));
format!("${}", params.len())
},
);
let _ = write!(text, ", {from_sql}, {to_sql}");
}
text.push(')');
Sql::raw(text, params)
}
fn connect_error(what: &str, error: &tokio_postgres::Error) -> Error {
let detail = detail(error);
if let Some(code) = error.code()
&& !is_transient_sqlstate(code.code())
{
return Error::InvalidRequest(format!(
"{what}: the store refused the connection ({}): {detail}",
code.code()
));
}
if is_permanent_kind(error) {
return Error::InvalidRequest(format!("{what}: {detail}"));
}
if detail.to_ascii_lowercase().contains("certificate") {
return Error::InvalidRequest(format!(
"{what}: its certificate could not be verified: {detail}"
));
}
Error::Connection(format!("{what}: {detail}"))
}
fn is_permanent_kind(error: &tokio_postgres::Error) -> bool {
use std::error::Error as _;
const PERMANENT: [&str; 11] = [
"invalid connection string",
"invalid configuration",
"authentication error",
"error performing TLS handshake",
"error encoding message to server",
"error serializing parameter",
"error deserializing column",
"error parsing response from server",
"unexpected message from server",
"query returned an unexpected number of columns",
"query returned an unexpected number of rows",
];
let kind = error.to_string();
PERMANENT.iter().any(|prefix| kind.starts_with(prefix))
|| error
.source()
.is_some_and(<dyn std::error::Error>::is::<tokio_postgres::types::WrongType>)
}
fn is_transient_sqlstate(sqlstate: &str) -> bool {
matches!(sqlstate.get(..2), Some("08" | "40" | "53" | "55" | "57"))
}
fn detail(error: &tokio_postgres::Error) -> String {
canton_core::chain(error)
}
#[cfg(test)]
mod tests {
use super::is_transient_sqlstate;
#[test]
fn a_server_that_is_momentarily_unable_is_retriable() {
for (code, what) in [
("57P01", "the admin shut the server down — a failover"),
("57P02", "the server crashed"),
("57P03", "the server cannot connect yet — still starting"),
("40001", "serialization failure under concurrent load"),
("40P01", "deadlock detected"),
("53300", "too many connections"),
("53200", "out of memory"),
("55P03", "lock not available"),
("08006", "connection failure"),
] {
assert!(
is_transient_sqlstate(code),
"{code} ({what}) must be retriable"
);
}
}
#[test]
fn a_statement_the_server_refused_is_the_callers_to_fix() {
for (code, what) in [
("42601", "syntax error"),
("42883", "no such function — a wrong qname"),
("42703", "no such column"),
("22P02", "invalid text representation — a bad cast"),
("23505", "unique violation"),
] {
assert!(
!is_transient_sqlstate(code),
"{code} ({what}) must not be retriable"
);
}
}
#[test]
fn a_malformed_sqlstate_is_not_mistaken_for_a_transient_one() {
for code in ["", "5", "4"] {
assert!(!is_transient_sqlstate(code), "{code:?} is not a class");
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod connection_tests {
use super::*;
#[tokio::test]
async fn a_connection_string_that_does_not_parse_is_the_callers_mistake() {
let error = PqsClient::connect("this is not a connection string")
.await
.expect_err("refused");
assert!(matches!(error, Error::InvalidRequest(_)), "{error:?}");
assert!(!error.is_retriable(), "a typo is not transient: {error}");
}
#[tokio::test]
async fn a_store_that_never_answers_is_a_timeout_not_a_hang() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let _held = listener.accept().await;
tokio::time::sleep(Duration::from_secs(30)).await;
});
let error = PqsClient::connect_with_timeout(
&format!("host=127.0.0.1 port={port} user=u dbname=d"),
Duration::from_millis(300),
)
.await
.expect_err("timed out");
assert!(matches!(error, Error::Timeout), "{error:?}");
assert!(error.is_retriable());
}
#[cfg(feature = "tls")]
#[tokio::test]
async fn a_store_that_refuses_tls_is_refused_back_not_downgraded() {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0u8; 8];
let _ = socket.read_exact(&mut request).await;
let _ = socket.write_all(b"N").await;
let _ = socket.shutdown().await;
});
let error = PqsClient::connect_tls_with_timeout(
&format!("host=127.0.0.1 port={port} user=u dbname=d"),
Duration::from_secs(5),
)
.await
.expect_err("no TLS means no connection");
assert!(
!error.is_retriable(),
"a store without TLS will not grow one on retry: {error}"
);
assert!(
format!("{error}").to_ascii_lowercase().contains("tls"),
"the reason names TLS: {error}"
);
}
#[test]
fn the_exercises_statement_binds_only_the_bounds_it_was_given() {
let q = || "pkg:Mod:T:Choice".to_string();
let none = exercises_sql(q(), None, None);
assert_eq!(none.text, "SELECT * FROM exercises($1)");
assert_eq!(none.params, vec![Param::Text(q())]);
let from = exercises_sql(q(), Some(10), None);
assert_eq!(
from.text,
"SELECT * FROM exercises($1, $2, latest_offset())"
);
assert_eq!(from.params, vec![Param::Text(q()), Param::Offset(10)]);
let to = exercises_sql(q(), None, Some(20));
assert_eq!(
to.text,
"SELECT * FROM exercises($1, COALESCE(pruned_offset(), oldest_offset()), $2)"
);
assert_eq!(to.params, vec![Param::Text(q()), Param::Offset(20)]);
let both = exercises_sql(q(), Some(10), Some(20));
assert_eq!(both.text, "SELECT * FROM exercises($1, $2, $3)");
assert_eq!(
both.params,
vec![Param::Text(q()), Param::Offset(10), Param::Offset(20)]
);
}
}