# Time-unit GUC rounding can bypass range checks and change zero-sentinel semantics

## Summary and reproduction

PostgreSQL rounds time-unit GUC input before checking the parameter's declared
range.  A nonzero duration can therefore become zero before validation.  The
result is both sign-sensitive and spelling-dependent: a negative value below
the declared minimum can be accepted as zero, while a positive timeout can
silently turn into a disabled timeout or another zero-sentinel behavior.

A minimal reproduction is:

```sql
SET statement_timeout =  '0.0001d'; SHOW statement_timeout; -- 0, although 0.0001d is 8.64 seconds
SET statement_timeout = '-0.0001d'; SHOW statement_timeout; -- 0, although the declared minimum is 0
SET statement_timeout = '-1ms';                              -- ERROR: outside the valid range
```

Thus a negative 8.64-second value is accepted as zero, while negative one
millisecond is rejected.  The first line also changes a requested positive
8.64-second timeout into zero, which disables `statement_timeout`.

The same mechanism affects other time-unit GUCs.  Whether the resulting zero
is merely lossy or operationally dangerous depends on the parameter's minimum
and on what zero means to its consumer.

## Shared mechanism and the unit-spelling invariant

The tracked source of the parameter definitions is
[`src/backend/utils/misc/guc_parameters.dat`](src/backend/utils/misc/guc_parameters.dat).
The parsing and conversion logic is in
[`src/backend/utils/misc/guc.c`](src/backend/utils/misc/guc.c).

### First rounding stage: the next smaller unit

`convert_to_base_unit()` first converts the numeric value to the parameter's
base unit:

```c
double cvalue = value * table[i].multiplier;
```

It then rounds the result to a multiple of the next smaller unit:

```c
if (*table[i + 1].unit &&
    base_unit == table[i + 1].base_unit)
    cvalue = rint(cvalue / table[i + 1].multiplier) *
        table[i + 1].multiplier;
```

Under the default round-to-nearest, ties-to-even mode, these nonzero
magnitudes can become zero during this first stage:

| Input suffix | Rounding quantum | Nonzero magnitude that can become zero | Physical duration |
|---|---|---:|---:|
| `d` | one hour | `abs(value) <= 1/48 d` | up to 30 minutes |
| `h` | one minute | `abs(value) <= 1/120 h` | up to 30 seconds |
| `min` | one second | `abs(value) <= 1/120 min` | up to 500 milliseconds |
| `s` | one millisecond | `abs(value) <= 0.0005 s` | up to 0.5 milliseconds |
| `ms` | one microsecond | `abs(value) <= 0.0005 ms` | up to 0.5 microseconds |
| `us` | no smaller table entry | none at this stage | none at this stage |

The same windows apply on the negative side.  For example:

```text
 0.0001d  ->  0 hours ->  0
-0.0001d  -> -0 hours -> -0.0
 0.001h   ->  0 minutes -> 0
-0.001h   -> -0 minutes -> -0.0
```

### Second rounding stage: integer GUC storage

For an integer GUC, `parse_int()` performs a final rounding after unit
conversion:

```c
val = rint(val);
```

The parameter-specific minimum and maximum are checked only after this value
has been converted to an integer.  The nominal final zero windows are:

| Integer base unit | Converted magnitude that can become integer zero |
|---|---:|
| milliseconds | up to 0.5 milliseconds |
| seconds | up to 0.5 seconds |
| minutes | up to 0.5 minutes |

The two stages compose.  For a millisecond GUC, for example, an input written
as `0.1ms` becomes zero at the final integer rounding, while `0.001min`
becomes zero earlier by rounding 0.06 seconds to zero seconds.  The latter is
a physical duration of 60 milliseconds, well above the final half-millisecond
window.

Values very close to a half-unit boundary can also be affected by double
rounding.  The exact floating-point edge depends on representation and the
active rounding mode; the tables state the nominal mathematical boundaries.

### Unit-spelling invariant

The first-stage loss window is a property of the input spelling, not of the
parameter.  The suffix selects the next-smaller-unit rounding quantum before
the parameter's integer base unit adds the second rounding stage.

For example, `0.001h` is first rounded to a whole number of minutes whether
the target parameter is millisecond-based or second-based:

```text
0.001h = 3.6 seconds = 0.06 minutes -> 0 minutes -> 0
```

