feat(raw-reads-processing): use the deacon Python bindings instead of the CLI server - #7202
Draft
corneliusroemer-agent wants to merge 3 commits into
Draft
corneliusroemer-agent wants to merge 3 commits into
corneliusroemer-agent wants to merge 3 commits into
Conversation
… the CLI server The service shelled out to `deacon --use-server filter` per request, against a `deacon server` subprocess started at boot. The PyPI `deacon` package (same 0.17.0 we already pin) loads the index once into this process and filters against it directly, so requests share one index without a daemon in between. Measured against the real 3.28GB panhuman-1 index: 8 concurrent POSTs completed 5.2s of filtering in 1.0s wall, with peak RSS flat at 4.65GB — one copy of the index, and real parallelism, because deacon.Index is immutable and releases the GIL while filtering. This deletes the whole class of deacon-server failure modes: the early-disconnect wedge (daemon alive at 99% CPU, every later filter hangs, liveness checks still pass), the information-free EOF panic, the stale socket left behind after a crash, and the serial accept loop that queued concurrent requests. Sharing is now per-process rather than per-host, so uvicorn workers=1 becomes load-bearing: a second worker would load a second 4.5GB index and exceed the pod's 8Gi limit. Noted at the call site. Two behaviours had to change rather than port across: - `deacon_filter_timeout_seconds` is gone. `subprocess.run(timeout=)` could kill a hung child; a Rust call that has released the GIL cannot be interrupted. Input size is what bounds runtime now, enforced mid-download by `max_input_file_bytes`. - Output goes to /dev/null explicitly. With no `-o` deacon writes to stdout, which the server previously swallowed. In-process that would have put the human-matching reads — the exact thing this service exists to keep out — into the pod logs. Also: R1/R2 are named explicitly rather than relying on dict ordering, /health reports whether the index is loaded, and the deacon threshold tests now always run (they used to skip when the CLI was absent from PATH) against a session-scoped index, which is how the service uses it. The conda `deacon` CLI stays in environment.yml: build-index.sh still needs it, and the index-build workflow builds its environment from that same file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CranotPXe4QCrgZdwmkzSa
Nothing loaded config/defaults.yaml through get_config — every test built Config directly — so a key missing from defaults would only have surfaced as a pydantic ValidationError at pod startup, with the whole suite green. Also narrows the /health comment: there is no daemon to wedge any more, but a filter call that hangs in-process still leaks a concurrency slot while /health answers 200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CranotPXe4QCrgZdwmkzSa
…handled failures Nothing caught the exceptions the bindings raise, so bad input would have surfaced as an unhandled 500 with no logged reason. The old code turned a non-zero deacon exit into a ProcessingFailure; restore that, including the TODO about alerting on repeated deacon failures. Testing input deacon rejects also showed malformed FASTQ never reaches deacon at all: sampling the read length to choose -a hits Biopython's parser first and raised an uncaught ValueError. That now becomes the same "empty or corrupted" annotation the empty-file case already produced. Behaviour on a mismatched pair, end to end against the real index: HTTP 500 carrying deacon's own message, and the next request succeeds. Under the server this input killed the daemon and left its socket behind, breaking every later submission until a restart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CranotPXe4QCrgZdwmkzSa
Contributor
|
This PR may be related to: #7110 (raw-reads-processing: send slack notifications if deacon server panics/crashes and the pod needs to be restarted) — this PR removes the deacon server/daemon architecture entirely (in favor of in-process Python bindings) and turns deacon errors into handled |
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.
The service ran a
deacon serversubprocess and shelled out todeacon --use-server filteronce per request. The same deacon release (0.17.0) is published on PyPI with Python bindings, which load the index into our own process and filter against it directly. Same index, same thresholds, same verdicts — no daemon and no socket in between.Why
Server mode is the only way the CLI can share one loaded index, and it is fragile in exactly the way that matters for a service. A client that disconnects before finishing a request puts the daemon into a busy loop it never recovers from: the process stays alive so the liveness probe keeps passing, but every later filter hangs. When the daemon does die it leaves its socket file behind, so subsequent calls fail with a misleading "connection refused" instead of anything about deacon. And any error inside the daemon reaches us as an uninformative panic, because the protocol between client and server has no way to express an error at all. On top of that the daemon handles one request at a time, so our concurrent requests queued up behind each other.
All of that is gone. There is no second process to die, wedge, or lose its socket, and errors arrive as ordinary Python exceptions with real messages.
It is also faster, because requests now filter in parallel instead of queueing. The bindings hold the index immutably and release the interpreter lock while filtering, so one loaded index serves many threads at once without being copied.
Measured against the real 3.28 GB panhuman index, driving the actual service over HTTP with 602,800 read pairs per request:
Memory stays flat as concurrency rises, which is the point: there is one index in RAM, not one per request. Index load at startup is about 5 seconds warm and 30 seconds cold, well inside the startup probe's budget.
What a reviewer should look at
One index now means one process. The index is roughly 4.5 GB deserialised onto the heap, not memory-mapped, so each process that loads it pays for its own copy. Sharing between requests is now between threads rather than between processes — which is what the socket bought us before. That costs nothing today because we already run a single replica with a single uvicorn worker, but it makes
workers=1load-bearing rather than incidental: a second worker would quietly load a second copy and blow the pod's 8Gi limit. There is a comment at the uvicorn config saying so. If we ever want several workers or several pods sharing one index, this is the wrong approach and we should talk about it before scaling.The filter call can no longer be timed out, so
deacon_filter_timeout_secondsis gone. Killing a subprocess on timeout was straightforward; a call that has handed control to Rust and released the interpreter lock cannot be interrupted from Python at all. Rather than leave a config key that silently does nothing, the bound is now on input size:max_input_file_bytes(20 GiB, per file) is checked while the file downloads, so an oversized submission is rejected before it reaches disk or deacon. Worth being explicit that this bounds a large input, not a hang — if a filter call ever wedges, that request waits forever and holds one of the concurrency slots, while/healthstill answers 200 because the index is loaded. That is the same "probe passes while filtering is broken" shape we just removed from the server, relocated rather than eliminated. A watchdog that fails the pod is the alternative; happy to add one if you'd rather not accept this.Thread budget is now configurable and needs tuning.
deacon_threads: 4anddeacon_max_concurrent_filters: 2are starting values, not measured optima. Without a cap this would be dangerous: the endpoint is a plaindef, so FastAPI runs it in a threadpool of about 40 slots, and eight worker threads in each of those is several hundred threads. A semaphore bounds how many filters run at once. Note the pod's 50m CPU request with no limit is unchanged by this PR — the old server also defaulted to eight threads.What happens on input deacon rejects
Worth its own section, because this is where the old setup was at its worst and the change is easiest to under-sell.
Hand deacon two supposedly-paired files with different numbers of reads and it stops with "Incompatible record set sizes". Under the server that killed the daemon, and because only a clean shutdown removes the socket file, every subsequent submission then failed with a confusing "connection refused" until someone restarted the pod. One bad submission broke host filtering for everybody.
In-process, submitting a mismatched pair three times in a row against the real index returns HTTP 500 each time with deacon's own message in it, and the very next good submission returns 200. Health stays green, and four concurrent good requests afterwards still complete in 0.46 s, so no concurrency slot was leaked. Same for a truncated record, a corrupt gzip file, a file that is not FASTQ at all, and a missing file: each is an ordinary Python exception carrying a specific message, and the loaded index is unaffected. Even the case that makes the command-line tool abort outright — pointing it at a compressed index — merely raises here, because the bindings convert errors where the command-line tool unwraps them.
One case deacon itself does not object to is an empty file — it filters to zero reads and reports success. That is caught before deacon twice over, so it is a caution for anyone else calling the bindings rather than a hole here: readtools rejects an empty FASTQ (plain or gzipped) with "Cannot determine candidate qualities: no qualities found", and if it were ever skipped, sampling the read length to choose
-arejects it too.Testing this turned up two gaps that are fixed in the third commit. Nothing caught the exceptions, so any of the above would have surfaced as a generic 500 with no logged reason — the old code turned a non-zero deacon exit into a
ProcessingFailure, and that is restored along with the note about alerting when deacon starts failing. Separately, malformed FASTQ never reaches deacon at all: we sample the first hundred reads to decide whether the library is short-read, and Biopython's parser throws first. That was an uncaughtValueError; it now produces the same "file may be empty or corrupted" message the submitter already gets for an empty file.Two things that would have been bugs in a literal port
Neither is visible from reading the old code, so flagging both.
The old call passed no output path. In the CLI that means "write to stdout", and it was harmless only because the server owned stdout and we had pointed it at
/dev/null. In-process, stdout is ours, and we run deacon in search mode — meaning the reads it writes out are the ones that matched the human index. A faithful port would have printed human reads into the pod logs, which is the exact thing this service exists to prevent. Output now goes to/dev/nullexplicitly, with a comment. As a side effect this also stops us buffering those reads into memory, which the oldcapture_output=Truedid.The bindings offer a
check_pairsoption that verifies R1 and R2 names line up, which looks like a natural safety net given we pass two files positionally. It is too strict to use: it rejected ordinary paired FASTQ from SRA whose names do end in/1and/2, because a description follows the read name. Left off. Instead R1 and R2 are named explicitly at the call site rather than relying on dictionary ordering to put them in the right order.Deliberately not changed
The conda
deaconCLI stays inenvironment.ymleven though the service no longer uses it:build-index.shcalls it, and the index-build workflow creates its environment from that same file. Removing it would have broken the nightly index build. There is a one-line comment recording why it is still there.local_filesis keyed by file name, so a submission with two files of the same name collapses to one entry and a paired submission would be filtered as if single-ended. That is pre-existing and the readtools step shares the assumption, so it belongs in its own change.Notes on testing
The two deacon threshold tests used to be skipped whenever the
deaconbinary was missing fromPATH, which means they may not have been running everywhere. The bindings are an ordinary Python dependency, so those tests now always run; they produce the same 75% and 5% host proportions as before, so behaviour is unchanged. The index is loaded once per test session, which is also how the service uses it.New tests cover the new behaviour: that an oversized download is rejected part-way through rather than after being written in full, that the semaphore really does cap concurrent filters and does not leak a slot when a filter fails, and that a mismatched pair fails the submission while leaving the index usable. One more covers
get_configagainstdefaults.yaml, because nothing did — every test builtConfigdirectly, so a key missing from the defaults file would only have shown up as a validation error at pod startup with the whole suite green.The image build is green on this PR, and
uvresolved a prebuilt wheel rather than falling back to building from source, so the switch to a PyPI dependency needs no Rust toolchain in the image. The package publishes nodeaconcommand-line script, so it cannot shadow the conda binary thatbuild-index.shneeds.🚀 Preview: https://raw-reads-deacon-python-b.loculus.org