feat(raw-reads-processing): validate against a warm readtools JVM - #7188
Draft
corneliusroemer-agent wants to merge 5 commits into
Draft
corneliusroemer-agent wants to merge 5 commits into
corneliusroemer-agent wants to merge 5 commits into
Conversation
Every submission currently forks `java -jar readtools.jar`, and about half of that call's wall time goes on classloading and JIT warm-up that the process then discards. Profiling put JVM boot at 0.036s, so it is the compiled parse loop being thrown away, not startup. The service now starts one long-lived readtools JVM next to the deacon server, warms it up, and POSTs each validation to it on loopback. Measured on 100k-read pairs: ~1.3s per validation today, ~0.9s warm, and ~0.3s once several run concurrently in the one JVM. The concurrency half only pays off once the caller stops issuing /process-files serially, but the server is built to absorb it now, and doing it in one JVM is what makes it affordable at all -- the resident deacon index leaves too little headroom under the 8Gi limit to fork several JVMs instead. The server returns the exact stdout, stderr and exit code the CLI would have printed, so _parse_validation_error is unchanged and a submitter sees the same message either way. test_server_and_subprocess_agree pins that across the valid, content-error and structural cases. Startup blocks until the JVM reports healthy so no submission is served by a cold JVM, and /health now fails if the JVM has exited or its own health probe (a real one-read validation) fails. A long-lived process is a failure mode the fork-per-call design did not have, so readtools_server_enabled: false restores the old path without a rebuild, and the JVM runs under an explicit -Xmx with concurrency capped so it cannot grow into deacon's memory. Needs a readtools release containing the server before the image will build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
|
This PR may be related to: #7187 |
…erver The previous commit referenced a loculus-project/readtools v1.1.0 tag that does not exist, so the image could not build and none of this was testable. Point at a preview release cut from the head of loculus-project/readtools#1 instead. This URL is temporary: it is on a personal fork because the agent account cannot push to loculus-project/readtools. Repoint it there once that release is cut. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Probing a live preview showed the server path emits no readtools log line at all: the only breadcrumb lived in the subprocess branch, so the duration had to be inferred from the gap to the next component's log line. Log it once for both paths instead, with the elapsed time and which path ran. Measured on the preview with this line's arrival: ~0.95s per 53MB paired FASTQ, against ~3s for the fork-per-call path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
readtools only reads the first 100k reads of each file by default, matching what webin-cli does client-side. This adds a flag to validate every read instead, on both the warm-server and the subprocess path so the two cannot disagree about how much of a file was checked. Left off by default. On a 53MB paired submission (727,641 reads per mate) full validation costs ~8.6s against ~1.15s, and peak memory 1.33GB against 0.39GB, because cost stops being capped and scales with read count. Note the pairing Bloom filter is sized from the read limit rather than the file, so full mode allocates for 50M reads whatever the actual file size. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…annot run Any non-zero exit was reported to the submitter as "File validation failed while running ENA readtools", including the cases where readtools never reached a verdict at all - a missing file, or the JVM running out of heap. Someone submitting a perfectly good FASTQ would be told their file was rejected because our validator ran out of memory. readtools exits 1 for "these files are invalid" and 2 for "I could not judge them". Only 1 is a verdict about the submission; 2 is now a ProcessingFailure, which is retried and alerted on rather than shown to the submitter as their fault. This matters more with readtools_full_validation enabled, where memory use per validation rises from ~0.39GB to ~1.33GB and concurrent validations can genuinely exhaust the heap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
corneliusroemer
added a commit
that referenced
this pull request
Aug 27, 2026
…ly exercise (#7189) I noticed that #7188 changes only `raw-reads-processing/`, and got no integration test run. It turns out that various Github workflows have not had their path filters updated for various new services. This PR fixes that. 🚀 Preview: Add `preview` label to enable --------- Co-authored-by: Cornelius Roemer <cornelius.roemer@pathoplexus.org> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Cornelius Roemer <cornelius.roemer@gmail.com>
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.
Depends on loculus-project/readtools#1.
The Dockerfile currently pulls the readtools jar from a personal fork,
corneliusroemer-agent/readtoolsv1.1.0-rc1, because the agent account cannot push toloculus-project/readtoolsto cut a release there. That makes this PR actually buildable and testable rather than pointing at a tag that does not exist, but it must not merge like this — cut the equivalent release onloculus-project/readtoolsand this becomes a one-line repoint. There is a TODO on the line.That preview jar is built from the head of loculus-project/readtools#1 and was checked before upload: its CLI output is byte-identical to the released v1.0.0 jar across valid, content-error and structural cases.
Every raw-reads submission forks
java -jar readtools.jar, and roughly half that call is classloading and JIT warm-up the process immediately throws away. The number that makes this worth doing: JVM boot tomainis 0.036s, so this is not process-startup overhead that a lighter launcher would fix — it is the compiled parse loop being rebuilt from scratch for every submission.This starts one long-lived readtools JVM alongside the deacon server, warms it, and POSTs each validation to it on loopback. On 100k-read pairs: ~1.3s per validation today, ~0.9s warm, ~0.3s once several run concurrently in that one JVM.
Be aware the ~0.3s figure does not land from this PR alone.
process_allin the preprocessing pipeline still calls/process-filesone entry at a time and waits for each, so today you get the warm-JVM half, roughly 1.3s to 0.9s. I've deliberately not touchedprepro.pyhere — parallelising that loop is a separate change with its own risks (it needs a concurrency cap, and deacon's serial accept loop becomes the next bottleneck). What this PR does is make the service able to absorb that concurrency when it comes, and doing it inside one JVM is what makes it affordable at all: the resident deacon index leaves too little room under the 8Gi limit to fork several JVMs side by side.Measured on a live preview, not just locally
Four 53 MB paired-FASTQ submissions (SRR21755542, 1,455,282 reads / 190 Mbp) through the preview for this branch, all
NO_ISSUES, then approved./process-filesThat is about 3x on the readtools step in production — a bigger win than the ~1.5x measured on a 16-vCPU dev box, because the pod pays more for a cold JVM than a fat container does. Worth knowing for anyone who benchmarks this kind of change locally and assumes the local ratio carries over.
The pod's own startup log shows the JIT warm-up curve directly, which is the clearest evidence that the mechanism is what we think it is rather than noise:
Warm-up costs 3 seconds of pod startup, against a
startupProbethat allows 600 s. Memory moving 4.89 → 5.15 GB is the resident JVM, and is why I have not touched the pod's 8 Gi sizing — the deacon index still dominates it.Two things the live run taught us that the bench could not
The service used to log nothing at all about readtools. The only breadcrumb was a debug line inside the subprocess branch, so on the warm path the duration had to be reconstructed from the gap between the last "Successfully downloaded" line and deacon's first line. That is fixed here: both paths now log the elapsed time and which one ran. Found only by trying to measure the thing in production.
A
Connection refusedtoloculus-raw-reads-processing:5000is what an unscheduled pod looks like. The pod requests 8 Gi and satPending/unschedulablefor ~15 minutes because no preview node had that much free, producing zero log lines. Submissions during that window failed with a cleanInternal Error ... Connection refused, which reads like an application bug and is not one. Incidentally this is a decent negative control: the pipeline does surface a missing validator rather than silently passing the submission, which is the failure mode that would actually matter.Full validation: measured, and off by default
readtools_full_validationvalidates every read instead of the first 100k per file. Measured on the same 53 MB submission (727,641 reads per mate, so 7.3x more data than the default cap):Two things worth knowing before anyone turns this on.
The warm server barely helps in full mode. The win it provides is a roughly fixed amount of reclaimed JIT compilation, so it is most of a ~1 s job but only ~14% of an ~8.6 s one. The ~3x this PR buys in production becomes about 1.15x with full validation on.
Memory does not scale with the file, it scales with the limit.
ValidatorWrappersizes the pairing Bloom filter asreadCountLimit / 2, so full mode allocates for 50M reads even for a tiny submission, and copies it per mate. Four concurrent full validations against a 1 GB heap ran out of memory on three of the four. That is why the flag defaults to off, and why turning it on needsreadtools_server_max_heapraised orreadtools_server_threadslowered rather than just flipping the boolean.That experiment also turned up two real bugs, both fixed here and in loculus-project/readtools#1: the OOM left the HTTP exchange unanswered so callers hung for their full timeout instead of erroring, and any non-zero exit was reported to the submitter as an invalid file - meaning our validator running out of memory would have been shown to someone as a problem with their perfectly good FASTQ.
What you should push on
A long-lived JVM is a genuinely new failure mode. Today a validation bug that leaks memory or wedges dies with the process and costs one submission; now it can affect every subsequent one. Three things buy that back, and none of them is free:
readtools_server_enabled: falserestores the old fork-per-call path without a rebuild. I did not add an automatic fallback when the server is unreachable, on purpose — a silent fallback would hide a permanently broken server behind merely-slower validations, and I'd rather that page someone. Say the word if you'd prefer it fall back anyway.The JVM runs under an explicit
-Xmxwith concurrency capped, so it cannot grow into deacon's memory. I did not raise the pod's 8Gi request: measured resident use of the warm JVM was ~440MB against a 1g heap, well inside the headroom the deacon index leaves, and this actually lowers peak use compared with several concurrent forked JVMs. Flagging it because it's the kind of thing worth disagreeing with.Startup blocks until the JVM reports healthy, so no submission is ever served by a cold JVM. That makes pod startup slower by the warm-up, which the existing
startupProbealready tolerates (600s allowance, warm-up takes seconds)./healthnow also fails if the JVM has exited, or if its own probe — a real one-read validation, not a liveness ping — fails.The part I care most about being right
The submitter-facing error message must not depend on which path ran. Rather than defining a JSON verdict schema, the server returns the exact stdout, stderr and exit code the CLI would have printed, so
_parse_validation_erroris untouched and both paths are literally the same text.test_server_and_subprocess_agreeruns each fixture through both and compares the resulting message string, so any drift fails the suite rather than silently reaching a submitter.There's also a test that fires mixed valid and invalid files concurrently and checks no request gets another request's verdict, which is the failure I'd most fear from sharing a JVM.
🚀 Preview: Add
previewlabel to enable