Equivalent physical durations can therefore behave differently:

```text
statement_timeout = '0.001h'  -> 0 ms
statement_timeout = '3.6s'    -> 3600 ms
statement_timeout = '3600ms'  -> 3600 ms
```

## 1. Negative values can bypass declared minimums

### Parameters with `min = 0`

For a parameter whose minimum is zero, every negative input should be below
the declared range.  If conversion produces `-0.0`, however, the final stored
integer is zero and the range check succeeds.

```sql
SET statement_timeout = '-0.0001d';
SHOW statement_timeout;
```

Result:

```text
0
```

The original value is -8.64 seconds, but rounding days to whole hours erases
both its magnitude and its effective sign before the minimum is checked.

The following time-unit GUCs have `min = 0`.  All can receive zero from a
negative input within one of the relevant loss windows.  The operational
effect depends on the meaning of zero.

| Parameter | Type and base unit | Meaning of zero |
|---|---|---|
| `archive_timeout` | integer seconds | disable forced WAL switches |
| `client_connection_check_interval` | integer milliseconds | disable connection checks |
| `idle_in_transaction_session_timeout` | integer milliseconds | disable the timeout |
| `idle_replication_slot_timeout` | integer seconds | disable idle-slot invalidation |
| `idle_session_timeout` | integer milliseconds | disable the timeout |
| `io_worker_idle_timeout` | integer milliseconds | immediate idle deadline |
| `lock_timeout` | integer milliseconds | disable the timeout |
| `statement_timeout` | integer milliseconds | disable the timeout |
| `tcp_user_timeout` | integer milliseconds | use the operating-system default |
| `transaction_timeout` | integer milliseconds | disable the timeout |
| `vacuum_cost_delay` | real milliseconds | disable cost-based vacuum delay |
| `wal_receiver_timeout` | integer milliseconds | disable the timeout |
| `wal_sender_timeout` | integer milliseconds | disable the timeout |

Other time-unit GUCs with `min = 0` are affected by the same range-check
ordering even though they are not timeout controls:

- `checkpoint_warning`
- `io_worker_launch_interval`
- `log_rotation_age`
- `log_startup_progress_interval`
- `password_expiration_warning_threshold`
- `recovery_min_apply_delay`
- `tcp_keepalives_idle`
- `tcp_keepalives_interval`
- `wal_receiver_status_interval`
- `wal_summary_keep_time`

### Parameters with `min = -1`

Several time-unit GUCs reserve `-1` for a special behavior.  They can accept a
value far below `-1` if a coarse input suffix rounds it to zero before the
range check.

For example:

```sql
SET log_min_duration_statement = '-0.001h';
SHOW log_min_duration_statement;
```

Result:

```text
0
```

`-0.001h` is `-3600ms`, below the declared `-1ms` minimum.  It nevertheless
becomes zero because -0.001 hours rounds to zero whole minutes.  The semantic
result is also reversed: `-1` disables duration logging, while `0` logs every
statement duration.

The affected `min = -1` time-unit GUCs are:

| Parameter | Type and base unit | Meaning of `-1` | Meaning of `0` |
|---|---|---|---|
| `autovacuum_vacuum_cost_delay` | real milliseconds | inherit `vacuum_cost_delay` | disable cost-based delay |
| `log_autoanalyze_min_duration` | integer milliseconds | disable logging | log every qualifying action |
| `log_autovacuum_min_duration` | integer milliseconds | disable logging | log every qualifying action |
| `log_min_duration_sample` | integer milliseconds | disable sampling | sample all statements |
| `log_min_duration_statement` | integer milliseconds | disable duration logging | log every statement duration |
| `max_standby_archive_delay` | integer milliseconds | wait forever | no grace period before cancellation |
| `max_standby_streaming_delay` | integer milliseconds | wait forever | no grace period before cancellation |
| `wal_sender_shutdown_timeout` | integer milliseconds | wait indefinitely for the receiver | do not wait for the receiver |

### Parameters protected from the zero case

Integer timeouts with a positive minimum reject a value that has become zero:

| Parameter | Base unit | Minimum |
|---|---|---:|
| `authentication_timeout` | seconds | 1 second |
| `deadlock_timeout` | milliseconds | 1 millisecond |
| `checkpoint_timeout` | seconds | 30 seconds |

