use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use crate::config::{DATABASE_URL_ENV, Serializer};
use crate::connection::Connection;
use crate::error::{Error, Result};
use crate::handle::WorkflowHandle;
use crate::identity::validate_app_name;
use crate::serialization::encode;
use crate::sysdb::DEFAULT_SCHEMA;
use crate::sysdb::types::{NewWorkflow, Timestamp, VersionInfo, WorkflowStatus};
use crate::workflow::{Enqueue, Submitted, encode_attributes, init_or_join, new_row};
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: Option<u32>,
pub outcome_poll_interval: Option<Duration>,
pub notification_coalesce: Option<Duration>,
}
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: true,
polling_concurrency: None,
outcome_poll_interval: None,
notification_coalesce: None,
}
}
pub fn from_env() -> Self {
Self::new(std::env::var(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 {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()));
}
if self.outcome_poll_interval == Some(Duration::ZERO) {
return Err(Error::Config(
"`outcome_poll_interval` cannot be zero".to_owned(),
));
}
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 async fn enqueue<P, R, E>(
&self,
workflow: &str,
queue: &str,
input: P,
) -> Result<WorkflowHandle<R, E>>
where
P: Serialize,
{
self.enqueue_with(workflow, input, EnqueueOptions::new(queue))
.await
}
pub async fn enqueue_with<P, R, E>(
&self,
workflow: &str,
input: P,
options: EnqueueOptions<'_>,
) -> Result<WorkflowHandle<R, E>>
where
P: Serialize,
{
options.queue.validate()?;
let input = encode(&input, "argument")?;
let attributes = encode_attributes(options.attributes)?;
let generated;
let workflow_id = match options.workflow_id {
Some(id) => id,
None => {
generated = uuid::Uuid::new_v4().to_string();
&generated
}
};
let new = NewWorkflow {
name: Some(workflow),
class_name: options.class_name,
config_name: options.config_name,
input: Some(&input),
serialization: Some(self.0.serializer().name()),
executor_id: None,
application_name: options.app_name.or_else(|| self.0.app_name()),
application_version: options.app_version,
timeout: options.timeout,
deadline: None,
attributes: attributes.as_deref(),
..new_row(workflow_id, Some(&options.queue))
};
match init_or_join(&self.0, &new, options.queue.duplication_policy, None).await? {
Submitted::Created(_) => {
tracing::debug!(
workflow_id,
workflow,
queue = options.queue.name,
"the workflow is enqueued"
);
Ok(WorkflowHandle::polling(
Arc::clone(&self.0),
workflow_id.to_owned(),
true,
))
}
Submitted::Joined(holder) => {
Ok(WorkflowHandle::polling(Arc::clone(&self.0), holder, true))
}
}
}
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 async fn list_application_versions(&self) -> Result<Vec<VersionInfo>> {
self.0
.sysdb()
.list_application_versions()
.await
.map_err(Error::SystemDatabase)
}
pub async fn latest_application_version(&self) -> Result<Option<VersionInfo>> {
self.0
.sysdb()
.get_latest_application_version(self.0.app_name())
.await
.map_err(Error::SystemDatabase)
}
pub async fn set_latest_application_version(&self, version_name: &str) -> Result<()> {
self.0
.sysdb()
.update_application_version_timestamp(version_name, Timestamp::now(), self.0.app_name())
.await
.map_err(Error::SystemDatabase)
}
pub async fn set_latest_application_version_for(
&self,
version_name: &str,
application_name: &str,
) -> Result<()> {
self.0
.sysdb()
.update_application_version_timestamp(
version_name,
Timestamp::now(),
Some(application_name),
)
.await
.map_err(Error::SystemDatabase)
}
pub(crate) fn connection(&self) -> &Arc<Connection> {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct EnqueueOptions<'a> {
pub queue: Enqueue<'a>,
pub workflow_id: Option<&'a str>,
pub class_name: Option<&'a str>,
pub config_name: Option<&'a str>,
pub app_name: Option<&'a str>,
pub app_version: Option<&'a str>,
pub timeout: Option<Duration>,
pub attributes: Option<&'a serde_json::Map<String, serde_json::Value>>,
}
impl<'a> EnqueueOptions<'a> {
pub fn new(queue: &'a str) -> Self {
Self::on(Enqueue::new(queue))
}
pub fn on(queue: Enqueue<'a>) -> Self {
Self {
queue,
workflow_id: None,
class_name: None,
config_name: None,
app_name: None,
app_version: None,
timeout: None,
attributes: None,
}
}
}
impl<'a> From<Enqueue<'a>> for EnqueueOptions<'a> {
fn from(queue: Enqueue<'a>) -> Self {
Self::on(queue)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workflow::DuplicationPolicy;
#[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's waits are woken rather than polled, as Go's are"
);
assert_eq!(config.outcome_poll_interval(), Duration::from_secs(1));
}
#[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(DATABASE_URL_ENV), "{err}");
}
#[test]
fn an_enqueue_asks_for_a_queue_and_nothing_else() {
let options = EnqueueOptions::new("work");
assert_eq!(options.queue, Enqueue::new("work"));
assert_eq!(options.workflow_id, None);
assert_eq!(options.app_version, None);
assert_eq!(options.timeout, None);
assert_eq!(
options.queue.duplication_policy,
DuplicationPolicy::Reject,
"a caller who did not think about deduplication should hear that a key was taken"
);
}
#[test]
fn options_over_a_queue_keep_the_queue_they_were_given() {
let queue = Enqueue {
priority: Some(3),
..Enqueue::new("billing")
};
let options = EnqueueOptions {
workflow_id: Some("order-42"),
..EnqueueOptions::on(queue.clone())
};
assert_eq!(options.queue, queue);
assert_eq!(options.queue.name, "billing");
assert_eq!(options.workflow_id, Some("order-42"));
assert_eq!(EnqueueOptions::from(queue.clone()).queue, queue);
}
#[test]
fn a_client_is_shareable() {
fn assert_shareable<T: Send + Sync + Clone + std::fmt::Debug>() {}
assert_shareable::<Client>();
}
}