Analysis: io_min_workers > io_max_workers Semantics in AIO Worker Pool
Background and Architectural Context
Commit d1c01b79d4ae ("aio: Adjust I/O worker pool automatically") is part of the asynchronous I/O (AIO) subsystem introduced by Thomas Munro and Andres Freund in PostgreSQL 18. The io_method=worker backend relies on a pool of dedicated I/O worker processes that service submission queues on behalf of regular backends. Because workload I/O demand is bursty, the pool needs to scale dynamically rather than being statically sized, hence the introduction of an adjustment loop driven by maybe_start_io_workers_scheduled_at().
The pool is bounded by two GUCs:
io_min_workers— the floor the pool should maintain (to absorb sudden bursts without incurring worker-start latency).io_max_workers— the ceiling above which the pool will not grow.
Both are validated independently with the range [1, MAX_IO_WORKERS]. Crucially, there is no cross-GUC assign hook that rejects or reconciles the case where io_min_workers > io_max_workers.
The Reported Issue
The reporter (xunengzhou) observed that with io_min_workers = 32 and io_max_workers = 1, postgres silently accepts the configuration. Then, inside the scheduler helper:
if (io_worker_count >= io_max_workers)
return 0; /* do not schedule another start */
if (io_worker_count < io_min_workers)
return TIMESTAMP_MINUS_INFINITY; /* start one immediately */
Because the io_max_workers guard is evaluated first, it short-circuits the io_min_workers branch. Effectively, io_max_workers silently caps io_min_workers, and the documented "minimum" is not actually honored when it exceeds the maximum.
Why This Matters
- Silent misconfiguration. An operator who sets
io_min_workershigh (perhaps viaALTER SYSTEM) and later lowersio_max_workersbelow it will get a pool sized byio_max_workers, with no warning. Theio_min_workersvalue becomes a lie inpg_settings. - Precedent from autovacuum. The reporter correctly draws a parallel to the recent
autovacuum_max_workers/autovacuum_worker_slotswork (PG17), where a similar ordering issue was handled by emitting aWARNINGat GUC assignment / at launcher start when the relationship is inverted. That precedent establishes community-accepted behavior: do not reject, but inform. - Code ordering is load-bearing. The current behavior is a consequence of the order of two
ifstatements rather than an explicit policy decision. That is a fragile way to encode semantics — a future refactor could invert the ordering and silently change which GUC "wins."
Design Options
The post implicitly enumerates three possible resolutions:
- Document the current behavior. Add a note in
config.sgmlthatio_max_workerscaps the effectiveio_min_workers. Lowest-cost fix; preserves current runtime behavior. Weakness: leaves a foot-gun in place. - Emit a WARNING (autovacuum-style). When either GUC is assigned such that
io_min_workers > io_max_workers, log a warning. This is the most consistent option given the autovacuum precedent and is likely the preferred approach for committers who value cross-subsystem consistency. - Reject the configuration via a GUC check hook. Stronger, but problematic because GUC check hooks see only one variable at a time; the other may be mid-assignment or not yet loaded during startup. This is why autovacuum chose WARNING rather than ERROR.
Technical Subtleties
- GUC check hook ordering at startup.
postgresql.confvalues are applied in file order. A check hook onio_min_workersthat inspectsio_max_workersmay see the old (default) value of the latter. This is the canonical reason PostgreSQL tends to defer such cross-GUC validation to the consuming code path rather than the assign/check hooks — and emit a WARNING from there. TIMESTAMP_MINUS_INFINITYreturn convention. The scheduler returns-infinityto mean "schedule immediately." Reordering the twoifchecks so that the min branch is evaluated first would be incorrect: it would allow the pool to exceedio_max_workerstransiently wheneverio_min > io_max, violating the harder invariant (the max). So the current ordering is defensible —io_max_workersshould win — but that decision deserves to be explicit.- Interaction with
MAX_IO_WORKERS. The compile-time ceiling means both GUCs share an upper bound, but nothing ties their runtime relationship.
Likely Resolution
Given Thomas Munro's style and the autovacuum precedent, the most likely outcome is a small patch that:
- Keeps the current ordering (max wins).
- Adds a
WARNING(likely inassign_io_max_workers/assign_io_min_workersor at postmaster startup) whenio_min_workers > io_max_workers. - Possibly adds a documentation note clarifying that
io_max_workersis the hard cap.
This is a minor correctness/UX issue rather than a deep architectural problem — the AIO pool sizing algorithm itself is sound; only the GUC validation surface is underspecified.