For example, `deadlock_timeout = '0.001min'` is converted to zero but rejected
because zero is below its one-millisecond minimum.  A positive minimum
protects against this particular zero-sentinel collision; it does not prevent
all just-out-of-range fractional inputs from rounding into range.

### Independent correction and tests

The negative-range problem can be handled independently of the policy for
positive timeouts.  Validation needs access to the converted value before the
lossy next-smaller-unit and integer rounding stages.  A narrowly scoped
ordering is:

```text
parse numeric input
-> convert to the base unit without rounding
-> reject a value below the parameter's declared minimum
-> apply the documented unit and integer rounding for storage
-> perform the existing range checks on the stored value
```

Checking `signbit()` after conversion is sufficient for a caller that rejects
all negative values, including negative zero.  It is not a complete generic
GUC fix because `min = -1` parameters legitimately accept some negative
values; those parameters require comparison with the unrounded converted
value.

Regression coverage for this section should include:

```sql
-- min = 0: negative values must not enter the range through zero.
SET statement_timeout = '-0.0001d';
SET statement_timeout = '-0.001h';
SET statement_timeout = '-0.001min';

-- min = -1: values below -1ms must not become zero.
SET log_min_duration_statement = '-0.001h';
SET log_min_duration_sample = '-0.001h';

-- The actual sentinel remains valid.
SET log_min_duration_statement = '-1ms';
```

Tests should cover several suffixes because the magnitude lost before range
validation is determined by the spelling.  SIGHUP-only parameters such as
`max_standby_streaming_delay` require configuration-file and reload or startup
coverage rather than session-level `SET` tests.

## 2. Positive nonzero values can collapse into zero sentinels

Positive input presents a distinct policy question.  The same rounding rule
can turn a requested nonzero timeout into a zero sentinel even though no range
constraint is violated.

```sql
SET statement_timeout = '0.0001d';
SHOW statement_timeout;
```

The requested duration is 8.64 seconds, but the stored value is zero and the
timeout is disabled.

### Disabled or unbounded timeout mechanisms

These settings accept zero and use it to disable a timeout or closely related
mechanism:

| Parameter | Base unit | Example positive input that becomes zero | Resulting zero behavior |
|---|---|---|---|
| `statement_timeout` | milliseconds | `0.0001d` (8.64 s) | timeout disabled |
| `lock_timeout` | milliseconds | `0.001min` (60 ms) | timeout disabled |
| `transaction_timeout` | milliseconds | `0.001h` (3.6 s) | timeout disabled |
| `idle_in_transaction_session_timeout` | milliseconds | `0.001min` (60 ms) | timeout disabled |
| `idle_session_timeout` | milliseconds | `0.001min` (60 ms) | timeout disabled |
| `wal_receiver_timeout` | milliseconds | `0.001h` (3.6 s) | timeout disabled |
| `wal_sender_timeout` | milliseconds | `0.001h` (3.6 s) | timeout disabled |
| `archive_timeout` | seconds | `0.001min` (60 ms) | timeout disabled |
| `idle_replication_slot_timeout` | seconds | `0.001min` (60 ms) | invalidation disabled |
| `client_connection_check_interval` | milliseconds | `0.001min` (60 ms) | connection checks disabled |
| `vacuum_cost_delay` | real milliseconds | `0.0001s` (0.1 ms) | cost-based delay disabled |

### Zero can mean the operational opposite

For other settings, zero is active rather than disabled.  A positive value
that rounds to zero can remove a grace period or enable the most aggressive
behavior:

| Parameter | Example positive input that becomes zero | Meaning of zero |
|---|---|---|
| `log_min_duration_statement` | `0.001h` (3.6 s) | log every statement duration |
| `log_min_duration_sample` | `0.001h` (3.6 s) | sample all statements |
| `log_autovacuum_min_duration` | `0.001h` (3.6 s) | log every qualifying vacuum action |
| `log_autoanalyze_min_duration` | `0.001h` (3.6 s) | log every qualifying analyze action |
| `max_standby_archive_delay` | `0.001h` (3.6 s) | cancel conflicts with no grace period |
| `max_standby_streaming_delay` | `0.001h` (3.6 s) | cancel conflicts with no grace period |
| `wal_sender_shutdown_timeout` | `0.001h` (3.6 s) | do not wait for the receiver |
| `tcp_user_timeout` | `0.001min` (60 ms) | use the operating-system default |
| `recovery_min_apply_delay` | `0.001min` (60 ms) | apply with no delay |
| `io_worker_idle_timeout` | `0.0001s` (0.1 ms) | immediate idle deadline |

