Add a warm validation server so the JVM is reused between validations - #1
Draft
corneliusroemer-agent wants to merge 3 commits into
Draft
Conversation
Validating a submission with `java -jar readtools.jar` spends roughly half its wall time on classloading and JIT warm-up, which the process then throws away. Profiling put JVM boot itself at 0.036s, so what a fresh process discards is the C2-compiled parse/validate loop, not startup. `java -jar readtools.jar server` keeps one JVM warm and validates over loopback HTTP instead. On a 100k-read pair that is ~1.3s per cold CLI run against ~0.9s warm, and ~0.3s when four run concurrently in the one JVM. Concurrency is safe because the v2 validation path holds no shared mutable state: every validation builds its own ValidatorWrapper, and the only non-final statics on the path are InsdcReadsValidator's error strings, which are never reassigned. /validate returns the exact stdout, stderr and exit code the CLI would have produced rather than a schema of its own, so callers that already parse the CLI's output keep working and cannot drift from it. ValidationRunner is the single implementation both entry points call, and ValidateServerTest asserts the two agree. Validation concurrency is bounded by a semaphore rather than by the HTTP thread pool, so /health never queues behind in-flight validations. A fixed pool would make a merely busy server fail its liveness probe and get restarted, which in the Loculus pod also evicts deacon's multi-gigabyte in-memory index. The server warms itself on synthetic reads and reports unhealthy until that finishes, so the first real request does not pay the cold cost. /health then runs a real one-read validation, so a JVM that is up but whose validation path is broken does not report healthy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading a capped number of bytes and parsing whatever arrived turned a too-large request into a truncated-JSON parse error, so the caller was told its well-formed request was malformed. Check for trailing input and answer 413 instead. The test fixture interrupted the thread that ran main, but run() has already returned by then and it is the HttpServer's own threads that hold the port, so nothing was actually stopped. Give the server a stop() and have the fixture call it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
corneliusroemer-agent
added a commit
to loculus-project/loculus
that referenced
this pull request
Aug 26, 2026
…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>
A validation large enough to exhaust the heap throws OutOfMemoryError, which is an Error rather than a RuntimeException, so it escaped the handler's catch and left the HTTP exchange unanswered. The caller then waited out its entire timeout instead of being told the request had failed. Found by running four concurrent full-file validations against a 1g heap: three died and their clients hung for over ten minutes. Catching Throwable here is deliberate. The alternative is not "fail cleanly", it is "never reply", and a stuck submission is worse than a reported error. The semaphore permit was already released in a finally block, so the server keeps serving; it now returns exit code 2, which callers treat as "could not validate" rather than as a verdict on the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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.
Validating one submission costs a fresh
java -jar readtools.jar, and roughly half of that call is classloading and JIT warm-up that the process then throws away. The measurement that motivated this: JVM boot tomainis only 0.036s, so almost none of the waste is process startup — it is the compiled parse/validate loop being discarded and rebuilt every single time. Running the identical validation repeatedly inside one JVM goes 1.66s on the first pass and settles at ~0.81s by the fourth, with distinct inputs each round so it is not a page-cache effect.java -jar readtools.jar server --port N --threads Nkeeps one JVM warm and takes validations over loopback HTTP. Measured here on 100k-read pairs: ~1.3s per cold CLI run, ~0.9s warm, and ~0.3s per validation when four run concurrently in the one JVM.Concurrency is the part I most want a second opinion on, so here is the evidence rather than an assertion. Every validation constructs its own
ValidatorWrapperand shares nothing with any other. I grepped the whole v2 validation path for non-final static fields: the only ones areInsdcReadsValidator'sERROR_*andINVALID_FILEstrings, and nothing insrc/mainever reassigns them. Everything else static on that path is a method. Beyond that reading, a 360-validation soak at 9-way concurrency against 4 slots, mixing valid, content-invalid and structurally-invalid inputs, returned zero wrong verdicts.Confirmed on a live deployment, not only on a bench
Four 53 MB paired-FASTQ submissions (1,455,282 reads / 190 Mbp) through the Loculus preview in loculus-project/loculus#7188:
About 3x in production, against ~1.5x on a 16-vCPU dev box — a cold JVM costs a constrained pod more than it costs a fat container, so the local ratio understates this.
The server's own startup output shows the JIT curve, which is the most direct evidence that what is being reclaimed is compilation rather than process startup:
Total warm-up: 3 seconds, once, at pod start. The resident JVM adds ~0.26 GB.
Full-file validation stresses this in a way quick mode does not
--full(every read, rather than the first 100k per file) on a 53 MB pair, 727,641 reads per mate:The warm-JVM saving is roughly fixed, so it is ~25% of quick mode and only ~14% of full mode. Worth saying plainly: this server is much less valuable if full validation is ever made the default.
Memory is the sharper edge.
ValidatorWrappersizes the pairing Bloom filter fromreadCountLimit / 2, so--fullallocates for 50M reads regardless of how small the file actually is, andgetCopy()duplicates it per mate. Four concurrent--fullvalidations against-Xmx1gexhausted the heap on three of four. Nothing here changes that sizing - it is upstream behaviour - but it is the reason--threadsand the heap have to be chosen together, and the reason the OOM-handling commit exists.The thing I'd push back on if I were reviewing
/validatereturns the exactstdout,stderrand exit code the CLI would have printed, rather than a JSON schema describing the verdict. That looks lazy and it is deliberate: Loculus parses the CLI's text today, so returning anything else creates two ways to phrase the same validation error and a chance for them to drift apart, on a path whose whole job is telling a submitter precisely why their file was rejected.ValidationRunneris the one implementation both entry points call, andValidateServerTestasserts the server's bytes equal the CLI's for the same input. If you'd rather have a structured response, the clean version is to add fields alongside the text, not to replace it.The second judgement call: validation concurrency is bounded by a semaphore, and the HTTP thread pool is left unbounded, which is backwards from how these are usually written. With a fixed pool of
--threads, a health check queues behind in-flight validations, so a merely busy server fails its liveness probe and gets restarted — and in the Loculus pod that restart also evicts deacon's multi-gigabyte in-memory index, turning a busy minute into a several-minute outage. Threads are cheap; what actually needs bounding is how many validations allocate read buffers at once, which is exactly what the semaphore bounds. There's a test that health stays answerable with zero free slots (measured 50ms worst case under saturation).Deliberately not in here
The two hot-loop inefficiencies profiling turned up — the autoboxed
HashSet<Character>lookup per base inInsdcReadsValidator.validate, and the leading-.*regex run per read name inPairedFastqReadsValidator— are together about 60% of steady-state time and are being worked on separately. They compose with this rather than competing with it: this change removes the warm-up cost, those attack what remains.Also left alone: the
webin-cli-validator:2.+floating version range. Resolving it hitsmaven.imagej.net, which returned 503 throughout this work, so a clean build needs--offlineagainst a warm cache right now. Worth pinning, but it isn't this change's business.Note for whoever merges
ValidateClikeeps its existing behaviour and command line exactly;serveris a new first argument. I checked the CLI's output is byte-identical to the released v1.0.0 jar across valid, content-error and structural cases before touching anything else.Loculus consumes this in loculus-project/loculus#7188. So that PR is actually testable rather than pointing at a tag that does not exist, there is a preview build of this branch at https://github.com/corneliusroemer-agent/readtools/releases/tag/v1.1.0-rc1 and the Loculus Dockerfile pulls from there for now. It is on a personal fork only because the agent account cannot push here; cutting the real release on this repo is a maintainer call and I have not assumed it. Once you do, the Loculus PR is a one-line repoint.