1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
//! How to reach and configure a [`DBOS`](crate::DBOS) instance.
use std::time::Duration;
use crate::serialization::Serializer;
use crate::sysdb::DEFAULT_SCHEMA;
/// Environment variable naming the system database, which [`Config::from_env`] and
/// [`ClientConfig::from_env`](crate::ClientConfig::from_env) read.
pub const SYSTEM_DATABASE_URL_ENV: &str = "DBOS_SYSTEM_DATABASE_URL";
/// Everything a [`DBOS`](crate::DBOS) instance needs.
///
/// Constructed with [`Config::new`] or [`Config::from_env`] and adjusted by functional update:
///
/// ```no_run
/// # use dbos::Config;
/// let config = Config { max_connections: 20, ..Config::from_env("my-app") };
/// ```
///
/// **Deliberately not `#[non_exhaustive]`.** That attribute forbids struct-literal construction
/// from other crates entirely, functional update included, which is exactly the form above. Adding
/// a field stays source-compatible for every caller who wrote `..Config::new(..)`, so the
/// convention is documented rather than enforced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
/// Names this application among those sharing the system database.
///
/// Load-bearing rather than cosmetic: it is the ownership key stamped on every row this
/// instance writes, it mixes into the application version, and Conductor addresses an
/// application by it. Three to 256 characters of lowercase letters, digits, dashes and
/// underscores, checked at [`launch`](crate::DBOS::launch).
pub app_name: String,
/// Connection URL for the system database.
pub database_url: String,
/// Maximum pooled connections.
pub max_connections: u32,
/// Schema holding the DBOS tables.
///
/// Deployment-wide: every application sharing a database has to agree on it.
pub schema: String,
/// Identifies this process among the executors sharing the database.
///
/// Resolved like [`app_version`](Self::app_version) and with the same precedence: this wins
/// over [`EXECUTOR_ID_ENV`](crate::EXECUTOR_ID_ENV) off DBOS Cloud and loses to it on, and
/// when neither says anything the executor is `"local"`, the id other SDKs sharing the
/// database also default to.
pub executor_id: Option<String>,
/// The version of the application's code.
///
/// **Nothing computes one.** Falling back to a hash of the application's code does not work
/// here: in Rust the only thing to hash is the executable, and a Rust build is not
/// reproducible, so the version would move under a rebuild that changed nothing — stranding the
/// previous run's `PENDING` rows with an executor that no longer exists, after hashing tens of
/// megabytes at every launch to get there. A version has to come from somewhere, so
/// [`launch`](crate::DBOS::launch) fails when neither this nor `DBOS__APPVERSION` gives it one.
///
/// `None` here therefore means "whatever the environment says", not "work it out". The
/// precedence is resolved at launch: this wins over
/// [`APP_VERSION_ENV`](crate::APP_VERSION_ENV), except on DBOS Cloud
/// ([`CLOUD_ENV`](crate::CLOUD_ENV)), where the deployment's variable wins over whatever the
/// application was built believing.
///
/// What to put here: `env!("CARGO_PKG_VERSION")` for an application that ships as a crate, or
/// the commit sha for a deployment that ships per commit. A workflow is only recovered by an
/// executor running the version that started it, which is what makes a rolling deploy safe —
/// so the value should change exactly when the code changed in a way that matters.
///
/// Setting this at all is what takes the choice away from `DBOS__APPVERSION`: the variable
/// applies only where the field is `None`. On DBOS Cloud the deployment wins either way.
pub app_version: Option<String>,
/// How payloads are encoded, by default: the built-in `rust_serde`, or the application's own
/// [`CustomSerializer`](crate::CustomSerializer).
pub serializer: Serializer,
/// Whether to use LISTEN/NOTIFY rather than polling.
///
/// CockroachDB has no LISTEN/NOTIFY and forces polling whatever this says.
pub use_listen_notify: bool,
/// Whether [`launch`](crate::DBOS::launch) brings the schema up to date.
///
/// Off suits an application whose database is migrated by a deployment step instead.
pub migrate: bool,
/// How many polling reads may run at once: half the pool by default; see
/// [`PollingConcurrency`](crate::PollingConcurrency).
pub polling_concurrency: crate::PollingConcurrency,
/// How often a caller waiting on a workflow it does not own asks whether it has finished.
///
/// `None` is one second. Workflow completion has no wakeup anywhere — no channel carries it
/// and no trigger publishes it — so looking is the only way to learn a status changed.
///
/// Zero is rejected rather than treated as "as fast as possible": it is a busy loop against
/// the database, never something a caller means.
pub outcome_poll_interval: Option<Duration>,
/// Which queues this process dequeues from. `None` is all of them.
///
/// **Configuration rather than a runtime call.** The supervisor intersects this with what it
/// reads from the `queues` table on every sweep, so the dynamic half — a queue registered
/// later, or deleted — comes for free without a second way to change the set.
///
/// What it is for: splitting one application's queues across differently-shaped processes.
/// A worker fleet sized for slow media jobs and one sized for fast API calls share a database
/// and a code base, and neither should drain the other's backlog.
///
/// `Some(vec![])` listens to **nothing**, and is not the same as `None`. It is a real setting
/// — a process that enqueues and never runs anything. An empty list never means "all": the
/// option carries that meaning, so the two cannot be confused.
///
/// [`INTERNAL_QUEUE`](crate::sysdb::INTERNAL_QUEUE) is always dequeued from, whatever this
/// says. It is where `resume`, `fork` and recovery put work, so filtering it out would strand
/// them with no way to notice.
pub listen_queues: Option<Vec<String>>,
/// How long a written key waits for company before a wakeup is pushed for it.
///
/// `None` is ten milliseconds. `Some(Duration::ZERO)` is meaningful and *not* rejected — it
/// turns coalescing off, which is a push per write. That is the one duration here where zero
/// is a setting rather than a mistake.
pub notification_coalesce: Option<Duration>,
/// How long a system-database connection may sit idle inside an open transaction before the
/// server ends its session.
///
/// Sixty seconds by default; see [`IdleTransactionTimeout`](crate::IdleTransactionTimeout).
pub idle_transaction_timeout: crate::IdleTransactionTimeout,
/// How often the scheduler re-reads the schedules table for schedules created, changed,
/// paused or deleted since its last look.
///
/// `None` is thirty seconds. For the first minute after launch the scheduler looks at least
/// once a second whatever this says, so a schedule created at startup starts promptly. Zero is
/// rejected: it would re-read the table continuously.
pub scheduler_polling_interval: Option<Duration>,
}
impl Config {
/// A configuration with the defaults, for an application named `app_name`, reaching the
/// database at `database_url`.
///
/// Leaves [`app_version`](Self::app_version) unset, which means launch takes it from
/// `DBOS__APPVERSION` and fails if that is unset too.
pub fn new(app_name: impl Into<String>, database_url: impl Into<String>) -> Self {
Self {
app_name: app_name.into(),
app_version: None,
database_url: database_url.into(),
max_connections: 10,
schema: DEFAULT_SCHEMA.to_owned(),
executor_id: None,
serializer: Serializer::default(),
use_listen_notify: true,
migrate: true,
polling_concurrency: crate::PollingConcurrency::Default,
outcome_poll_interval: None,
listen_queues: None,
notification_coalesce: None,
idle_transaction_timeout: crate::IdleTransactionTimeout::Default,
scheduler_polling_interval: None,
}
}
/// [`Config::new`], taking the database URL from [`SYSTEM_DATABASE_URL_ENV`].
///
/// That variable and no other. The identity variables — `DBOS__APPVERSION`, `DBOS__VMID`,
/// `DBOS__CLOUD` and `DBOS__APPID` — carry two underscores because they are DBOS Cloud's to
/// set, and `DBOS_APP_NAME` is a deployment's too; an end user *may* set them, but a
/// configuration is not the layer that reads them. Launch is, so that a `Config` written by
/// hand and one from here resolve to the same identity.
///
/// A missing or empty `DBOS_SYSTEM_DATABASE_URL` leaves [`database_url`](Self::database_url)
/// empty rather than failing here, so that a caller who sets it afterwards is not forced
/// through an error path. [`launch`](crate::DBOS::launch) is where an empty URL is reported,
/// which is also where it would have failed anyway.
pub fn from_env(app_name: impl Into<String>) -> Self {
Self::new(
app_name,
std::env::var(SYSTEM_DATABASE_URL_ENV).unwrap_or_default(),
)
}
/// Checks what can be checked before anything is connected.
///
/// Not the identity — the name, version and executor id are resolved against the environment
/// at launch, and [`identity`](crate::identity) is what validates the values that resolution
/// arrives at rather than the ones the configuration happened to carry.
pub(crate) fn validate(&self) -> crate::Result<()> {
if self.database_url.is_empty() {
return Err(crate::Error::Config(format!(
"no database URL: set `database_url`, or the {SYSTEM_DATABASE_URL_ENV} environment \
variable if the configuration came from `Config::from_env`"
)));
}
if self.schema.is_empty() {
return Err(crate::Error::Config("`schema` cannot be empty".to_owned()));
}
if self.max_connections == 0 {
return Err(crate::Error::Config(
"`max_connections` cannot be zero".to_owned(),
));
}
// Unlike `notification_coalesce`, where zero turns coalescing off and is a setting, a zero
// poll interval is a busy loop against the database rather than a faster answer.
if self.outcome_poll_interval == Some(Duration::ZERO) {
return Err(crate::Error::Config(
"`outcome_poll_interval` cannot be zero".to_owned(),
));
}
if self.scheduler_polling_interval == Some(Duration::ZERO) {
return Err(crate::Error::Config(
"`scheduler_polling_interval` cannot be zero".to_owned(),
));
}
self.idle_transaction_timeout
.validate()
.map_err(|error| crate::Error::Config(error.to_string()))?;
self.serializer.validate()
}
/// How often an adopting caller looks, resolved.
pub(crate) fn outcome_poll_interval(&self) -> Duration {
self.outcome_poll_interval
.unwrap_or(DEFAULT_OUTCOME_POLL_INTERVAL)
}
/// How often the scheduler re-reads the schedules table, resolved.
pub(crate) fn scheduler_polling_interval(&self) -> Duration {
self.scheduler_polling_interval
.unwrap_or(DEFAULT_SCHEDULER_POLLING_INTERVAL)
}
}
/// How often the scheduler re-reads the schedules table when nothing else is configured.
pub(crate) const DEFAULT_SCHEDULER_POLLING_INTERVAL: Duration = Duration::from_secs(30);
/// How often a workflow's outcome is polled when nothing else is configured.
pub(crate) const DEFAULT_OUTCOME_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[cfg(test)]
mod tests {
use super::*;
/// The name is public contract, read by both `from_env` constructors, so it is pinned as a
/// literal rather than compared with itself.
#[test]
fn the_system_database_url_is_read_from_dbos_system_database_url() {
assert_eq!(SYSTEM_DATABASE_URL_ENV, "DBOS_SYSTEM_DATABASE_URL");
}
#[test]
fn validation_reports_a_missing_url_by_the_name_of_the_variable_that_sets_it() {
let err = Config::new("app", "").validate().unwrap_err();
assert!(err.to_string().contains(SYSTEM_DATABASE_URL_ENV), "{err}");
}
#[test]
fn a_zero_outcome_poll_interval_is_a_busy_loop_and_is_refused() {
let config = |outcome_poll_interval| Config {
outcome_poll_interval,
..Config::new("app", "postgres://x")
};
assert_eq!(
config(None).outcome_poll_interval(),
Duration::from_secs(1),
"the default polling interval"
);
assert_eq!(
config(Some(Duration::from_millis(250))).outcome_poll_interval(),
Duration::from_millis(250)
);
let err = config(Some(Duration::ZERO)).validate().unwrap_err();
assert!(
err.to_string().contains("outcome_poll_interval"),
"unlike `notification_coalesce`, zero here is not a setting: {err}"
);
}
/// The server takes a 32-bit count of milliseconds, so anything longer is refused here rather
/// than failing every connection, and a zero timeout is refused rather than read as the
/// server's setting, which has its own variant.
#[test]
fn an_idle_transaction_timeout_out_of_the_servers_range_is_refused() {
use crate::IdleTransactionTimeout::{After, ServerSetting};
let config = |idle_transaction_timeout| Config {
idle_transaction_timeout,
app_version: Some("v1".to_owned()),
..Config::new("app", "postgres://x")
};
assert!(config(ServerSetting).validate().is_ok());
assert!(
config(After(crate::IdleTransactionTimeout::MAX))
.validate()
.is_ok()
);
for refused in [Duration::ZERO, Duration::from_millis(i32::MAX as u64 + 1)] {
let err = config(After(refused)).validate().unwrap_err();
assert!(
err.to_string().contains("idle_transaction_timeout"),
"{err}"
);
}
}
#[test]
fn functional_update_is_the_documented_way_to_adjust_a_config() {
let config = Config {
max_connections: 20,
..Config::new("app", "postgres://x")
};
assert_eq!(config.max_connections, 20);
assert_eq!(config.app_name, "app");
}
}