Skip to content

Commit 9c46eb8

Browse files
Merge pull request #23 from jeromekelleher/on-demand-sidecars
Build encoder-server static sidecars on demand
2 parents 690d38c + f0ed251 commit 9c46eb8

5 files changed

Lines changed: 463 additions & 149 deletions

File tree

biofuse/encoder_client.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,14 +201,17 @@ async def start(
201201
try:
202202
await self._handshake()
203203
except BaseException as exc:
204+
# If the subprocess has exited on its own during startup
205+
# the handshake failure is a downstream symptom (refused
206+
# connect, RST mid frame, premature EOF). Surface a single
207+
# clean OSError so the CLI prints one error line and
208+
# points the user at the server's own log. Check
209+
# ``is_alive`` before ``aclose`` — the latter signals stop
210+
# and joins the child, after which ``exitcode`` is set
211+
# regardless of why the child exited.
212+
child_died_on_own = not proc.is_alive()
204213
await self.aclose()
205-
# If the subprocess has exited during startup the handshake
206-
# failure is a downstream symptom (refused connect, RST mid
207-
# frame, premature EOF). Surface a single clean OSError so
208-
# the CLI prints one error line and points the user at the
209-
# server's own log, instead of leaking the trio / socket
210-
# exception type to the caller.
211-
if proc.exitcode is not None and not isinstance(exc, KeyboardInterrupt):
214+
if child_died_on_own and not isinstance(exc, KeyboardInterrupt):
212215
raise OSError(
213216
errno.EIO,
214217
f"{spec.name}-server exited during startup; "

biofuse/encoder_server.py

Lines changed: 130 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
``AF_UNIX`` listening socket. The owning :class:`~biofuse.formats.FormatSpec`
66
selects:
77
8-
- which static sidecar files to precompute and cache (``.bim``/``.fam``
9-
for PLINK; ``.sample``/``.bgen.bgi`` for BGEN), via
10-
:meth:`FormatSpec.build_static_files`;
8+
- which static sidecar files to build on demand for the metadata
9+
handshake (``.bim``/``.fam`` for PLINK; ``.sample``/``.bgen.bgi``
10+
for BGEN), via :meth:`FormatSpec.build_static_files`;
1111
- which encoder class to construct per accepted connection, via
1212
:meth:`FormatSpec.encoder_factory`.
1313
@@ -37,10 +37,13 @@
3737
class _ServerSession:
3838
"""Server-side state shared across all connection threads.
3939
40-
Holds the ``VczReader``, the precomputed static-file bodies (in
41-
the format spec's declared order), and the streaming-file size.
42-
Immutable after construction; safe to read concurrently from any
43-
thread without locking.
40+
Holds the ``VczReader`` and the active format spec. The static
41+
sidecar bytes and the streaming-file size are both built on demand
42+
inside the metadata handshake (see ``_make_metadata_reply``)
43+
rather than cached here — the parent fuse handler is the canonical
44+
owner of that metadata after the handshake completes. Immutable
45+
after construction; safe to read concurrently from any thread
46+
without locking.
4447
"""
4548

4649
def __init__(
@@ -50,19 +53,6 @@ def __init__(
5053
) -> None:
5154
self.reader = reader
5255
self.spec = spec
53-
self.static_files: dict[str, bytes] = spec.build_static_files(reader)
54-
missing = set(spec.static_suffixes) - set(self.static_files)
55-
extra = set(self.static_files) - set(spec.static_suffixes)
56-
if missing or extra:
57-
raise ValueError(
58-
f"{spec.name}: build_static_files returned keys "
59-
f"{sorted(self.static_files)}; expected {list(spec.static_suffixes)}"
60-
)
61-
# Open one throwaway encoder to read ``total_size`` — encoder
62-
# construction is I/O-free, so this is cheap. Per-connection
63-
# encoders are constructed fresh in ``_handle_connection``.
64-
with spec.encoder_factory(reader) as encoder:
65-
self.stream_size: int = int(encoder.total_size)
6656

6757

6858
def _recv_exact_sync(sock: socket.socket, n: int) -> bytes:
@@ -85,6 +75,113 @@ def _recv_exact_sync(sock: socket.socket, n: int) -> bytes:
8575
return bytes(buf)
8676

8777

78+
def _make_error_reply(exc: BaseException, context: str) -> bytes:
79+
"""Pack an errno reply for ``exc`` and log the cause.
80+
81+
Non-``OSError`` causes log a one-line ERROR with the errno and
82+
the exception text, plus a DEBUG-only traceback. Used by both
83+
the encoder-construction failure path and the per-dispatch
84+
exception handler.
85+
"""
86+
err = encoder_protocol.errno_for_exception(exc)
87+
if not isinstance(exc, OSError):
88+
logger.error("encoder-server %s; replying with errno %d: %s", context, err, exc)
89+
logger.debug("encoder-server %s traceback", context, exc_info=True)
90+
return encoder_protocol.pack_error_reply(err)
91+
92+
93+
def _make_metadata_reply(session: "_ServerSession") -> bytes:
94+
"""Build the reply for a ``TAG_GET_METADATA`` request.
95+
96+
The static sidecars and the streaming-file size are built on
97+
demand from the format spec, serialised into the reply, then
98+
dropped: the parent fuse handler owns the canonical copy after
99+
the handshake. Encoder construction is I/O-free so reading
100+
``total_size`` here is cheap.
101+
"""
102+
static_files = session.spec.build_static_files(session.reader)
103+
missing = set(session.spec.static_suffixes) - set(static_files)
104+
extra = set(static_files) - set(session.spec.static_suffixes)
105+
if missing or extra:
106+
raise ValueError(
107+
f"{session.spec.name}: build_static_files returned keys "
108+
f"{sorted(static_files)}; expected {list(session.spec.static_suffixes)}"
109+
)
110+
with session.spec.encoder_factory(session.reader) as encoder:
111+
stream_size = int(encoder.total_size)
112+
ordered_bodies = [static_files[suffix] for suffix in session.spec.static_suffixes]
113+
return encoder_protocol.pack_metadata_reply(ordered_bodies, stream_size)
114+
115+
116+
def _make_read_reply(conn_sock: socket.socket, encoder, tname: str) -> bytes | None:
117+
"""Build the reply for a ``TAG_READ`` request, or ``None`` on
118+
truncated payload (caller terminates the connection)."""
119+
payload = _recv_exact_sync(conn_sock, encoder_protocol.REQ_READ_PAYLOAD_SIZE)
120+
if len(payload) < encoder_protocol.REQ_READ_PAYLOAD_SIZE:
121+
return None
122+
off, size = encoder_protocol.parse_read_payload(payload)
123+
t_read = time.monotonic()
124+
data = encoder.read(off, size)
125+
logger.debug(
126+
"%s: encoder.read off=%d size=%d in %.3fs",
127+
tname,
128+
off,
129+
size,
130+
time.monotonic() - t_read,
131+
)
132+
return encoder_protocol.pack_read_reply(data)
133+
134+
135+
def _send_reply(conn_sock: socket.socket, reply: bytes) -> bool:
136+
"""Send ``reply`` on the socket; return ``True`` on success and
137+
``False`` if the send raised ``OSError`` (already logged)."""
138+
try:
139+
conn_sock.sendall(reply)
140+
return True
141+
except OSError as exc:
142+
logger.warning("encoder-server send failed: %s", exc)
143+
return False
144+
145+
146+
def _serve_connection(
147+
conn_sock: socket.socket,
148+
session: "_ServerSession",
149+
encoder,
150+
tname: str,
151+
) -> None:
152+
"""Run the tag-dispatch loop for one connected client.
153+
154+
Exits cleanly on EOF, unknown tag, truncated payload, or a send
155+
failure. Exceptions raised inside a dispatch case are converted
156+
to errno replies and the loop continues to the next request.
157+
"""
158+
while True:
159+
try:
160+
tag_buf = _recv_exact_sync(conn_sock, 1)
161+
except EOFError as exc:
162+
logger.warning("encoder-server frame error: %s", exc)
163+
return
164+
if len(tag_buf) == 0:
165+
return
166+
tag = bytes(tag_buf)
167+
try:
168+
if tag == encoder_protocol.TAG_GET_METADATA:
169+
reply = _make_metadata_reply(session)
170+
elif tag == encoder_protocol.TAG_READ:
171+
reply = _make_read_reply(conn_sock, encoder, tname)
172+
else:
173+
logger.warning(
174+
"encoder-server: unknown tag %r; closing connection", tag
175+
)
176+
return
177+
except Exception as exc: # noqa: BLE001 - any error becomes errno reply
178+
reply = _make_error_reply(exc, "dispatch raised")
179+
if reply is None:
180+
return
181+
if not _send_reply(conn_sock, reply):
182+
return
183+
184+
88185
def _handle_connection(conn_sock: socket.socket, session: _ServerSession) -> None:
89186
"""Run one client connection synchronously. Returns on EOF.
90187
@@ -102,73 +199,15 @@ def _handle_connection(conn_sock: socket.socket, session: _ServerSession) -> Non
102199
t_enc = time.monotonic()
103200
encoder_cm = session.spec.encoder_factory(session.reader)
104201
except Exception as exc: # noqa: BLE001 - any error becomes errno reply
105-
err = encoder_protocol.errno_for_exception(exc)
106-
if not isinstance(exc, OSError):
107-
logger.exception(
108-
"encoder-server encoder construction failed; replying with errno"
109-
)
110-
try:
111-
conn_sock.sendall(encoder_protocol.pack_error_reply(err))
112-
except OSError as send_exc:
113-
logger.warning("encoder-server send failed: %s", send_exc)
202+
_send_reply(
203+
conn_sock, _make_error_reply(exc, "encoder construction failed")
204+
)
114205
return
115206
with encoder_cm as encoder:
116207
logger.debug(
117208
"%s: encoder created in %.3fs", tname, time.monotonic() - t_enc
118209
)
119-
while True:
120-
try:
121-
tag_buf = _recv_exact_sync(conn_sock, 1)
122-
except EOFError as exc:
123-
logger.warning("encoder-server frame error: %s", exc)
124-
return
125-
if len(tag_buf) == 0:
126-
return
127-
tag = bytes(tag_buf)
128-
try:
129-
if tag == encoder_protocol.TAG_GET_METADATA:
130-
ordered_bodies = [
131-
session.static_files[suffix]
132-
for suffix in session.spec.static_suffixes
133-
]
134-
reply = encoder_protocol.pack_metadata_reply(
135-
ordered_bodies, session.stream_size
136-
)
137-
elif tag == encoder_protocol.TAG_READ:
138-
payload = _recv_exact_sync(
139-
conn_sock, encoder_protocol.REQ_READ_PAYLOAD_SIZE
140-
)
141-
if len(payload) < encoder_protocol.REQ_READ_PAYLOAD_SIZE:
142-
return
143-
off, size = encoder_protocol.parse_read_payload(payload)
144-
t_read = time.monotonic()
145-
data = encoder.read(off, size)
146-
logger.debug(
147-
"%s: encoder.read off=%d size=%d in %.3fs",
148-
tname,
149-
off,
150-
size,
151-
time.monotonic() - t_read,
152-
)
153-
reply = encoder_protocol.pack_read_reply(data)
154-
else:
155-
logger.warning(
156-
"encoder-server: unknown tag %r; closing connection",
157-
tag,
158-
)
159-
return
160-
except Exception as exc: # noqa: BLE001 - any error becomes errno reply
161-
err = encoder_protocol.errno_for_exception(exc)
162-
if not isinstance(exc, OSError):
163-
logger.exception(
164-
"encoder-server dispatch raised; replying with EIO"
165-
)
166-
reply = encoder_protocol.pack_error_reply(err)
167-
try:
168-
conn_sock.sendall(reply)
169-
except OSError as exc:
170-
logger.warning("encoder-server send failed: %s", exc)
171-
return
210+
_serve_connection(conn_sock, session, encoder, tname)
172211
logger.debug("%s: conn thread exit", tname)
173212

174213

@@ -253,12 +292,15 @@ def _server_main(
253292
``make_reader`` consumes.
254293
255294
Any exception raised before ``serve_forever`` starts (reader
256-
construction, ``_ServerSession`` construction — the latter eagerly
257-
walks the variants to build the static sidecars and may reject e.g.
258-
multi-allelic input) is caught here so multiprocessing's default
259-
handler does not print a traceback. The cause is logged at ERROR
260-
(visible at default verbosity); the traceback only surfaces at
261-
DEBUG.
295+
construction, ``_ServerSession`` construction) is caught here so
296+
multiprocessing's default handler does not print a traceback. The
297+
cause is logged at ERROR (visible at default verbosity); the
298+
traceback only surfaces at DEBUG. Static-sidecar build errors
299+
(e.g. multi-allelic input rejection) now surface inside
300+
``_handle_connection`` on the first metadata request rather than
301+
at session construction; the handler converts them to errno
302+
replies so the parent sees a clean ``OSError`` from
303+
``_handshake``.
262304
"""
263305
log_config.apply()
264306
try:

tests/test_encoder_client.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,22 +344,25 @@ class TestRealSubprocess:
344344
@pytest.mark.parametrize(
345345
"spec", [formats.PLINK_SPEC, formats.BGEN_SPEC], ids=["plink", "bgen"]
346346
)
347-
async def test_start_fast_fails_on_subprocess_startup_error(
347+
async def test_start_fast_fails_on_handshake_build_error(
348348
self, fx_multiallelic_vcz, tmp_path, spec
349349
):
350350
"""``EncoderClient.start()`` must surface a clean ``OSError`` and
351351
return well under the 10 s connect deadline when the subprocess
352-
catches a startup error (here: multi-allelic VCZ) and exits."""
352+
rejects the input during the metadata handshake. Static-file
353+
build (here: multi-allelic VCZ) runs on demand inside
354+
``_handle_connection``; failures become errno replies on the
355+
handshake socket."""
353356
socket_path = tmp_path / "encoder.sock"
354357
t0 = trio.current_time()
355-
with pytest.raises(OSError, match="exited during startup") as excinfo:
358+
with pytest.raises(OSError, match="encoder-server reported errno") as excinfo:
356359
await encoder_client.EncoderClient.start(
357360
str(fx_multiallelic_vcz.path), socket_path, spec
358361
)
359362
elapsed = trio.current_time() - t0
360363
assert excinfo.value.errno == errno.EIO
361364
assert elapsed < encoder_client._CONNECT_DEADLINE_S, (
362-
f"start() should fast-fail when child dies, took {elapsed:.2f}s"
365+
f"start() should fast-fail on handshake error, took {elapsed:.2f}s"
363366
)
364367

365368
async def test_max_alleles_filter_through_start(

0 commit comments

Comments
 (0)