Skip to content

Commit 515ce42

Browse files
Merge pull request #31 from jeromekelleher/try-cache
Serve cache resident reads synchronously
2 parents eaa5e72 + f8dd49f commit 515ce42

3 files changed

Lines changed: 78 additions & 2 deletions

File tree

biofuse/encoder_host.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ def __init__(
8383
async def read(self, off: int, size: int) -> bytes:
8484
if self._closed:
8585
raise OSError(errno.EIO, "stream handle is closed")
86+
# Fast path: serve from the encoder's in-memory cache without
87+
# taking the lock or dispatching to a worker thread.
88+
# ``try_cached_read`` is documented thread-safe and never
89+
# advances the iterator, so it is safe to call concurrently
90+
# with an in-flight slow-path ``encoder.read`` on another task.
91+
cached = self._encoder.try_cached_read(off, size)
92+
if cached is not None:
93+
return cached
8694
async with self._lock:
8795
if self._closed:
8896
raise OSError(errno.EIO, "stream handle is closed")

tests/test_encoder_host.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,13 @@ def __init__(self) -> None:
7474
self.release_read = threading.Event()
7575
self.entered_read = threading.Event()
7676
self.read_calls: list[tuple[int, int]] = []
77+
self.try_cached_read_calls: list[tuple[int, int]] = []
7778
self.close_calls = 0
7879
self._payload = b"X"
80+
# When set, ``try_cached_read`` returns a slice of this buffer
81+
# so the StreamHandle fast path can be exercised. Default ``None``
82+
# means cache miss — every call falls back to ``read``.
83+
self.cached_payload: bytes | None = None
7984

8085
def set_payload(self, body: bytes) -> None:
8186
self._payload = body
@@ -86,6 +91,12 @@ def read(self, off: int, size: int) -> bytes:
8691
self.release_read.wait()
8792
return self._payload
8893

94+
def try_cached_read(self, off: int, size: int) -> bytes | None:
95+
self.try_cached_read_calls.append((off, size))
96+
if self.cached_payload is None:
97+
return None
98+
return self.cached_payload[off : off + size]
99+
89100
def close(self) -> None:
90101
self.close_calls += 1
91102

@@ -140,6 +151,63 @@ async def test_read_after_close_raises_eio(self):
140151
assert excinfo.value.errno == errno.EIO
141152

142153

154+
class TestStreamHandleCachedFastPath:
155+
async def test_cache_hit_returns_bytes_without_thread_dispatch(self):
156+
encoder = _FakeEncoder()
157+
encoder.cached_payload = b"hello"
158+
# release_read deliberately not set: if the fast path doesn't
159+
# short-circuit, the slow-path worker thread will block forever
160+
# and the test will hang.
161+
handle = encoder_host.StreamHandle(encoder)
162+
try:
163+
got = await handle.read(0, 5)
164+
assert got == b"hello"
165+
assert encoder.try_cached_read_calls == [(0, 5)]
166+
assert encoder.read_calls == []
167+
assert not encoder.entered_read.is_set()
168+
finally:
169+
encoder.release_read.set()
170+
await handle.aclose()
171+
172+
async def test_cache_miss_falls_back_to_slow_path(self):
173+
encoder = _FakeEncoder()
174+
encoder.set_payload(b"world")
175+
encoder.release_read.set()
176+
handle = encoder_host.StreamHandle(encoder)
177+
try:
178+
got = await handle.read(0, 5)
179+
assert got == b"world"
180+
assert encoder.try_cached_read_calls == [(0, 5)]
181+
assert encoder.read_calls == [(0, 5)]
182+
finally:
183+
await handle.aclose()
184+
185+
async def test_cache_hit_does_not_enter_encoder_read(self):
186+
encoder = _FakeEncoder()
187+
encoder.cached_payload = b"ABCDEFGH"
188+
handle = encoder_host.StreamHandle(encoder)
189+
try:
190+
async with trio.open_nursery() as nursery:
191+
for off in range(4):
192+
nursery.start_soon(handle.read, off, 2)
193+
assert len(encoder.try_cached_read_calls) == 4
194+
assert encoder.read_calls == []
195+
finally:
196+
encoder.release_read.set()
197+
await handle.aclose()
198+
199+
async def test_cache_hit_after_close_raises_eio_without_calling_encoder(self):
200+
encoder = _FakeEncoder()
201+
encoder.cached_payload = b"never seen"
202+
encoder.release_read.set()
203+
handle = encoder_host.StreamHandle(encoder)
204+
await handle.aclose()
205+
with pytest.raises(OSError, match="stream handle is closed") as excinfo:
206+
await handle.read(0, 1)
207+
assert excinfo.value.errno == errno.EIO
208+
assert encoder.try_cached_read_calls == []
209+
210+
143211
class TestStreamHandleSerialisation:
144212
async def test_concurrent_reads_serialise_on_one_handle(self):
145213
"""Two concurrent ``handle.read`` calls must not enter the

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)