Skip to content

Commit 690d38c

Browse files
Merge pull request #22 from jeromekelleher/more-ts-hardening
Diagnose mmap-read EAGAIN; mark mmap-read informational
2 parents 21ac529 + 9b11da3 commit 690d38c

5 files changed

Lines changed: 69 additions & 14 deletions

File tree

biofuse/access_log.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ def record_event(
9494
t_end: float | None = None,
9595
) -> None:
9696
"""Record a non-read lifecycle event (open / release / limiter_wait /
97-
aclose). ``offset`` and ``size`` are zero for these events; the
98-
``[t_start, t_end]`` interval is what matters."""
97+
limiter_timeout / aclose). ``offset`` and ``size`` are zero for
98+
these events; the ``[t_start, t_end]`` interval is what matters."""
9999
if t_end is None:
100100
t_end = time.monotonic()
101101
self._write(

biofuse/encoder_ops.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@
4545
# Maximum time a FUSE_OPEN may wait at the per-mount streaming-file
4646
# capacity limiter. On expiry the open returns ``EAGAIN`` to the kernel
4747
# rather than blocking forever — this guards against a leaked limiter
48-
# slot permanently wedging open().
48+
# slot permanently wedging open(). The timeout is also recorded in the
49+
# access log as a ``limiter_timeout`` event so a post-hoc trace can
50+
# tell EAGAIN-from-limiter from any other EAGAIN.
4951
_LIMITER_TIMEOUT_S = 30.0
5052

5153
_STATIC_KIND = "static"
@@ -222,6 +224,9 @@ async def open(self, inode, flags, ctx=None):
222224
await self._stream_limiter.acquire_on_behalf_of(fh)
223225
t_limiter_end = time.monotonic()
224226
if cs.cancelled_caught:
227+
self._record_event(
228+
"limiter_timeout", name, fh, t_limiter_start, t_limiter_end
229+
)
225230
raise pyfuse3.FUSEError(errno.EAGAIN)
226231
self._record_event("limiter_wait", name, fh, t_limiter_start, t_limiter_end)
227232
try:

fs_tests/README.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,22 @@ host (page cache, CPU, fixture size).
9797
each job in `harness/fio_runner.py`:
9898

9999
- Gated streaming jobs against `.bed`: `seq-read`, `rand-read`,
100-
`mmap-read`, `parallel-seq-read` (numjobs=4 sequential). Errors here
101-
fail the runner.
102-
- Informational streaming job: `multithread` (numjobs=16, random) is
103-
expected to time out under FUSE backpressure (the kernel returns
104-
EAGAIN); its errors are recorded in the detail line but do not gate
105-
the run. The concurrency-overlap check it drives is still gated.
100+
`parallel-seq-read` (numjobs=4 sequential). Errors here fail the
101+
runner.
102+
- Informational streaming jobs:
103+
- `multithread` (numjobs=16 random) times out under FUSE
104+
backpressure (EAGAIN).
105+
- `mmap-read` (single-job mmap) triggers EAGAIN via a different
106+
path: fio's mmap engine does a tight open/close storm (~5000/s on
107+
a host filesystem), which on biofuse fills the streaming-fh
108+
limiter because each FUSE_OPEN spins up a fresh encoder-server
109+
connection. The limiter wait expires and surfaces as EAGAIN.
110+
Bytes that fio did manage to read came back valid; the failure is
111+
about the open rate, not data correctness. Triggers a
112+
`limiter_timeout` event in the access log when it fires.
113+
Both jobs' errors are recorded in the detail line but do not gate
114+
the run. The concurrency-overlap checks they drive *are* gated —
115+
they confirm the kernel is fanning out parallel readahead.
106116
- Gated static-file jobs: `static-stress-bim` / `static-stress-fam`
107117
hammer the in-memory cached `.bim` and `.fam` files with
108118
`numjobs=16` random reads. These must always pass.

fs_tests/harness/fio_runner.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,28 @@ class _JobSpec:
2828

2929

3030
# ``gate=False`` jobs are informational: errors are recorded in the
31-
# detail line but don't fail the runner. ``fio-multithread.fio`` is the
32-
# 16-way parallel random read that reliably times out under FUSE
33-
# backpressure (EAGAIN); we keep it in the schedule because the
34-
# concurrency check it drives is still a useful regression signal.
31+
# detail line but don't fail the runner.
32+
#
33+
# - ``fio-multithread.fio`` is the 16-way parallel random read that
34+
# reliably times out under FUSE backpressure (EAGAIN).
35+
# - ``fio-mmap-read.fio`` triggers EAGAIN via a different path: fio's
36+
# mmap engine does a tight open/close storm (~5000/s on a host fs),
37+
# which on biofuse fills the streaming-fh limiter because each
38+
# FUSE_OPEN spins up a fresh encoder-server connection. The
39+
# resulting EAGAIN is unrelated to data correctness — bytes already
40+
# read came back valid. The job stays in the schedule because the
41+
# ``:concurrent`` check it drives confirms the kernel is fanning out
42+
# readahead on a single fh.
3543
JOBS: tuple[_JobSpec, ...] = (
3644
_JobSpec("seq-read", "fio-seq-read.fio", ".bed"),
3745
_JobSpec("rand-read", "fio-rand-read.fio", ".bed"),
38-
_JobSpec("mmap-read", "fio-mmap-read.fio", ".bed", concurrent="single-fh"),
46+
_JobSpec(
47+
"mmap-read",
48+
"fio-mmap-read.fio",
49+
".bed",
50+
gate=False,
51+
concurrent="single-fh",
52+
),
3953
_JobSpec(
4054
"parallel-seq-read",
4155
"fio-parallel-seq-read.fio",

tests/test_encoder_ops.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,32 @@ async def test_open_returns_eagain_when_limiter_starved(
484484
finally:
485485
await ops.release(held.fh)
486486

487+
async def test_limiter_timeout_records_access_event(
488+
self, fx_client, fx_spec, monkeypatch
489+
):
490+
"""A timed-out FUSE_OPEN must leave a ``limiter_timeout`` event
491+
in the access log so post-hoc analysis can attribute an
492+
``EAGAIN`` to limiter starvation rather than some other path."""
493+
timeout_s = 0.2
494+
monkeypatch.setattr(encoder_ops, "_LIMITER_TIMEOUT_S", timeout_s)
495+
log = access_log.AccessLogger()
496+
ops = encoder_ops.EncoderOps(
497+
fx_client, "small", fx_spec, max_open_stream=1, access_logger=log
498+
)
499+
stream_inode = ops._name_to_inode[f"small{fx_spec.streaming_suffix}"]
500+
held = await ops.open(stream_inode, os.O_RDONLY)
501+
try:
502+
await _expect_fuse_error(ops.open(stream_inode, os.O_RDONLY), errno.EAGAIN)
503+
finally:
504+
await ops.release(held.fh)
505+
timeouts = [r for r in log.records if r.kind == "limiter_timeout"]
506+
assert len(timeouts) == 1
507+
rec = timeouts[0]
508+
# The held open ran first and got fh=1; the timed-out attempt is
509+
# the next allocated fh.
510+
assert rec.fh == held.fh + 1
511+
assert rec.t_end - rec.t_start >= timeout_s
512+
487513

488514
class TestLifecycleEvents:
489515
"""``open`` / ``release`` / ``aclose`` / ``limiter_wait`` events

0 commit comments

Comments
 (0)