use std::sync::Arc;
use std::time::Duration;
use crate::config::SYSTEM_DATABASE_URL_ENV;
use crate::connection::Connection;
use crate::error::{Error, Result};
use crate::handle::WorkflowHandle;
use crate::identity::validate_app_name;
use crate::serialization::Serializer;
use crate::sysdb::DEFAULT_SCHEMA;
use crate::sysdb::types::WorkflowStatus;
use crate::{Queue, QueueChange, QueueConflict, QueueOptions};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig {
pub app_name: Option<String>,
pub database_url: String,
pub max_connections: u32,
pub schema: String,
pub serializer: Serializer,
pub use_listen_notify: bool,
pub polling_concurrency: crate::PollingConcurrency,
pub outcome_poll_interval: Option<Duration>,
pub notification_coalesce: Option<Duration>,
pub idle_transaction_timeout: crate::IdleTransactionTimeout,
}
impl ClientConfig {
pub fn new(database_url: impl Into<String>) -> Self {
Self {
app_name: None,
database_url: database_url.into(),
max_connections: 5,
schema: DEFAULT_SCHEMA.to_owned(),
serializer: Serializer::default(),
use_listen_notify: false,
polling_concurrency: crate::PollingConcurrency::Default,
outcome_poll_interval: None,
notification_coalesce: None,
idle_transaction_timeout: crate::IdleTransactionTimeout::Default,
}
}
pub fn from_env() -> Self {
Self::new(std::env::var(SYSTEM_DATABASE_URL_ENV).unwrap_or_default())
}
pub(crate) fn validate(&self) -> Result<()> {
if self.database_url.is_empty() {
return Err(Error::Config(format!(
"no database URL: set `database_url`, or the {SYSTEM_DATABASE_URL_ENV} environment \
variable if the configuration came from `ClientConfig::from_env`"
)));
}
if let Some(app_name) = &self.app_name {
validate_app_name(app_name)?;
}
if self.schema.is_empty() {
return Err(Error::Config("`schema` cannot be empty".to_owned()));
}
if self.max_connections == 0 {
return Err(Error::Config("`max_connections` cannot be zero".to_owned()));
}
self.serializer.validate()?;
if self.outcome_poll_interval == Some(Duration::ZERO) {
return Err(Error::Config(
"`outcome_poll_interval` cannot be zero".to_owned(),
));
}
self.idle_transaction_timeout
.validate()
.map_err(|error| crate::Error::Config(error.to_string()))?;
Ok(())
}
pub(crate) fn outcome_poll_interval(&self) -> Duration {
self.outcome_poll_interval
.unwrap_or(crate::config::DEFAULT_OUTCOME_POLL_INTERVAL)
}
}
#[derive(Clone, Debug)]
pub struct Client(Arc<Connection>);
impl Client {
pub async fn connect(config: ClientConfig) -> Result<Self> {
Ok(Self(Arc::new(Connection::for_client(&config).await?)))
}
pub async fn close(&self) {
self.0.close().await;
tracing::info!(
app_name = self.0.app_name().unwrap_or("<none>"),
"DBOS client closed"
);
}
pub fn app_name(&self) -> Option<&str> {
self.0.app_name()
}
pub fn retrieve_workflow<R, E>(&self, workflow_id: &str) -> WorkflowHandle<R, E> {
WorkflowHandle::polling(Arc::clone(&self.0), workflow_id.to_owned(), false)
}
pub async fn workflow_status(&self, workflow_id: &str) -> Result<Option<WorkflowStatus>> {
Ok(self
.0
.sysdb()
.get_workflow(workflow_id)
.await
.map_err(Error::SystemDatabase)?
.map(|row| row.status))
}
pub async fn register_queue(
&self,
name: &str,
options: QueueOptions,
on_conflict: QueueConflict,
) -> Result<Queue> {
self.0
.register_queue(name, options, on_conflict, None)
.await
}
pub async fn queue(&self, name: &str) -> Result<Option<Queue>> {
self.0.queue(name).await
}
pub async fn list_queues(&self) -> Result<Vec<Queue>> {
self.0.list_queues().await
}
pub async fn update_queue(&self, name: &str, change: QueueChange) -> Result<Queue> {
self.0.update_queue(name, change).await
}
pub async fn delete_queue(&self, name: &str) -> Result<()> {
self.0.delete_queue(name).await
}
pub(crate) fn connection(&self) -> &Arc<Connection> {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_configuration_speaks_for_no_application() {
let config = ClientConfig::new("postgres://localhost/app");
assert_eq!(
config.app_name, None,
"a name is a claim, and a bare constructor should not make one"
);
assert_eq!(config.schema, DEFAULT_SCHEMA);
assert!(
!config.use_listen_notify,
"a client polls unless told the application pushes notifications"
);
assert_eq!(config.outcome_poll_interval(), Duration::from_secs(1));
}
#[tokio::test]
async fn a_client_starts_notifications_only_when_asked() {
let db = dbos_test_support::test_database().await;
let default = Client::connect(ClientConfig::new(db.url()))
.await
.expect("connect failed");
let opted_in = Client::connect(ClientConfig {
use_listen_notify: true,
..ClientConfig::new(db.url())
})
.await
.expect("connect failed");
let started = (
default.connection().sysdb().is_pushing(),
opted_in.connection().sysdb().is_pushing(),
);
default.close().await;
opted_in.close().await;
assert_eq!(
started,
(false, db.backend() == dbos_test_support::Backend::Postgres),
"(default, opted in)"
);
}
#[tokio::test]
async fn an_opted_in_client_is_woken_by_the_application() {
let db = dbos_test_support::test_database().await;
if db.backend() == dbos_test_support::Backend::Cockroach {
return; }
let dbos = crate::DBOS::new(crate::Config {
migrate: false,
app_version: Some("1.0.0".to_owned()),
..crate::Config::new("client-wake-app", db.url())
});
let release = Arc::new(tokio::sync::Notify::new());
let workflow = dbos
.register_workflow("publish-later", {
let release = Arc::clone(&release);
move |()| {
let release = Arc::clone(&release);
async move {
release.notified().await;
crate::set_event("ready", &1u32).await?;
Ok::<(), Error>(())
}
}
})
.unwrap();
dbos.launch().await.expect("launch failed");
let client = Client::connect(ClientConfig {
use_listen_notify: true,
..ClientConfig::new(db.url())
})
.await
.expect("connect failed");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !client.connection().sysdb().is_delivering() && std::time::Instant::now() < deadline {
crate::rt::sleep(Duration::from_millis(50)).await;
}
let delivering = client.connection().sysdb().is_delivering();
let handle = workflow.start(()).await.expect("start failed");
let id = handle.workflow_id().to_owned();
let waiting = crate::rt::spawn({
let client = client.clone();
async move {
let started = std::time::Instant::now();
let value = client
.get_event::<u32>(&id, "ready", Duration::from_secs(50))
.await;
(value, started.elapsed())
}
});
crate::rt::sleep(Duration::from_millis(200)).await;
release.notify_one();
let (value, elapsed) = waiting.await.expect("the wait panicked");
client.close().await;
dbos.shutdown().await;
assert!(delivering, "the client's listener never proved it delivers");
assert_eq!(value.expect("read failed"), Some(1));
assert!(
elapsed < Duration::from_secs(20),
"took {elapsed:?}: the wait fell back to its re-read rather than being woken"
);
}
#[test]
fn a_client_s_application_name_is_held_to_the_shared_rule() {
let named = |app_name: &str| ClientConfig {
app_name: Some(app_name.to_owned()),
..ClientConfig::new("postgres://localhost/app")
};
assert!(named("my-app").validate().is_ok());
for bad in ["ab", "My-App", "my app"] {
let err = named(bad)
.validate()
.expect_err("the name should have been refused");
assert!(err.to_string().contains("app_name"), "{err}");
}
assert!(
ClientConfig::new("postgres://localhost/app")
.validate()
.is_ok(),
"namelessness is a setting, not a missing name"
);
}
#[test]
fn validation_reports_a_missing_url_by_the_name_of_the_variable_that_sets_it() {
let err = ClientConfig::new("").validate().unwrap_err();
assert!(err.to_string().contains(SYSTEM_DATABASE_URL_ENV), "{err}");
}
#[test]
fn a_client_is_shareable() {
fn assert_shareable<T: Send + Sync + Clone + std::fmt::Debug>() {}
assert_shareable::<Client>();
}
}