use std::time::Duration;
use crate::{Result, error};
pub(crate) const DEFAULT_POOL_MIN: usize = 1;
pub(crate) const DEFAULT_POOL_MAX: usize = 4;
pub(crate) const DEFAULT_ACQUIRE_TIMEOUT: Duration = Duration::from_millis(5_000);
pub(crate) const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) const MAX_POOL_SIZE: usize = 65_536;
pub(crate) const MAX_POOL_TIMEOUT_MS: u64 = 365 * 24 * 3600 * 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PoolReap {
Auto,
Manual,
}
#[derive(Debug, Clone)]
pub(crate) struct PoolConfig {
pub(crate) sender_pool_min: usize,
pub(crate) sender_pool_max: usize,
pub(crate) query_pool_min: usize,
pub(crate) query_pool_max: usize,
pub(crate) acquire_timeout: Duration,
pub(crate) idle_timeout: Duration,
pub(crate) pool_reap: PoolReap,
pub(crate) lazy_connect: bool,
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
sender_pool_min: DEFAULT_POOL_MIN,
sender_pool_max: DEFAULT_POOL_MAX,
query_pool_min: DEFAULT_POOL_MIN,
query_pool_max: DEFAULT_POOL_MAX,
acquire_timeout: DEFAULT_ACQUIRE_TIMEOUT,
idle_timeout: DEFAULT_IDLE_TIMEOUT,
pool_reap: PoolReap::Auto,
lazy_connect: false,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ParsedConf {
pub(crate) pool: PoolConfig,
pub(crate) sf_disk: bool,
}
pub(crate) fn parse(conf: &str) -> Result<ParsedConf> {
let Some((service, params)) = conf.split_once("::") else {
return Err(error::fmt!(
ConfigError,
"Invalid QuestDb pool config: missing '::' service separator"
));
};
if !is_qwp_ws_schema(service) {
return Err(error::fmt!(
ConfigError,
"The QuestDb pool requires a QWP/WebSocket connect string \
(schema must be 'ws' or 'wss', got {:?})",
service
));
}
let mut pool = PoolConfig::default();
let mut sf_dir_specified = false;
let mut query_pool_min_specified = false;
let mut initial_connect_retry: Option<String> = None;
walk_params(params, |key, value| {
if key == "sf_dir" {
sf_dir_specified = true;
}
match key {
"request_durable_ack" => {
let _ = parse_on_off("request_durable_ack", value)?;
}
"qwp_ws_progress" if value != "background" => {
return Err(error::fmt!(
ConfigError,
"The QuestDb pool requires \"qwp_ws_progress=background\" (got {:?})",
value
));
}
"sender_pool_min" => {
pool.sender_pool_min = parse_pool_usize(key, value)?;
}
"sender_pool_max" => {
let value = parse_pool_usize(key, value)?;
if value == 0 {
return Err(error::fmt!(
ConfigError,
"\"sender_pool_max\" must be greater than 0"
));
}
pool.sender_pool_max = value;
}
"query_pool_min" => {
pool.query_pool_min = parse_pool_usize(key, value)?;
query_pool_min_specified = true;
}
"query_pool_max" => {
let value = parse_pool_usize(key, value)?;
if value == 0 {
return Err(error::fmt!(
ConfigError,
"\"query_pool_max\" must be greater than 0"
));
}
pool.query_pool_max = value;
}
"acquire_timeout_ms" => {
pool.acquire_timeout = parse_pool_timeout_ms("acquire_timeout_ms", value)?;
}
"idle_timeout_ms" => {
pool.idle_timeout = parse_pool_timeout_ms("idle_timeout_ms", value)?;
}
"pool_reap" => {
pool.pool_reap = match value {
"auto" => PoolReap::Auto,
"manual" => PoolReap::Manual,
other => {
return Err(error::fmt!(
ConfigError,
"Invalid value for \"pool_reap\" (expected 'auto' or 'manual'): {:?}",
other
));
}
};
}
"lazy_connect" => {
pool.lazy_connect = match value.to_ascii_lowercase().as_str() {
"on" | "true" => true,
"off" | "false" => false,
other => {
return Err(error::fmt!(
ConfigError,
"Invalid value for \"lazy_connect\" (expected \
'true' or 'false'): {:?}",
other
));
}
};
}
"initial_connect_retry" => {
initial_connect_retry = Some(value.to_owned());
}
"pool_size" | "pool_max" | "pool_idle_timeout_ms" => {
return Err(error::fmt!(
ConfigError,
"{:?} was renamed; use \"sender_pool_min\" / \
\"sender_pool_max\" / \"query_pool_min\" / \
\"query_pool_max\" / \"idle_timeout_ms\" instead",
key
));
}
other if other.starts_with("pool_") => {
return Err(error::fmt!(
ConfigError,
"Unknown pool config key {:?}",
other
));
}
_ => {
}
}
Ok(())
})?;
if pool.lazy_connect {
let blocking_mode = initial_connect_retry
.as_deref()
.filter(|mode| crate::ingress::initial_connect_retry_value_is_blocking(mode));
if let Some(mode) = blocking_mode {
return Err(error::fmt!(
ConfigError,
"conflicting configuration: lazy_connect=true needs a non-blocking \
startup, but initial_connect_retry={} makes the initial connect \
block / fail-fast. Resolve by removing initial_connect_retry \
(lazy_connect implies initial_connect_retry=async) or setting \
initial_connect_retry=async.",
mode
));
}
if query_pool_min_specified && pool.query_pool_min > 0 {
return Err(error::fmt!(
ConfigError,
"conflicting configuration: lazy_connect=true needs query_pool_min=0 \
(the read pool connects lazily on first use and must not fail-fast \
at startup), but query_pool_min={} was set. Resolve by removing \
query_pool_min (lazy_connect defaults it to 0) or setting \
query_pool_min=0.",
pool.query_pool_min
));
}
pool.query_pool_min = 0;
}
if pool.sender_pool_min > pool.sender_pool_max {
return Err(error::fmt!(
ConfigError,
"\"sender_pool_min\" ({}) must not exceed \"sender_pool_max\" ({})",
pool.sender_pool_min,
pool.sender_pool_max
));
}
if pool.query_pool_min > pool.query_pool_max {
return Err(error::fmt!(
ConfigError,
"\"query_pool_min\" ({}) must not exceed \"query_pool_max\" ({})",
pool.query_pool_min,
pool.query_pool_max
));
}
Ok(ParsedConf {
pool,
sf_disk: sf_dir_specified,
})
}
fn parse_on_off(key: &str, value: &str) -> Result<bool> {
match value {
"on" => Ok(true),
"off" => Ok(false),
_ => Err(error::fmt!(
ConfigError,
"Invalid value for {:?} (expected 'on' or 'off'): {:?}",
key,
value
)),
}
}
fn is_qwp_ws_schema(service: &str) -> bool {
service.eq_ignore_ascii_case("ws") || service.eq_ignore_ascii_case("wss")
}
fn parse_pool_timeout_ms(key: &str, value: &str) -> Result<Duration> {
let millis: u64 = value.parse().map_err(|_| {
error::fmt!(
ConfigError,
"Invalid value for {:?} (expected an unsigned integer): {:?}",
key,
value
)
})?;
if millis > MAX_POOL_TIMEOUT_MS {
return Err(error::fmt!(
ConfigError,
"{:?} ({}) exceeds maximum ({})",
key,
millis,
MAX_POOL_TIMEOUT_MS
));
}
Ok(Duration::from_millis(millis))
}
fn parse_pool_usize(key: &str, value: &str) -> Result<usize> {
let parsed: usize = value.parse().map_err(|_| {
error::fmt!(
ConfigError,
"Invalid value for {:?} (expected an unsigned integer): {:?}",
key,
value
)
})?;
if parsed > MAX_POOL_SIZE {
return Err(error::fmt!(
ConfigError,
"{:?} ({}) exceeds maximum ({})",
key,
parsed,
MAX_POOL_SIZE
));
}
Ok(parsed)
}
fn walk_params<F>(params: &str, mut visit: F) -> Result<()>
where
F: FnMut(&str, &str) -> Result<()>,
{
let mut pos = 0usize;
while pos < params.len() {
let Some(eq_rel) = params[pos..].find('=') else {
return Err(error::fmt!(
ConfigError,
"Invalid QuestDb pool config: parameter without '=' at position {}",
pos
));
};
let key = ¶ms[pos..pos + eq_rel];
pos = pos + eq_rel + 1;
let mut value = String::new();
while pos < params.len() {
let rest = ¶ms[pos..];
let mut chars = rest.char_indices();
let (_, ch) = chars.next().expect("pos is within params");
if ch == ';' {
let next_pos = pos + ch.len_utf8();
if params[next_pos..].starts_with(';') {
value.push(';');
pos = next_pos + 1;
continue;
}
pos = next_pos;
break;
}
value.push(ch);
pos += ch.len_utf8();
}
visit(key, value.as_str())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ErrorCode;
fn parse_ok(conf: &str) -> ParsedConf {
parse(conf).unwrap_or_else(|e| panic!("expected ok, got {e}"))
}
fn parse_err(conf: &str) -> crate::Error {
match parse(conf) {
Ok(_) => panic!("expected error for {conf:?}"),
Err(e) => e,
}
}
#[test]
fn defaults() {
let p = parse_ok("ws::addr=localhost:9000;");
assert_eq!(p.pool.sender_pool_min, DEFAULT_POOL_MIN);
assert_eq!(p.pool.sender_pool_max, DEFAULT_POOL_MAX);
assert_eq!(p.pool.query_pool_min, DEFAULT_POOL_MIN);
assert_eq!(p.pool.query_pool_max, DEFAULT_POOL_MAX);
assert_eq!(p.pool.acquire_timeout, DEFAULT_ACQUIRE_TIMEOUT);
assert_eq!(p.pool.idle_timeout, DEFAULT_IDLE_TIMEOUT);
assert_eq!(p.pool.pool_reap, PoolReap::Auto);
assert!(!p.sf_disk);
}
#[test]
fn parses_pool_knobs() {
let p = parse_ok(
"ws::addr=localhost:9000;sender_pool_min=4;sender_pool_max=8;\
query_pool_min=2;query_pool_max=6;acquire_timeout_ms=250;\
idle_timeout_ms=10000;pool_reap=manual;",
);
assert_eq!(p.pool.sender_pool_min, 4);
assert_eq!(p.pool.sender_pool_max, 8);
assert_eq!(p.pool.query_pool_min, 2);
assert_eq!(p.pool.query_pool_max, 6);
assert_eq!(p.pool.acquire_timeout, Duration::from_millis(250));
assert_eq!(p.pool.idle_timeout, Duration::from_secs(10));
assert_eq!(p.pool.pool_reap, PoolReap::Manual);
}
#[test]
fn renamed_legacy_keys_are_rejected_with_guidance() {
for key in ["pool_size", "pool_max", "pool_idle_timeout_ms"] {
let conf = format!("ws::addr=localhost:9000;{key}=2;");
let err = parse_err(&conf);
assert_eq!(err.code(), ErrorCode::ConfigError, "{key}");
assert!(err.msg().contains("renamed"), "{key}: {}", err.msg());
}
}
#[test]
fn pool_min_zero_is_allowed() {
let p = parse_ok("ws::addr=localhost:9000;sender_pool_min=0;query_pool_min=0;");
assert_eq!(p.pool.sender_pool_min, 0);
assert_eq!(p.pool.query_pool_min, 0);
}
#[test]
fn acquire_timeout_zero_is_allowed() {
let p = parse_ok("ws::addr=localhost:9000;acquire_timeout_ms=0;");
assert_eq!(p.pool.acquire_timeout, Duration::ZERO);
}
#[test]
fn refuses_non_qwp_ws_schema() {
let err = parse_err("http::addr=localhost:9000;");
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("QWP/WebSocket"));
}
#[test]
fn sf_dir_selects_disk_store_and_forward_without_changing_pool_max() {
let p = parse_ok("ws::addr=localhost:9000;sf_dir=/tmp/qdb-sf;");
assert!(p.sf_disk);
assert_eq!(p.pool.sender_pool_min, 1);
assert_eq!(p.pool.sender_pool_max, DEFAULT_POOL_MAX);
}
#[test]
fn sf_dir_accepts_multi_slot_pool() {
let p = parse_ok(
"ws::addr=localhost:9000;sf_dir=/tmp/qdb-sf;sender_pool_min=4;sender_pool_max=8;sender_id=abc;",
);
assert!(p.sf_disk);
assert_eq!(p.pool.sender_pool_min, 4);
assert_eq!(p.pool.sender_pool_max, 8);
}
#[test]
fn accepts_sf_keys_without_sf_dir() {
for key in [
"sender_id",
"sf_max_segment_bytes",
"sf_max_total_bytes",
"sf_durability",
"sf_sync_interval_millis",
"sf_append_deadline_millis",
] {
let conf = format!("ws::addr=localhost:9000;{key}=whatever;");
let p = parse_ok(&conf);
assert!(!p.sf_disk, "{key} must not imply disk-backed SF");
assert_eq!(p.pool.sender_pool_max, DEFAULT_POOL_MAX, "key {key}");
}
}
#[test]
fn refuses_pool_max_zero() {
for key in ["sender_pool_max", "query_pool_max"] {
let conf = format!("ws::addr=localhost:9000;{key}=0;");
let err = parse_err(&conf);
assert_eq!(err.code(), ErrorCode::ConfigError, "{key}");
assert!(err.msg().contains(key), "{key}: {}", err.msg());
}
}
#[test]
fn refuses_pool_min_above_pool_max() {
let err = parse_err("ws::addr=localhost:9000;sender_pool_min=10;sender_pool_max=5;");
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("sender_pool_min") && err.msg().contains("sender_pool_max"));
let err = parse_err("ws::addr=localhost:9000;query_pool_min=10;query_pool_max=5;");
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("query_pool_min") && err.msg().contains("query_pool_max"));
}
#[test]
fn invalid_pool_reap_value() {
let err = parse_err("ws::addr=localhost:9000;pool_reap=sometimes;");
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("pool_reap"));
}
#[test]
fn ignores_unknown_keys() {
let _ = parse_ok("ws::addr=localhost:9000;auth_timeout=5000;some_future_key=value;");
}
#[test]
fn parses_request_durable_ack() {
let _ = parse_ok("ws::addr=localhost:9000;");
let _ = parse_ok("ws::addr=localhost:9000;request_durable_ack=on;");
let _ = parse_ok("ws::addr=localhost:9000;request_durable_ack=off;");
}
#[test]
fn refuses_invalid_request_durable_ack_value() {
let err = parse_err("ws::addr=localhost:9000;request_durable_ack=true;");
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("request_durable_ack"));
}
#[test]
fn refuses_manual_progress_mode() {
let err = parse_err("ws::addr=localhost:9000;qwp_ws_progress=manual;");
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("qwp_ws_progress"));
}
#[test]
fn accepts_explicit_background_progress_mode() {
let _ = parse_ok("ws::addr=localhost:9000;qwp_ws_progress=background;");
}
#[test]
fn doubled_semicolon_in_value() {
let _ = parse_ok("ws::addr=localhost:9000;password=a;;b;sender_pool_min=2;");
}
}