### Compatibility considerations

Fractional GUC rounding is documented behavior.  Existing configurations can
therefore depend on a fractional value being rounded to zero, and a global
change from nearest rounding to upward rounding would alter stored values for
more than timeout settings.

The positive case consequently requires a policy decision separate from the
negative range-check problem.  Possible policies include:

- retain the current rounding and document the zero-sentinel consequence;
- reject a positive nonzero input when rounding would select a zero sentinel;
- clamp such inputs to the smallest positive value supported by that
  parameter's base unit; or
- adopt upward rounding for parameters representing deadlines or minimum
  delays while retaining existing behavior for other numeric GUCs.

Any targeted policy needs parameter metadata or a caller-specific decision;
`convert_to_base_unit()` does not know whether zero is disabled, immediate,
inherited, or otherwise special.

## Historical context

The next-smaller-unit rule was introduced by commit
[`1a83a80a2fe`](https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=1a83a80a2fe5b559f85ed4830acb92d5124b7a9a),
"Allow fractional input values for integer GUCs, and improve rounding logic."

Its motivating example was:

```sql
SET work_mem = '30.1GB';
```

The commit explains that directly rounding to the parameter's native unit
would produce a visually awkward value such as `31562138kB`, so it instead
rounds to the nearest multiple of the next smaller unit, producing `30822MB`.
The rule was implemented in the common conversion function and documented for
both memory and time units.

