Skip to content

Add a warm validation server so the JVM is reused between validations - #1

Draft
corneliusroemer-agent wants to merge 3 commits into
loculus-project:validate-clifrom
corneliusroemer-agent:warm-validation-server
Draft

Add a warm validation server so the JVM is reused between validations#1
corneliusroemer-agent wants to merge 3 commits into
loculus-project:validate-clifrom
corneliusroemer-agent:warm-validation-server

Conversation

@corneliusroemer-agent

@corneliusroemer-agent corneliusroemer-agent commented Aug 26, 2026

Copy link
Copy Markdown

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 to main is 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 N keeps 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 ValidatorWrapper and shares nothing with any other. I grepped the whole v2 validation path for non-final static fields: the only ones are InsdcReadsValidator's ERROR_* and INVALID_FILE strings, and nothing in src/main ever 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:

fork per call warm server
readtools validation, per submission ~3 s 0.90 / 0.96 / 1.01 / 1.13 s
pod memory, peak 4.89 GB 5.15 GB

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:

warm-up 1/3: 1.06s
warm-up 2/3: 0.71s
warm-up 3/3: 0.55s
readtools validation server ready

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:

quick full
cold CLI ~1.15 s ~8.6 s
warm server 0.86 s 7.40 s
peak memory 0.39 GB 1.33 GB

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. ValidatorWrapper sizes the pairing Bloom filter from readCountLimit / 2, so --full allocates for 50M reads regardless of how small the file actually is, and getCopy() duplicates it per mate. Four concurrent --full validations against -Xmx1g exhausted the heap on three of four. Nothing here changes that sizing - it is upstream behaviour - but it is the reason --threads and 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

/validate returns the exact stdout, stderr and 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. ValidationRunner is the one implementation both entry points call, and ValidateServerTest asserts 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 in InsdcReadsValidator.validate, and the leading-.* regex run per read name in PairedFastqReadsValidator — 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 hits maven.imagej.net, which returned 503 throughout this work, so a clean build needs --offline against a warm cache right now. Worth pinning, but it isn't this change's business.

Note for whoever merges

ValidateCli keeps its existing behaviour and command line exactly; server is 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.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant