libaio: grow the staging array instead of aborting when it fills - #133
Merged
Conversation
evpl_libaio_read/write/flush each staged an iocb into a fixed
pending_iocbs array and aborted the process outright once it was full:
evpl_libaio_abort_if(ctx->num_pending >= ctx->max_pending,
"too many pending iocbs");
That treats a full submission queue as a programming error, but it is
ordinary backpressure. evpl_defer() only arms the flush -- the callback
does not run until the current callback chain returns -- so a caller that
issues a batch within one event-loop iteration stages the entire batch
before a single io_submit() happens. Nothing drains in between, and the
staging array is sized to libaio_max_pending (256 by default), which is
the *ring* depth, not a bound on what a caller may hand us.
Callers exceed it legitimately. In chimera's diskfs, the tail-push
thread caps home writes per device (DISKFS_PUSH_DEV_WATERMARK, 64), so
its in-flight budget is 64 x device count -- 640 over the ten devices its
tests configure -- and the commit thread checks its own watermark once
per batch rather than per write, admitting a batch of many chunk writes
whenever redo_inflight is below DISKFS_COMMIT_WATERMARK on entry. Either
ceiling sits above the 256-entry array, and the process dies on the 257th
iocb staged in one pass. That is the "too many pending iocbs" abort seen
intermittently in chimera CI on the libaio backend -- and only there,
since the io_uring backend's ring defaults to 8192.
Grow the array instead. max_pending keeps its real meaning (the
io_setup() nr_events ring depth) and a separate pending_capacity tracks
the allocated staging slots, doubling as needed; evpl_realloc() already
fatals on OOM like the rest of the allocator API. Draining needs no new
machinery: a short or -EAGAIN io_submit() already keeps its unsubmitted
tail queued, and evpl_libaio_complete() re-arms the flush after any
completion batch, so the surplus goes out as ring slots free up.
The three copies of the stage-and-defer sequence become one
evpl_libaio_enqueue() helper.
Adds libevpl/libaio/burst, which sets the ring to 32 and stages 512
writes in a single event-loop iteration. It aborts at libaio_block.c:133
before this change and passes after, with every completion delivered.
benjarvis
force-pushed
the
libaio-grow-pending
branch
from
August 14, 2026 10:55
aa9538d to
a725090
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
evpl_libaio_read/write/flushstaged each iocb into a fixedpending_iocbsarray and killed the process once it filled:That treats a full submission queue as a caller bug, but it is ordinary backpressure. This PR grows the array instead, and adds a regression test that reproduces the abort deterministically.
Why the array fills
evpl_defer()only arms the flush — the callback does not run until the current callback chain returns (evpl.c, deferrals are drained at the top of the loop). So a caller that issues a batch inside one event-loop iteration stages all of it before a singleio_submit()happens. Nothing drains in between.The array is sized to
libaio_max_pending(256 by default), which is the ring depth passed toio_setup()— not a bound on what a caller may hand us in one pass. Callers exceed it legitimately. In chimera's diskfs there are at least two independent paths that can:DISKFS_PUSH_DEV_WATERMARK, 64), so its in-flight budget is 64 × device count — 640 across the ten devices its test harness configures.diskfs_iq_process_batch()returns early only ifredo_inflight >= DISKFS_COMMIT_WATERMARK(256) on entry, then admits a batch whose records each issue several chunk writes. Overshoot past the watermark is expected there by design — the adjacent retire ring is sized1024 /* >= DISKFS_COMMIT_WATERMARK */.Either way the caller's ceiling is above 256 while the staging array is exactly 256, and the process dies on the 257th iocb staged in a single pass.
This is the intermittent
"too many pending iocbs"abort in chimera CI (chimera/posix/diskfs_evict_diskfs_aio, ~1–5 s into a ~60 s test). It is libaio-only because the io_uring backend's ring defaults to 8192 — 32× deeper — so the same burst fits. The io_uring backend has the same shape of hard failure at its own limit (evpl_io_uring_abort_if(!sqe, ...)); only the depth differs.The change
max_pendingkeeps its real meaning — theio_setup()nr_eventsring depth. A newpending_capacitytracks allocated staging slots and doubles on demand.evpl_realloc()already fatals on OOM like the rest of the allocator API, so there is no new error path.evpl_libaio_enqueue()helper.-EAGAINio_submit()already keeps its unsubmitted tail queued, andevpl_libaio_complete()re-arms the flush after any completion batch, so the surplus goes out as ring slots free up.Growth is bounded in practice by whatever in-flight cap the caller enforces, and the array holds one pointer per staged iocb — 640 entries is ~5 KB.
Verification
ubuntu24 / aarch64, Release, libaio via
/usr/lib/aarch64-linux-gnu/libaio.so.New test
libevpl/libaio/burstsets the ring to 32 and stages 512 writes in one event-loop iteration, forcing submission to make partial progress and drain through completions:"too many pending iocbs"atlibaio_block.c:133, SIGABRT (rc=134)libevpl/libaio/basicpasses in both builds, so the abort is specific to the burst shape.Full suite on a clean build of this branch: 162/162 pass.
Note on the chimera-side reproduction
The end-to-end
diskfs_evict_diskfs_aiofailure did not reproduce on my box, and it is worth saying so rather than claiming more than was shown. With the staging high-water mark instrumented, the observed peaks were:All far below 256, and the test passes every time. Burst depth is a function of how long writes stay outstanding, and this box completes them too quickly for in-flight to accumulate toward either caller ceiling — consistent with the CI failure being Release-heavy (10 of 11 tracker events), since it takes an optimized build plus real device latency to hold 257+ writes outstanding. Reproducing it faithfully likely needs CI's degree of oversubscription, where the issuing thread is descheduled while other threads keep feeding its queue.
So what is demonstrated here is the mechanism and the fix, on the exact abort site, message and line seen in CI. The chimera-side attribution above is read off the caller's own constants and corroborated by the CI signature being libaio-only and never io_uring — it is not something I reproduced end to end, and the two candidate paths are not distinguished by the evidence I have.
Separately, chimera should probably not offer a backend more concurrent I/O than its submission path is configured for — a global in-flight ceiling derived from a queryable backend depth rather than constants maintained in parallel on both sides. That belongs in a chimera PR, not here; this change makes the backend correct regardless of what the caller offers.