The historical evidence supports the narrower statement that the rounding
rule was motivated with a memory-size example and that its interaction with
zero-sentinel time settings is not discussed in the commit rationale.  It does
not establish that time-unit behavior was accidental: the
[originating discussion](https://www.postgresql.org/message-id/1798.1552165479%40sss.pgh.pa.us)
also concerned time-based vacuum delays, and the committed documentation
deliberately describes the rule generically.

There is contrasting precedent in runtime wait code.  For example:

- [`TimestampDifferenceMilliseconds()`](src/backend/utils/adt/timestamp.c#L1764)
  rounds a fractional millisecond upward because waiting for less than the
  intended timeout is undesirable.
- [`pg_sleep()`](src/backend/utils/adt/misc.c#L331) uses `ceil()` when
  converting seconds to microseconds.

Those functions demonstrate an established reason to round actual waits
upward, but they do not by themselves establish a universal rounding rule for
stored GUC values.  The GUC rule and runtime-wait rules serve different
interfaces, which is why changing the positive-input behavior requires an
explicit compatibility decision.

## Recommendations and regression matrix

The two defects should remain separate in both implementation and regression
coverage.  Rejecting an out-of-range negative value is a range-validation fix;
deciding what to do when a valid positive duration becomes a zero sentinel is
a compatibility policy.

### Recommendations

1. Preserve both the exact converted value and the legacy-rounded value during
   parsing.  The exact value is needed for validation and for recovering the
   duration when legacy rounding would otherwise erase it.  The rounded value
   remains available wherever compatibility requires the documented behavior.

2. Validate the declared lower bound against the exact converted value before
   either lossy rounding stage.  This closes the `min = 0` and `min = -1`
   bypasses without changing the rounding of valid values.

3. Give parameters whose zero value has control semantics an explicit policy
   for positive-nonzero-to-zero collisions.  The preferred behavior is:

   ```text
   if exact_value > 0 and legacy_stored_value == 0:
       integer GUC: store max(1, rint(exact_value))
       real GUC:    store exact_value
   ```

   This policy first restores the requested duration in the parameter's native
   unit and only clamps when that duration is genuinely below the native
   integer resolution.  A blanket post-rounding clamp is not sufficient:
   `0.0001d` means 8.64 seconds, so `statement_timeout` should become `8640ms`,
   not `1ms`.

4. Preserve existing results outside those two cases.  In particular, normal
   nonzero rounding, explicit zero, valid `-1` sentinels, overflow handling,
   and invalid-unit errors should not change.  Parameters with a positive
   minimum already reject zero and should not acquire the new positive policy
   unless that is an intentional, separately reviewed change.

The `min = -1` interval between `-1` and zero is a separate edge case.  For
example, `-0.5ms` is within the declared range but can round to zero.  The
lower-bound fix does not change that result; changing it requires an explicit
decision about the meaning of valid fractional negative input.  Likewise,
whether a textual `-0` should be rejected must be decided explicitly rather
than inferred from the numeric lower bound.

### Regression matrix

| Case | Representative input | Expected result | Purpose |
|---|---|---|---|
| Explicit zero | `statement_timeout = '0'` | stored as `0` | Preserve the documented zero sentinel. |
| `min = 0`, day suffix | `statement_timeout = '-0.0001d'` | error | Negative 8.64 seconds must not become zero. |
| `min = 0`, hour suffix | `statement_timeout = '-0.001h'` | error | Cover the hour-to-minute loss window. |
| `min = 0`, minute suffix | `statement_timeout = '-0.001min'` | error | Cover the minute-to-second loss window. |
| `min = 0`, second suffix | `statement_timeout = '-0.0001s'` | error | Cover the second-to-millisecond loss window. |
| `min = 0`, millisecond suffix | `statement_timeout = '-0.1ms'` | error | Cover the millisecond-to-microsecond conversion plus integer rounding. |
| `min = 0`, microsecond suffix | `statement_timeout = '-100us'` | error | Cover a suffix with no next-smaller-unit rounding. |
| `min = 0`, no suffix | `statement_timeout = '-0.1'` | error | Cover native-unit integer rounding. |
| Real-valued `min = 0` | `vacuum_cost_delay = '-0.0001s'` | error | Prove the fix does not depend on final integer rounding. |
| Below `min = -1` | `log_min_duration_statement = '-0.001h'` | error | A value below `-1ms` must not enter the range as zero. |
| Valid `-1` sentinel | `log_min_duration_statement = '-1ms'` | stored as `-1` | Preserve the disable sentinel. |
| Valid fractional negative | `log_min_duration_statement = '-0.5ms'` | unchanged unless separately specified | Expose the unresolved within-range policy explicitly. |
| Positive coarse spelling | `statement_timeout = '0.0001d'` | stored as `8640ms` | Recover the exact native-unit duration, not merely `1ms`. |
| Equivalent duration across suffixes | `0.0001d`; `0.0024h`; `0.144min`; `8.64s`; `8640ms`; `8640000us` | respectively `8640ms`; `8640ms`; `9000ms`; `8640ms`; `8640ms`; `8640ms` | Repair the zero collisions while preserving the existing nonzero rounding of `0.144min`. |
| Positive below integer resolution | `statement_timeout = '0.1ms'` | stored as `1ms` | Do not turn a requested nonzero timeout into disabled. |
| Positive real value | `vacuum_cost_delay = '0.0001s'` | stored as `0.1ms` | Preserve exact real-valued duration rather than zero. |
| Zero means immediate | `max_standby_streaming_delay = '0.001h'` | stored as `3600ms` | Prevent a positive grace period from becoming immediate cancellation. |
| Normal nonzero value | `statement_timeout = '1.5ms'` | stored as `2ms` | Preserve ordinary documented integer rounding. |
| Positive-min control | `deadlock_timeout = '0.001min'` | existing out-of-range error | Avoid broadening the policy to already-protected parameters. |
| Overflow | an existing over-range time value | error | Preserve range and overflow handling. |
| Invalid unit | `statement_timeout = '1fortnight'` | error | Preserve unit validation. |

The sub-millisecond integer boundary deserves direct coverage because it is
where ties-to-even and the proposed positive policy meet:

| Exact duration | Legacy stored value | Expected opted-in result |
|---:|---:|---:|
| `0.499ms` | `0ms` | `1ms` |
| `0.5ms` | `0ms` | `1ms` |
| `0.501ms` | `1ms` | `1ms` |
| `1.5ms` | `2ms` | `2ms` |

The corresponding negative values should error for a `min = 0` parameter
before either rounding stage.

Session-settable parameters can be exercised in
`src/test/regress/sql/guc.sql` with matching expected output.  SIGHUP-only
parameters need configuration-file plus reload or startup coverage, most
naturally in a TAP test.  At least one test should express the same physical
duration with every accepted suffix so that the intended suffix-specific
outcomes are explicit and any future change is visible directly.
