From 0346498d82ab55b830d04334b85f3b15991bee66 Mon Sep 17 00:00:00 2001 From: Markus Schirp Date: Sat, 10 Oct 2026 23:04:50 +0000 Subject: [PATCH] Change to use PG syntax for wait available timeout --- Cargo.lock | 1 - Cargo.toml | 1 - pg-ephemeral/CHANGELOG.md | 17 + pg-ephemeral/Cargo.toml | 1 - pg-ephemeral/README.md | 29 +- pg-ephemeral/src/config.rs | 63 +-- pg-ephemeral/src/container.rs | 43 +- pg-ephemeral/src/definition.rs | 45 +- pg-ephemeral/src/lib.rs | 1 + pg-ephemeral/src/time_unit.rs | 409 +++++++++++++++++ pg-ephemeral/tests/base.rs | 10 +- pg-ephemeral/tests/time_unit.rs | 775 ++++++++++++++++++++++++++++++++ 12 files changed, 1311 insertions(+), 84 deletions(-) create mode 100644 pg-ephemeral/src/time_unit.rs create mode 100644 pg-ephemeral/tests/time_unit.rs diff --git a/Cargo.lock b/Cargo.lock index d0864a93..d4368b03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2966,7 +2966,6 @@ dependencies = [ "http", "indexmap 2.14.0", "indoc", - "jiff", "log", "msqlx", "nom 8.0.0", diff --git a/Cargo.toml b/Cargo.toml index 1a5c4da7..8f477cf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,6 @@ ipnet = { version = "2", features = ["serde"] } http = "1.4" indoc = "2" itertools = "0.14" -jiff = { version = "0.2", default-features = false, features = ["std"] } log = "0.4" typed-reqwest = { version = "0.2.0", path = "typed-reqwest" } mmigration = { path = "mmigration" } diff --git a/pg-ephemeral/CHANGELOG.md b/pg-ephemeral/CHANGELOG.md index bb0fdfe7..2295d2c8 100644 --- a/pg-ephemeral/CHANGELOG.md +++ b/pg-ephemeral/CHANGELOG.md @@ -8,6 +8,20 @@ now carry the image reference as `Box` rather than inline. Code that pattern-matches these variants needs to dereference the binding. `Display` output is unchanged. +- `wait_available_timeout` now uses PostgreSQL's time-unit syntax, the one + timeout parameters such as `statement_timeout` accept: an integer directly + followed by one of `us`, `ms`, `s`, `min`, `h` or `d`, such as `30s`, `500ms` + or `2min`. Every accepted value means exactly what it means to PostgreSQL: + it is rounded half to even to whole milliseconds, `0` disables the timeout, + and the maximum is `2147483647ms` (about 24.8 days). Values outside this + syntax now fail to load, including `1m` (write `1min`), compound values such + as `1m30s`, a space between number and unit, and units such as `w`. +- The `wait_available_timeout` fields of `Definition` and `config::Instance`, + and the timeout parameters of `Container::wait_available` and + `Container::set_superuser_password`, are now `definition::Timeout`, whose + `Disabled` variant waits without limit. The `Definition::wait_available_timeout` + builder accepts `impl Into`, so existing calls passing a + `std::time::Duration` compile unchanged. ### Added @@ -26,6 +40,9 @@ Parameters feed the seed cache key, so a top-level parameter invalidates the cached images of every instance. +- `time_unit::Value`, parsing the subset of PostgreSQL's time-unit parameter + syntax used by `wait_available_timeout`, and `definition::Timeout`, giving + such a value the meaning of a PostgreSQL millisecond timeout parameter. ### Changed diff --git a/pg-ephemeral/Cargo.toml b/pg-ephemeral/Cargo.toml index 10b9318a..654c7d0a 100644 --- a/pg-ephemeral/Cargo.toml +++ b/pg-ephemeral/Cargo.toml @@ -25,7 +25,6 @@ env_logger.workspace = true git-proc.workspace = true hex.workspace = true indexmap = { version = "2.13", features = ["serde"] } -jiff.workspace = true log.workspace = true nom.workspace = true nom-language.workspace = true diff --git a/pg-ephemeral/README.md b/pg-ephemeral/README.md index 107fceca..0eabfb82 100644 --- a/pg-ephemeral/README.md +++ b/pg-ephemeral/README.md @@ -96,7 +96,7 @@ cache = { type = "none" } | `cache_registry` | OCI registry prefix for cache images (e.g. `"ghcr.io/myorg"`). See [Sharing cache across machines](#sharing-cache-across-machines). | | `parameters` | PostgreSQL server parameters inherited by every instance. See [Server parameters](#server-parameters). | | `ssl_config` | SSL configuration with `hostname` field ([example](https://github.com/mbj/mrs/tree/main/pg-ephemeral/examples/08-ssl)) | -| `wait_available_timeout` | How long to wait for PostgreSQL to accept connections (e.g. `"30s"`) | +| `wait_available_timeout` | How long to wait for PostgreSQL to accept connections (e.g. `"30s"`, default `"10s"`). See [Wait timeout](#wait-timeout). | ### Backend selection @@ -155,6 +155,33 @@ When `ssl_config` is set, pg-ephemeral controls the `ssl`, `ssl_cert_file`, `ssl and `ssl_ca_file` parameters itself; setting any of them in `parameters` — at any layer — is rejected at launch. +### Wait timeout + +`wait_available_timeout` is written in PostgreSQL's own +[syntax for time-valued parameters](https://www.postgresql.org/docs/current/config-setting.html#CONFIG-SETTING-NAMES-VALUES), +the one `statement_timeout` accepts, restricted to its unambiguous form: an integer +directly followed by one of the units `us`, `ms`, `s`, `min`, `h` or `d`. + +```toml +wait_available_timeout = "30s" + +[instances.slow] +wait_available_timeout = "2min" +``` + +A value means exactly what it would mean to `statement_timeout`: + +* It is rounded half to even to whole milliseconds, so `1500us` waits `2ms`. +* `0` in any unit, or a value that rounds to it such as `400us`, disables the timeout: + pg-ephemeral waits until PostgreSQL accepts connections, however long that takes. +* The maximum is `2147483647ms`, about 24.8 days. + +Spellings outside that form are rejected, including some PostgreSQL itself accepts: +a space between number and unit (`30 s`), a number without unit, fractions (`1.5s`), +signs, leading zeros (PostgreSQL reads `010s` as octal, 8 seconds), and hexadecimal. +So are spellings common in other tools that PostgreSQL rejects too: `1m` (write `1min`), +compound values such as `1m30s`, and units such as `w`. + ### Seed types Seeds run in declaration order inside the container. Whenever a seed step exits, diff --git a/pg-ephemeral/src/config.rs b/pg-ephemeral/src/config.rs index 7b1280f4..257a0854 100644 --- a/pg-ephemeral/src/config.rs +++ b/pg-ephemeral/src/config.rs @@ -26,7 +26,7 @@ pub struct Instance { pub superuser: pg_client::User, pub image: Image, pub cross_container_access: bool, - pub wait_available_timeout: std::time::Duration, + pub wait_available_timeout: crate::definition::Timeout, } impl Instance { @@ -42,7 +42,9 @@ impl Instance { database: pg_client::Database::POSTGRES, image, cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), + wait_available_timeout: crate::definition::Timeout::After( + std::time::Duration::from_secs(10), + ), } } @@ -324,31 +326,6 @@ pub struct SslConfigDefinition { pub hostname: pg_client::config::HostName, } -/// Deserializes a duration from its unit-suffixed form, such as `30s`, -/// `1m30s` or `500ms`, as well as ISO 8601 (`PT30S`). -mod duration { - pub(super) fn deserialize<'de, D>( - deserializer: D, - ) -> Result, D::Error> - where - D: serde::Deserializer<'de>, - { - let Some(text) = - as serde::Deserialize>::deserialize(deserializer)? - else { - return Ok(None); - }; - - let signed = text - .parse::() - .map_err(serde::de::Error::custom)?; - - std::time::Duration::try_from(signed) - .map(Some) - .map_err(serde::de::Error::custom) - } -} - #[derive(Debug, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct InstanceDefinition { @@ -359,8 +336,7 @@ pub struct InstanceDefinition { #[serde(default)] pub seeds: indexmap::IndexMap, pub ssl_config: Option, - #[serde(default, with = "duration")] - pub wait_available_timeout: Option, + pub wait_available_timeout: Option, } impl InstanceDefinition { @@ -431,7 +407,9 @@ impl InstanceDefinition { .wait_available_timeout .or(self.wait_available_timeout) .or(defaults.wait_available_timeout) - .unwrap_or(std::time::Duration::from_secs(10)); + .unwrap_or(crate::definition::Timeout::After( + std::time::Duration::from_secs(10), + )); Ok(Instance { application_name: None, @@ -457,8 +435,7 @@ pub struct Config { #[serde(default)] parameters: pg_client::parameter::Map, ssl_config: Option, - #[serde(default, with = "duration")] - wait_available_timeout: Option, + wait_available_timeout: Option, instances: Option>, } @@ -946,25 +923,3 @@ mod test { ); } } - -#[cfg(test)] -mod duration_tests { - use super::*; - - #[test] - fn parses_unit_suffixed_durations() { - for (text, expected) in [ - ("30s", std::time::Duration::from_secs(30)), - ("1m30s", std::time::Duration::from_secs(90)), - ("2m", std::time::Duration::from_secs(120)), - ("500ms", std::time::Duration::from_millis(500)), - ("1h", std::time::Duration::from_secs(3600)), - ("PT30S", std::time::Duration::from_secs(30)), - ] { - let config = Config::load_toml(format!("wait_available_timeout = \"{text}\"\n")) - .unwrap_or_else(|error| panic!("{text}: {error}")); - - assert_eq!(Some(expected), config.wait_available_timeout, "{text}"); - } - } -} diff --git a/pg-ephemeral/src/container.rs b/pg-ephemeral/src/container.rs index 48d83793..c1598cb2 100644 --- a/pg-ephemeral/src/container.rs +++ b/pg-ephemeral/src/container.rs @@ -403,16 +403,24 @@ impl Container { }) } - pub async fn wait_available(&self, timeout: std::time::Duration) -> Result<(), Error> { + pub async fn wait_available(&self, timeout: crate::definition::Timeout) -> Result<(), Error> { let config = self.client_config.to_sqlx_connect_options().unwrap(); let start = std::time::Instant::now(); - let max_duration = timeout; let sleep_duration = std::time::Duration::from_millis(100); let mut last_error: Option = None; - while start.elapsed() <= max_duration { + loop { + if let crate::definition::Timeout::After(duration) = timeout + && start.elapsed() > duration + { + return Err(Error::ConnectionTimeout { + timeout: duration, + source: last_error, + }); + } + log::trace!("connection attempt"); match sqlx::ConnectOptions::connect(&config).await { Ok(connection) => { @@ -434,11 +442,6 @@ impl Container { } tokio::time::sleep(sleep_duration).await; } - - Err(Error::ConnectionTimeout { - timeout: max_duration, - source: last_error, - }) } pub async fn exec_schema_dump( @@ -829,12 +832,23 @@ impl Container { Ok(()) } - async fn wait_for_container_socket(&self, timeout: std::time::Duration) -> Result<(), Error> { + async fn wait_for_container_socket( + &self, + timeout: crate::definition::Timeout, + ) -> Result<(), Error> { let start = std::time::Instant::now(); - let max_duration = timeout; let sleep_duration = std::time::Duration::from_millis(100); - while start.elapsed() <= max_duration { + loop { + if let crate::definition::Timeout::After(duration) = timeout + && start.elapsed() > duration + { + return Err(Error::ConnectionTimeout { + timeout: duration, + source: None, + }); + } + if self .container .exec("pg_isready") @@ -850,11 +864,6 @@ impl Container { } tokio::time::sleep(sleep_duration).await; } - - Err(Error::ConnectionTimeout { - timeout: max_duration, - source: None, - }) } /// Set the superuser password using peer authentication via Unix domain socket. @@ -864,7 +873,7 @@ impl Container { pub async fn set_superuser_password( &self, password: &pg_client::config::Password, - timeout: std::time::Duration, + timeout: crate::definition::Timeout, ) -> Result<(), Error> { self.wait_for_container_socket(timeout).await?; diff --git a/pg-ephemeral/src/definition.rs b/pg-ephemeral/src/definition.rs index 88bd3451..7a8b7033 100644 --- a/pg-ephemeral/src/definition.rs +++ b/pg-ephemeral/src/definition.rs @@ -15,6 +15,43 @@ pub enum SeedApplyError { EnvVariableValue(#[from] cmd_proc::EnvVariableValueError), } +/// How long to wait, with the meaning PostgreSQL gives a millisecond timeout +/// parameter such as `statement_timeout`: the value is rounded half to even to +/// whole milliseconds, a value rounding to zero disables the timeout, and the +/// timeout is at most 2147483647ms. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(try_from = "crate::time_unit::Value")] +pub enum Timeout { + Disabled, + After(std::time::Duration), +} + +impl From for Timeout { + fn from(duration: std::time::Duration) -> Self { + Self::After(duration) + } +} + +#[derive(Debug, thiserror::Error, PartialEq)] +#[error("timeout {value} is outside the valid range of 0ms .. 2147483647ms")] +pub struct TimeoutRangeError { + pub value: crate::time_unit::Value, +} + +impl TryFrom for Timeout { + type Error = TimeoutRangeError; + + fn try_from(value: crate::time_unit::Value) -> Result { + match value.to_milliseconds() { + Some(0) => Ok(Self::Disabled), + Some(milliseconds @ 1..) => Ok(Self::After(std::time::Duration::from_millis( + u64::from(milliseconds.unsigned_abs()), + ))), + _ => Err(TimeoutRangeError { value }), + } + } +} + /// Errors from cache sync operations ([`Definition::pull_cache`] / /// [`Definition::push_cache`]). #[derive(Debug, thiserror::Error)] @@ -95,7 +132,7 @@ pub struct Definition { pub superuser: pg_client::User, pub image: crate::image::Image, pub cross_container_access: bool, - pub wait_available_timeout: std::time::Duration, + pub wait_available_timeout: Timeout, pub remove: bool, /// Optional user-facing identifier for the resulting container. /// @@ -138,7 +175,7 @@ impl Definition { database: pg_client::Database::POSTGRES, image, cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), + wait_available_timeout: Timeout::After(std::time::Duration::from_secs(10)), remove: true, session_name: None, transparent_workdir: None, @@ -359,9 +396,9 @@ impl Definition { } #[must_use] - pub fn wait_available_timeout(self, timeout: std::time::Duration) -> Self { + pub fn wait_available_timeout(self, timeout: impl Into) -> Self { Self { - wait_available_timeout: timeout, + wait_available_timeout: timeout.into(), ..self } } diff --git a/pg-ephemeral/src/lib.rs b/pg-ephemeral/src/lib.rs index 11971f3e..c4c63e14 100644 --- a/pg-ephemeral/src/lib.rs +++ b/pg-ephemeral/src/lib.rs @@ -8,6 +8,7 @@ pub mod image; pub mod label; pub mod seed; pub mod session; +pub mod time_unit; pub use config::{Config, Instance}; pub use container::Container; diff --git a/pg-ephemeral/src/time_unit.rs b/pg-ephemeral/src/time_unit.rs new file mode 100644 index 00000000..d142795c --- /dev/null +++ b/pg-ephemeral/src/time_unit.rs @@ -0,0 +1,409 @@ +//! Values in PostgreSQL's time-unit parameter syntax. +//! +//! [`Value`] accepts exactly what PostgreSQL's `parse_int` accepts for an +//! integer parameter with a time unit, such as `statement_timeout`, and +//! [`Value::to_milliseconds`] converts it exactly as `convert_to_base_unit` and +//! `parse_int` do for such a parameter with base unit milliseconds. +//! +//! The number is read the way C's `strtol` reads it with base 0, falling back +//! to `strtod` when that stops at `.`, `e` or `E` or overflows. So leading +//! whitespace, a sign, octal (`010` is 8), hexadecimal (`0x1e`), decimal +//! fractions and exponents (`1.5`, `1e3`) and hexadecimal fractions (`0x1.8`) +//! are all accepted. Whitespace may follow the number and the unit. The unit is +//! one of `us`, `ms`, `s`, `min`, `h` or `d`, case-sensitive, or absent for the +//! parameter's base unit. +//! +//! `strtol` and `strtod` behave as glibc's do on a 64-bit platform, as in the +//! official PostgreSQL images. +//! +//! PostgreSQL's parser is `parse_int` and `convert_to_base_unit` in +//! . + +use nom::{ + IResult, Parser, + branch::alt, + bytes::complete::{tag_no_case, take_while, take_while_m_n, take_while1}, + character::complete::{char, digit0, digit1, one_of}, + combinator::{eof, opt, recognize, verify}, + sequence::{pair, preceded}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Unit { + Microsecond, + Millisecond, + Second, + Minute, + Hour, + Day, +} + +impl Unit { + const ALL: [Self; 6] = [ + Self::Microsecond, + Self::Millisecond, + Self::Second, + Self::Minute, + Self::Hour, + Self::Day, + ]; + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Microsecond => "us", + Self::Millisecond => "ms", + Self::Second => "s", + Self::Minute => "min", + Self::Hour => "h", + Self::Day => "d", + } + } + + /// The multiplier converting this unit to milliseconds, as in + /// `time_unit_conversion_table`. + fn milliseconds(self) -> f64 { + match self { + Self::Microsecond => 1.0 / 1000.0, + Self::Millisecond => 1.0, + Self::Second => 1000.0, + Self::Minute => 60_000.0, + Self::Hour => 3_600_000.0, + Self::Day => 86_400_000.0, + } + } + + /// The unit a fraction of this unit is rounded to, the next entry of + /// `time_unit_conversion_table` for base unit milliseconds. + fn next_smaller(self) -> Option { + match self { + Self::Microsecond => None, + Self::Millisecond => Some(Self::Microsecond), + Self::Second => Some(Self::Millisecond), + Self::Minute => Some(Self::Second), + Self::Hour => Some(Self::Minute), + Self::Day => Some(Self::Hour), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Value { + pub number: f64, + /// `None` for the base unit of the parameter. + pub unit: Option, +} + +impl std::fmt::Display for Value { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}", self.number)?; + + match self.unit { + Some(unit) => write!(formatter, "{}", unit.as_str()), + None => Ok(()), + } + } +} + +impl Value { + /// Whole milliseconds, as PostgreSQL computes them for an integer + /// parameter with base unit milliseconds: a fraction is rounded half to + /// even to the next smaller unit, then the result half to even to whole + /// milliseconds. `None` when that is outside the range of a 32-bit integer. + #[must_use] + pub fn to_milliseconds(&self) -> Option { + let mut milliseconds = self.number; + + if let Some(unit) = self.unit { + milliseconds *= unit.milliseconds(); + + if let Some(next) = unit.next_smaller() { + milliseconds = + (milliseconds / next.milliseconds()).round_ties_even() * next.milliseconds(); + } + } + + let milliseconds = milliseconds.round_ties_even(); + + if milliseconds > f64::from(i32::MAX) || milliseconds < f64::from(i32::MIN) { + None + } else { + Some(milliseconds as i32) + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum Error { + #[error( + "invalid time value {input:?}: expected a number, optionally followed by one of us, ms, s, min, h, d" + )] + Syntax { input: String }, + #[error("time value {input:?} is out of floating point range")] + Range { input: String }, +} + +impl std::str::FromStr for Value { + type Err = Error; + + fn from_str(input: &str) -> Result { + let syntax_error = || Error::Syntax { + input: input.to_string(), + }; + + let integer = strtol(input); + + let integer_end = integer.as_ref().map_or(input, |(_, rest)| rest); + + let reparse = matches!(integer, Some((Strtol::Overflow, _))) + || integer_end.starts_with(['.', 'e', 'E']); + + let (number, rest) = if reparse { + match strtod(input) { + Some((Strtod::Value(number), rest)) => (number, rest), + Some((Strtod::Range, _)) => { + return Err(Error::Range { + input: input.to_string(), + }); + } + None => return Err(syntax_error()), + } + } else { + match integer { + Some((Strtol::Value(number), rest)) => (number as f64, rest), + _ => return Err(syntax_error()), + } + }; + + if number.is_nan() { + return Err(syntax_error()); + } + + let (rest, _) = c_space(rest).map_err(|_| syntax_error())?; + + if rest.is_empty() { + return Ok(Self { number, unit: None }); + } + + let (_, (text, _, _)) = ( + take_while_m_n(0, 3, |character| !is_c_space(character)), + c_space, + eof, + ) + .parse(rest) + .map_err(|_: nom::Err>| syntax_error())?; + + let unit = Unit::ALL + .into_iter() + .find(|unit| unit.as_str() == text) + .ok_or_else(syntax_error)?; + + Ok(Self { + number, + unit: Some(unit), + }) + } +} + +impl<'de> serde::Deserialize<'de> for Value { + fn deserialize>(deserializer: D) -> Result { + >::deserialize(deserializer) + .and_then(|value| value.parse().map_err(serde::de::Error::custom)) + } +} + +/// C's `isspace` in the C locale. +fn is_c_space(character: char) -> bool { + matches!(character, ' ' | '\t' | '\n' | '\u{b}' | '\u{c}' | '\r') +} + +fn c_space(input: &str) -> IResult<&str, &str> { + take_while(is_c_space).parse(input) +} + +fn negative(input: &str) -> IResult<&str, bool> { + opt(one_of("+-")).map(|sign| sign == Some('-')).parse(input) +} + +enum Strtol { + Value(i64), + Overflow, +} + +/// What `strtol(input, &end, 0)` reads, with the input after it; `None` when +/// it reads no number. +fn strtol(input: &str) -> Option<(Strtol, &str)> { + let (rest, (_, negative, (radix, digits))) = ( + c_space, + negative, + alt(( + preceded( + tag_no_case("0x"), + take_while1(|character: char| character.is_ascii_hexdigit()), + ) + .map(|digits| (16, digits)), + recognize(pair( + char('0'), + take_while(|character: char| character.is_digit(8)), + )) + .map(|digits| (8, digits)), + take_while1(|character: char| character.is_ascii_digit()).map(|digits| (10, digits)), + )), + ) + .parse(input) + .ok()?; + + let value = u64::from_str_radix(digits, radix) + .ok() + .and_then(|magnitude| { + if negative { + 0i64.checked_sub_unsigned(magnitude) + } else { + i64::try_from(magnitude).ok() + } + }); + + Some((value.map_or(Strtol::Overflow, Strtol::Value), rest)) +} + +enum Strtod { + Value(f64), + Range, +} + +/// What `strtod(input, &end)` reads, with the input after it; `None` when it +/// reads no number. +fn strtod(input: &str) -> Option<(Strtod, &str)> { + fn hexadecimal(input: &str) -> IResult<&str, (&str, Option<&str>, Option<&str>)> { + let hex_digits = || take_while(|character: char| character.is_ascii_hexdigit()); + + preceded( + tag_no_case("0x"), + verify( + ( + hex_digits(), + opt(preceded(char('.'), hex_digits())), + opt(preceded( + one_of("pP"), + recognize(pair(opt(one_of("+-")), digit1)), + )), + ), + |(integer, fraction, _): &(&str, Option<&str>, Option<&str>)| { + !integer.is_empty() || fraction.is_some_and(|fraction| !fraction.is_empty()) + }, + ), + ) + .parse(input) + } + + fn decimal(input: &str) -> IResult<&str, (&str, Option<&str>)> { + ( + verify( + recognize(pair(digit0, opt(pair(char('.'), digit0)))), + |mantissa: &str| mantissa.bytes().any(|byte| byte.is_ascii_digit()), + ), + opt(preceded( + one_of("eE"), + recognize(pair(opt(one_of("+-")), digit1)), + )), + ) + .parse(input) + } + + let (input, (_, negative)) = (c_space, negative).parse(input).ok()?; + + if let Ok((rest, (integer, fraction, exponent))) = hexadecimal(input) { + let digits = integer.chars().chain(fraction.unwrap_or("").chars()); + + let exponent = exponent + .map_or(0, parse_exponent) + .saturating_sub(4 * i64::try_from(fraction.map_or(0, str::len)).unwrap_or(i64::MAX)); + + return Some((hexadecimal_value(digits, exponent, negative), rest)); + } + + let (rest, (mantissa, exponent)) = decimal(input).ok()?; + + let sign = if negative { "-" } else { "" }; + + let value: f64 = format!("{sign}{mantissa}e{}", exponent.unwrap_or("0")) + .parse() + .ok()?; + + let underflow = value.is_subnormal() + || (value == 0.0 && mantissa.bytes().any(|byte| matches!(byte, b'1'..=b'9'))); + + if value.is_infinite() || underflow { + Some((Strtod::Range, rest)) + } else { + Some((Strtod::Value(value), rest)) + } +} + +/// A decimal exponent, saturated far beyond any finite `f64`. +fn parse_exponent(exponent: &str) -> i64 { + const SATURATED: i64 = 1 << 40; + + exponent.parse::().map_or_else( + |_| { + if exponent.starts_with('-') { + -SATURATED + } else { + SATURATED + } + }, + |exponent| exponent.clamp(-SATURATED, SATURATED), + ) +} + +/// The hexadecimal digits times two to the power of `exponent`, correctly +/// rounded half to even, as glibc's `strtod` reads a hexadecimal number. +fn hexadecimal_value(digits: impl Iterator, exponent: i64, negative: bool) -> Strtod { + let mut mantissa: u128 = 0; + let mut exponent = exponent; + let mut sticky = false; + + for digit in digits.filter_map(|character| character.to_digit(16)) { + if mantissa >> 124 == 0 { + mantissa = mantissa << 4 | u128::from(digit); + } else { + exponent = exponent.saturating_add(4); + sticky |= digit != 0; + } + } + + if mantissa == 0 { + return Strtod::Value(if negative { -0.0 } else { 0.0 }); + } + + let bits = i64::from(128 - mantissa.leading_zeros()); + let dropped = bits - 53; + + let mut significand = if dropped <= 0 { + mantissa << -dropped + } else { + let kept = mantissa >> dropped; + let remainder = mantissa & ((1 << dropped) - 1); + let half = 1 << (dropped - 1); + let round_up = remainder > half || (remainder == half && (sticky || kept & 1 == 1)); + + kept + u128::from(round_up) + }; + + let mut leading_exponent = exponent.saturating_add(bits - 1); + + if significand == 1 << 53 { + significand >>= 1; + leading_exponent = leading_exponent.saturating_add(1); + } + + if !(-1022..=1023).contains(&leading_exponent) { + return Strtod::Range; + } + + let biased_exponent = u64::try_from(leading_exponent + 1023).unwrap(); + let fraction = u64::try_from(significand).unwrap() & ((1 << 52) - 1); + let value = f64::from_bits(biased_exponent << 52 | fraction); + + Strtod::Value(if negative { -value } else { value }) +} diff --git a/pg-ephemeral/tests/base.rs b/pg-ephemeral/tests/base.rs index 7f19f4d7..44d7a648 100644 --- a/pg-ephemeral/tests/base.rs +++ b/pg-ephemeral/tests/base.rs @@ -96,7 +96,7 @@ fn test_config_multi_instance() { superuser: pg_client::User::POSTGRES, image: "17.1".parse().unwrap(), cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), + wait_available_timeout: std::time::Duration::from_secs(10).into(), } ), ( @@ -111,7 +111,7 @@ fn test_config_multi_instance() { superuser: pg_client::User::POSTGRES, image: "17.2".parse().unwrap(), cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), + wait_available_timeout: std::time::Duration::from_secs(10).into(), } ) ]), @@ -145,7 +145,7 @@ fn test_config_no_explicit_instance() { superuser: pg_client::User::POSTGRES, image: "17.1".parse().unwrap(), cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), + wait_available_timeout: std::time::Duration::from_secs(10).into(), } ),]), }, @@ -208,7 +208,7 @@ fn test_config_ssl() { superuser: pg_client::User::POSTGRES, image: "18.0".parse().unwrap(), cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), + wait_available_timeout: std::time::Duration::from_secs(10).into(), } )]), }, @@ -594,7 +594,7 @@ fn test_config_image_with_sha256_digest() { superuser: pg_client::User::POSTGRES, image: expected_image.clone(), cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), + wait_available_timeout: std::time::Duration::from_secs(10).into(), } )]), }, diff --git a/pg-ephemeral/tests/time_unit.rs b/pg-ephemeral/tests/time_unit.rs new file mode 100644 index 00000000..687a4598 --- /dev/null +++ b/pg-ephemeral/tests/time_unit.rs @@ -0,0 +1,775 @@ +//! Equivalence of `pg_ephemeral::time_unit` and `pg_ephemeral::definition::Timeout` +//! with PostgreSQL's time-unit parameter parser. +//! +//! Every case is run through our parser and through a live server, which +//! interprets it as the value of `lock_timeout`: an integer parameter with +//! base unit milliseconds, range `0 .. INT_MAX` and `0` disabling the +//! timeout, the same domain as `statement_timeout`. `lock_timeout` is used +//! because setting it cannot interfere with the queries reading it back. + +mod common; + +use pg_ephemeral::definition::Timeout; +use pg_ephemeral::time_unit; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Postgres { + Accepts { milliseconds: i64 }, + Rejects, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Ours { + Timeout(Timeout), + Syntax, + FloatRange, + TimeoutRange, +} + +struct Case { + input: &'static str, + reference: &'static str, + postgres: Postgres, + ours: Ours, +} + +const fn accepts(milliseconds: i64) -> Postgres { + Postgres::Accepts { milliseconds } +} + +const fn after(milliseconds: u64) -> Ours { + Ours::Timeout(Timeout::After(std::time::Duration::from_millis( + milliseconds, + ))) +} + +const DISABLED: Ours = Ours::Timeout(Timeout::Disabled); + +/// Every reference, file path and line number, is to the PostgreSQL source at +/// commit c0bf1d89df29e81c6fdad64e0f7cde10f16322bd on REL_18_STABLE: +/// . +/// Line numbers drift on later commits; look them up at that commit. +/// `guc.c` is `src/backend/utils/misc/guc.c`, `guc_tables.c` is +/// `src/backend/utils/misc/guc_tables.c`, other paths are relative to the +/// repository root. +/// +/// The first section imports every distinct literal value PostgreSQL's own +/// tests assign to a parameter with a time unit, found by searching +/// `src/test`, `src/bin`, `src/pl` and `contrib` at that commit for +/// assignments to the parameters declared with `GUC_UNIT_MS`, `GUC_UNIT_S` or +/// `GUC_UNIT_MIN`. Each cites its first occurrence. Read as `lock_timeout`, a +/// value can mean something else than in its test, where the parameter may +/// have another base unit or range. The other sections derive from the +/// parser's source. +const CASES: &[Case] = &[ + // PostgreSQL's test suite. + Case { + input: "900us", + reference: "src/test/regress/sql/guc.sql:62 SET vacuum_cost_delay TO '900us'", + postgres: accepts(1), + ours: after(1), + }, + Case { + input: "1ms", + reference: "src/test/subscription/t/004_sync.pl:20 wal_retrieve_retry_interval = 1ms", + postgres: accepts(1), + ours: after(1), + }, + Case { + input: "10ms", + reference: "contrib/postgres_fdw/sql/query_cancel.sql:19 SET LOCAL statement_timeout = '10ms'", + postgres: accepts(10), + ours: after(10), + }, + Case { + input: "50ms", + reference: "src/test/recovery/t/031_recovery_conflict.pl:29 max_standby_streaming_delay = 50ms", + postgres: accepts(50), + ours: after(50), + }, + Case { + input: "100ms", + reference: "src/test/isolation/specs/fk-deadlock.spec:22 SET deadlock_timeout = '100ms'", + postgres: accepts(100), + ours: after(100), + }, + Case { + input: "500ms", + reference: "src/test/perl/PostgreSQL/Test/Cluster.pm:690 wal_retrieve_retry_interval = '500ms'", + postgres: accepts(500), + ours: after(500), + }, + Case { + input: "1s", + reference: "src/test/modules/brin/t/01_workitems.pl:15 autovacuum_naptime=1s", + postgres: accepts(1_000), + ours: after(1_000), + }, + Case { + input: "3s", + reference: "src/test/recovery/t/005_replay_delay.pl:32 recovery_min_apply_delay = '${delay}s' with $delay = 3", + postgres: accepts(3_000), + ours: after(3_000), + }, + Case { + input: "10s", + reference: "src/test/isolation/specs/deadlock-parallel.spec:70 SET deadlock_timeout = '10s', \ + src/test/regress/sql/guc.sql:152 rejected by vacuum_cost_delay's range", + postgres: accepts(10_000), + ours: after(10_000), + }, + Case { + input: "100s", + reference: "src/test/isolation/specs/deadlock-hard.spec:24 SET deadlock_timeout = '100s'", + postgres: accepts(100_000), + ours: after(100_000), + }, + Case { + input: "180 s", + reference: "src/test/postmaster/t/003_start_stop.pl:40 authentication_timeout = '$authentication_timeout s' \ + with $timeout_default = 180 (src/test/perl/PostgreSQL/Test/Utils.pm:173)", + postgres: accepts(180_000), + ours: after(180_000), + }, + Case { + input: "600s", + reference: "src/test/recovery/t/027_stream_regress.pl:63 max_standby_streaming_delay = 600s", + postgres: accepts(600_000), + ours: after(600_000), + }, + Case { + input: "1min", + reference: "src/test/recovery/t/044_invalidate_inactive_slots.pl:54 idle_replication_slot_timeout = 1min", + postgres: accepts(60_000), + ours: after(60_000), + }, + Case { + input: "30min", + reference: "src/test/recovery/t/039_end_of_wal.pl:120 checkpoint_timeout = '30min'", + postgres: accepts(1_800_000), + ours: after(1_800_000), + }, + Case { + input: "1h", + reference: "src/bin/pg_upgrade/t/003_logical_slots.pl:37 checkpoint_timeout = 1h", + postgres: accepts(3_600_000), + ours: after(3_600_000), + }, + Case { + input: "2h", + reference: "src/test/regress/sql/psql_pipeline.sql:383 SET LOCAL statement_timeout='2h'", + postgres: accepts(7_200_000), + ours: after(7_200_000), + }, + Case { + input: "1d", + reference: "contrib/test_decoding/logical.conf:4 autovacuum_naptime = 1d", + postgres: accepts(86_400_000), + ours: after(86_400_000), + }, + Case { + input: "0", + reference: "contrib/auto_explain/t/001_auto_explain.pl:34 auto_explain.log_min_duration = 0", + postgres: accepts(0), + ours: DISABLED, + }, + Case { + input: "1", + reference: "src/test/modules/test_misc/t/006_signal_autovacuum.pl:26 autovacuum_naptime = 1", + postgres: accepts(1), + ours: after(1), + }, + Case { + input: "10", + reference: "src/test/isolation/specs/prepared-transactions-cic.spec:25 SET lock_timeout = 10", + postgres: accepts(10), + ours: after(10), + }, + Case { + input: "13", + reference: "src/test/regress/sql/guc.sql:192 SET vacuum_cost_delay = 13", + postgres: accepts(13), + ours: after(13), + }, + Case { + input: "30", + reference: "src/test/regress/sql/guc.sql:91 SET LOCAL vacuum_cost_delay TO 30", + postgres: accepts(30), + ours: after(30), + }, + Case { + input: "40", + reference: "src/test/regress/sql/guc.sql:9 SET vacuum_cost_delay TO 40", + postgres: accepts(40), + ours: after(40), + }, + Case { + input: "50", + reference: "src/test/regress/sql/guc.sql:16 SET LOCAL vacuum_cost_delay TO 50", + postgres: accepts(50), + ours: after(50), + }, + Case { + input: "60", + reference: "src/test/regress/sql/guc.sql:36 SET vacuum_cost_delay TO 60", + postgres: accepts(60), + ours: after(60), + }, + Case { + input: "70", + reference: "src/test/regress/sql/guc.sql:48 SET vacuum_cost_delay TO 70", + postgres: accepts(70), + ours: after(70), + }, + Case { + input: "80.1", + reference: "src/test/regress/sql/guc.sql:53 SET vacuum_cost_delay TO 80.1", + postgres: accepts(80), + ours: after(80), + }, + Case { + input: "100", + reference: "src/test/regress/sql/guc.sql:67 SET vacuum_cost_delay TO 100", + postgres: accepts(100), + ours: after(100), + }, + Case { + input: "600", + reference: "src/test/postmaster/t/003_start_stop.pl:25 $authentication_timeout = 600", + postgres: accepts(600), + ours: after(600), + }, + Case { + input: "1000", + reference: "src/pl/plpgsql/src/sql/plpgsql_trap.sql:102 set statement_timeout to 1000", + postgres: accepts(1_000), + ours: after(1_000), + }, + Case { + input: "-1", + reference: "src/bin/pgbench/t/001_pgbench_with_server.pl:368 log_min_duration_statement = -1, \ + below lock_timeout's minimum of 0 (guc_tables.c:2756)", + postgres: Postgres::Rejects, + ours: Ours::TimeoutRange, + }, + // Units. + Case { + input: "2min", + reference: "guc.c:162 unit min", + postgres: accepts(120_000), + ours: after(120_000), + }, + Case { + input: "5000us", + reference: "guc.c:165 unit us", + postgres: accepts(5), + ours: after(5), + }, + Case { + input: "1500000us", + reference: "guc.c:165 unit us", + postgres: accepts(1_500), + ours: after(1_500), + }, + Case { + input: "30S", + reference: "guc.c:2700 units are case-sensitive", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "30m", + reference: "guc.c:2700 m is not a unit", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "1w", + reference: "guc.c:2700 w is not a unit", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "30kB", + reference: "guc.c:2700 memory units do not apply to time parameters", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "30sec", + reference: "guc.c:2700 sec is not a unit", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "30mins", + reference: "guc.c:2689 unit longer than MAX_UNIT_LEN", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "1m30s", + reference: "guc.c:2689 no compound values", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "30 ms x", + reference: "guc.c:2689 garbage after the unit", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + // Whitespace. + Case { + input: "30 s", + reference: "guc.c:2909 whitespace between number and unit", + postgres: accepts(30_000), + ours: after(30_000), + }, + Case { + input: "30\tms ", + reference: "guc.c:2909 whitespace between number and unit, guc.c:2685 after the unit", + postgres: accepts(30), + ours: after(30), + }, + Case { + input: "30s ", + reference: "guc.c:2685 whitespace after unit", + postgres: accepts(30_000), + ours: after(30_000), + }, + Case { + input: " 30s", + reference: "guc.c:2894 strtol skips leading whitespace", + postgres: accepts(30_000), + ours: after(30_000), + }, + Case { + input: "\n30s\u{b}", + reference: "guc.c:2894 strtol skips leading whitespace, guc.c:2685 C isspace", + postgres: accepts(30_000), + ours: after(30_000), + }, + // Signs. + Case { + input: "+30s", + reference: "guc.c:2894 strtol accepts a sign", + postgres: accepts(30_000), + ours: after(30_000), + }, + Case { + input: "-0s", + reference: "guc.c:2894 strtol accepts a sign", + postgres: accepts(0), + ours: DISABLED, + }, + Case { + input: "-1s", + reference: "guc.c:3182 below the minimum, guc_tables.c:2756 minimum 0", + postgres: Postgres::Rejects, + ours: Ours::TimeoutRange, + }, + Case { + input: "-1ms", + reference: "guc.c:3182 below the minimum, guc_tables.c:2756 minimum 0", + postgres: Postgres::Rejects, + ours: Ours::TimeoutRange, + }, + Case { + input: "-0.4ms", + reference: "guc.c:2936 rint rounds to -0, which is 0", + postgres: accepts(0), + ours: DISABLED, + }, + Case { + input: "-.5s", + reference: "guc.c:2895 strtol reads no number at the sign, so no float re-parse", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + // Octal and hexadecimal. + Case { + input: "010s", + reference: "guc.c:2894 strtol base 0 reads a leading zero as octal", + postgres: accepts(8_000), + ours: after(8_000), + }, + Case { + input: "00s", + reference: "guc.c:2894 strtol base 0 reads a leading zero as octal", + postgres: accepts(0), + ours: DISABLED, + }, + Case { + input: "08s", + reference: "guc.c:2894 strtol octal stops at 8, guc.c:2700 8s is not a unit", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "019s", + reference: "guc.c:2894 strtol octal stops at 9, guc.c:2700 9s is not a unit", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "017.5s", + reference: "guc.c:2895 a decimal point re-parses the octal digits as decimal", + postgres: accepts(17_500), + ours: after(17_500), + }, + Case { + input: "0x1es", + reference: "guc.c:2894 strtol base 0 reads 0x as hexadecimal", + postgres: accepts(30_000), + ours: after(30_000), + }, + Case { + input: "0X1Es", + reference: "guc.c:2894 strtol base 0 reads 0X as hexadecimal", + postgres: accepts(30_000), + ours: after(30_000), + }, + Case { + input: "0xs", + reference: "guc.c:2894 strtol reads 0 without hexadecimal digits, guc.c:2700 xs is not a unit", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "0x10.8s", + reference: "guc.c:2899 strtod reads a hexadecimal fraction", + postgres: accepts(16_500), + ours: after(16_500), + }, + Case { + input: "0x1.8p1s", + reference: "guc.c:2899 strtod reads a hexadecimal fraction with binary exponent", + postgres: accepts(3_000), + ours: after(3_000), + }, + Case { + input: "0x1p3s", + reference: "guc.c:2895 no float re-parse at p, guc.c:2689 p3s is not a unit", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + // Fractions and exponents. + Case { + input: "1.5s", + reference: "guc.c:2895 a decimal point re-parses as float", + postgres: accepts(1_500), + ours: after(1_500), + }, + Case { + input: ".5s", + reference: "guc.c:2895 a leading decimal point re-parses as float", + postgres: accepts(500), + ours: after(500), + }, + Case { + input: "1.5min", + reference: "guc.c:2711 a fraction rounds to the next smaller unit", + postgres: accepts(90_000), + ours: after(90_000), + }, + Case { + input: "1.5ms", + reference: "guc.c:2711 rounds to microseconds, guc.c:2936 rint rounds half to even", + postgres: accepts(2), + ours: after(2), + }, + Case { + input: "0.0005s", + reference: "guc.c:2711 rounds half to even to 0 milliseconds", + postgres: accepts(0), + ours: DISABLED, + }, + Case { + input: "1e3ms", + reference: "guc.c:2895 an exponent re-parses as float", + postgres: accepts(1_000), + ours: after(1_000), + }, + Case { + input: "1E3ms", + reference: "guc.c:2895 an exponent re-parses as float", + postgres: accepts(1_000), + ours: after(1_000), + }, + Case { + input: "1.e3ms", + reference: "guc.c:2899 strtod reads an empty fraction", + postgres: accepts(1_000), + ours: after(1_000), + }, + Case { + input: "1ems", + reference: "guc.c:2899 strtod stops at e without exponent digits, guc.c:2700 ems is not a unit", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "e3ms", + reference: "guc.c:2902 strtod reads no number", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "1e999s", + reference: "guc.c:2902 strtod overflows", + postgres: Postgres::Rejects, + ours: Ours::FloatRange, + }, + Case { + input: "1e-400ms", + reference: "guc.c:2902 strtod underflows", + postgres: Postgres::Rejects, + ours: Ours::FloatRange, + }, + Case { + input: "1e-310ms", + reference: "guc.c:2902 strtod underflows to a subnormal", + postgres: Postgres::Rejects, + ours: Ours::FloatRange, + }, + // Not numbers. + Case { + input: "", + reference: "guc.c:2902 no number", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "s", + reference: "guc.c:2902 no number", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "PT30S", + reference: "guc.c:2902 no number, ISO 8601 is not accepted", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "NaN", + reference: "guc.c:2902 no number, src/test/regress/sql/guc.sql:151 SET seq_page_cost TO 'NaN' (a real parameter)", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + Case { + input: "inf", + reference: "guc.c:2902 no number, strtol does not read infinity", + postgres: Postgres::Rejects, + ours: Ours::Syntax, + }, + // Zero and rounding. + Case { + input: "0ms", + reference: "guc_tables.c:2752 0 disables the timeout", + postgres: accepts(0), + ours: DISABLED, + }, + Case { + input: "0d", + reference: "guc_tables.c:2752 0 disables the timeout", + postgres: accepts(0), + ours: DISABLED, + }, + Case { + input: "1us", + reference: "guc.c:2936 rint rounds to 0, guc_tables.c:2752 0 disables the timeout", + postgres: accepts(0), + ours: DISABLED, + }, + Case { + input: "499us", + reference: "guc.c:2936 rint rounds down", + postgres: accepts(0), + ours: DISABLED, + }, + Case { + input: "500us", + reference: "guc.c:2936 rint rounds half to even", + postgres: accepts(0), + ours: DISABLED, + }, + Case { + input: "501us", + reference: "guc.c:2936 rint rounds up", + postgres: accepts(1), + ours: after(1), + }, + Case { + input: "1500us", + reference: "guc.c:2936 rint rounds half to even", + postgres: accepts(2), + ours: after(2), + }, + Case { + input: "2500us", + reference: "guc.c:2936 rint rounds half to even", + postgres: accepts(2), + ours: after(2), + }, + Case { + input: "3500us", + reference: "guc.c:2936 rint rounds half to even", + postgres: accepts(4), + ours: after(4), + }, + // Range. + Case { + input: "24d", + reference: "guc.c:160 unit d, largest whole day within INT_MAX ms", + postgres: accepts(2_073_600_000), + ours: after(2_073_600_000), + }, + Case { + input: "2147483647ms", + reference: "guc.c:2938 INT_MAX is the largest value", + postgres: accepts(2_147_483_647), + ours: after(2_147_483_647), + }, + Case { + input: "2147483647.4ms", + reference: "guc.c:2936 rounds to INT_MAX before the guc.c:2938 range check", + postgres: accepts(2_147_483_647), + ours: after(2_147_483_647), + }, + Case { + input: "2147483647001us", + reference: "guc.c:2936 rounds to INT_MAX before the guc.c:2938 range check", + postgres: accepts(2_147_483_647), + ours: after(2_147_483_647), + }, + Case { + input: "2147483647500us", + reference: "guc.c:2936 rint rounds half to even past INT_MAX, guc.c:2938 exceeds INT_MAX", + postgres: Postgres::Rejects, + ours: Ours::TimeoutRange, + }, + Case { + input: "2147483648ms", + reference: "guc.c:2938 exceeds INT_MAX", + postgres: Postgres::Rejects, + ours: Ours::TimeoutRange, + }, + Case { + input: "25d", + reference: "guc.c:2938 exceeds INT_MAX", + postgres: Postgres::Rejects, + ours: Ours::TimeoutRange, + }, + Case { + input: "-9223372036854775808ms", + reference: "guc.c:2894 strtol reads LONG_MIN, guc.c:2938 below INT_MIN", + postgres: Postgres::Rejects, + ours: Ours::TimeoutRange, + }, + Case { + input: "9223372036854775808ms", + reference: "guc.c:2895 strtol overflow re-parses as float, guc.c:2938 exceeds INT_MAX", + postgres: Postgres::Rejects, + ours: Ours::TimeoutRange, + }, +]; + +fn parse(input: &str) -> Ours { + match input.parse::() { + Ok(value) => match Timeout::try_from(value) { + Ok(timeout) => Ours::Timeout(timeout), + Err(_) => Ours::TimeoutRange, + }, + Err(time_unit::Error::Syntax { .. }) => Ours::Syntax, + Err(time_unit::Error::Range { .. }) => Ours::FloatRange, + } +} + +#[test] +fn ours_matches_expectations() { + let expected: Vec<(&str, Ours)> = CASES.iter().map(|case| (case.input, case.ours)).collect(); + + let actual: Vec<(&str, Ours)> = CASES + .iter() + .map(|case| (case.input, parse(case.input))) + .collect(); + + assert_eq!(expected, actual); +} + +#[test] +fn ours_is_equivalent_to_postgres() { + for case in CASES { + let postgres = match case.ours { + Ours::Timeout(Timeout::Disabled) => Postgres::Accepts { milliseconds: 0 }, + Ours::Timeout(Timeout::After(duration)) => Postgres::Accepts { + milliseconds: i64::try_from(duration.as_millis()).unwrap(), + }, + Ours::Syntax | Ours::FloatRange | Ours::TimeoutRange => Postgres::Rejects, + }; + + assert_eq!( + postgres, case.postgres, + "{:?} ({})", + case.input, case.reference + ); + } +} + +#[tokio::test] +async fn postgres_matches_expectations() { + let backend = ociman::test_backend_setup!(); + + let expected: Vec<(&str, Postgres)> = CASES + .iter() + .map(|case| (case.input, case.postgres)) + .collect(); + + let actual = common::test_definition(backend, "time-unit".parse().unwrap()) + .with_container(async |container| { + container + .with_connection(async |connection| { + let mut actual = Vec::new(); + + for case in CASES { + actual.push((case.input, postgres(connection, case.input).await)); + } + + actual + }) + .await + }) + .await + .unwrap(); + + assert_eq!(expected, actual); +} + +async fn postgres(connection: &mut sqlx::postgres::PgConnection, input: &str) -> Postgres { + let result = sqlx::query("SELECT set_config('lock_timeout', $1, false)") + .bind(input) + .execute(&mut *connection) + .await; + + match result { + Ok(_) => { + let row = sqlx::query("SELECT setting FROM pg_settings WHERE name = 'lock_timeout'") + .fetch_one(&mut *connection) + .await + .unwrap(); + + let setting: String = sqlx::Row::get(&row, 0); + + Postgres::Accepts { + milliseconds: setting.parse().unwrap(), + } + } + Err(sqlx::Error::Database(error)) => { + assert_eq!(Some("22023"), error.code().as_deref(), "{input}"); + Postgres::Rejects + } + Err(error) => panic!("{input}: {error}"), + } +}