diff --git a/src/main/java/uk/ac/ebi/ena/readtools/v2/cli/ValidateCli.java b/src/main/java/uk/ac/ebi/ena/readtools/v2/cli/ValidateCli.java
index fae14f2..ba6ad25 100644
--- a/src/main/java/uk/ac/ebi/ena/readtools/v2/cli/ValidateCli.java
+++ b/src/main/java/uk/ac/ebi/ena/readtools/v2/cli/ValidateCli.java
@@ -16,20 +16,15 @@
import java.util.List;
import java.util.stream.Collectors;
import uk.ac.ebi.ena.readtools.v2.FileFormat;
-import uk.ac.ebi.ena.readtools.v2.validator.ReadsValidationException;
-import uk.ac.ebi.ena.readtools.v2.validator.ValidatorWrapper;
/**
- * Standalone CLI wrapper around {@link ValidatorWrapper}, running the same client-side read
- * validation that webin-cli performs, without the manifest / upload machinery.
+ * Standalone CLI wrapper around the v2 validator, running the same client-side read validation that
+ * webin-cli performs, without the manifest / upload machinery.
*
*
Usage: java -cp readtools-all.jar uk.ac.ebi.ena.readtools.v2.cli.ValidateCli \ --format FASTQ
* [--full] file1 [file2]
*/
public class ValidateCli {
- // webin-cli's own limits: quick = first 100k reads, extended = first 100M reads.
- private static final long QUICK_READ_LIMIT = 100_000L;
- private static final long EXTENDED_READ_LIMIT = 100_000_000L;
@Parameter(
names = {"--format", "-f"},
@@ -52,8 +47,15 @@ public class ValidateCli {
private boolean help = false;
public static void main(String[] args) {
+ // `java -jar readtools.jar server ...` starts the warm validation server instead.
+ if (args.length > 0 && "server".equals(args[0])) {
+ ValidateServer.main(java.util.Arrays.copyOfRange(args, 1, args.length));
+ return;
+ }
+
ValidateCli cli = new ValidateCli();
- JCommander jc = JCommander.newBuilder().addObject(cli).programName("readtools-validate").build();
+ JCommander jc =
+ JCommander.newBuilder().addObject(cli).programName("readtools-validate").build();
try {
jc.parse(args);
@@ -73,41 +75,6 @@ public static void main(String[] args) {
private int run() {
List fileList = files.stream().map(File::new).collect(Collectors.toList());
- for (File f : fileList) {
- if (!f.isFile()) {
- System.err.println("ERROR: file not found: " + f.getPath());
- return 2;
- }
- }
-
- long limit = full ? EXTENDED_READ_LIMIT : QUICK_READ_LIMIT;
- ValidatorWrapper wrapper = new ValidatorWrapper(fileList, format, limit);
-
- try {
- wrapper.run();
- } catch (ReadsValidationException e) {
- System.out.println("RESULT: INVALID");
- System.out.println(" " + e.getMessage());
- return 1;
- } catch (RuntimeException e) {
- System.out.println("RESULT: INVALID (file structure / parse error)");
- System.out.println(" " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()));
- return 1;
- }
-
- System.out.println("RESULT: VALID");
- System.out.println(" format: " + format);
- System.out.println(" mode: " + (full ? "full (<=100M reads)" : "quick (<=100k reads)"));
- if (format == FileFormat.FASTQ && fileList.size() > 1) {
- System.out.println(" paired: " + wrapper.isPaired());
- }
- wrapper
- .getFileQualityStats()
- .forEach(
- s ->
- System.out.printf(
- " %s: reads=%d, highQuality(avg>=30)=%d%n",
- s.getFile().getName(), s.getReadCount(), s.getHighQualityReadCount()));
- return 0;
+ return ValidationRunner.validate(fileList, format, full, System.out, System.err);
}
}
diff --git a/src/main/java/uk/ac/ebi/ena/readtools/v2/cli/ValidateServer.java b/src/main/java/uk/ac/ebi/ena/readtools/v2/cli/ValidateServer.java
new file mode 100644
index 0000000..9e2a746
--- /dev/null
+++ b/src/main/java/uk/ac/ebi/ena/readtools/v2/cli/ValidateServer.java
@@ -0,0 +1,387 @@
+/*
+ * Copyright 2010-2021 EMBL - European Bioinformatics Institute
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+ * file except in compliance with the License. You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package uk.ac.ebi.ena.readtools.v2.cli;
+
+import com.beust.jcommander.JCommander;
+import com.beust.jcommander.Parameter;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.io.PrintWriter;
+import java.io.UncheckedIOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicBoolean;
+import uk.ac.ebi.ena.readtools.v2.FileFormat;
+
+/**
+ * A long-lived HTTP server that runs the same validation as {@link ValidateCli}, but in a JVM that
+ * stays warm between requests.
+ *
+ * Why: a cold {@code java -jar readtools.jar} spends roughly half its wall time on classloading
+ * and JIT warm-up that a fresh process throws away every call. Keeping one JVM warm removes that,
+ * and — because the validation path holds no shared mutable state — lets several validations run
+ * concurrently in it rather than forking one JVM per file pair.
+ *
+ *
Protocol (JSON over HTTP, bound to loopback by default):
+ *
+ *
+ * POST /validate {"files": ["/path/R1.fastq", "/path/R2.fastq"],
+ * "format": "FASTQ", "full": false}
+ * -> {"exitCode": 0, "stdout": "RESULT: VALID\n...", "stderr": ""}
+ * GET /health -> 200 {"status":"ok"} once warm, 503 while still warming up
+ *
+ *
+ * {@code stdout}/{@code stderr} are what the CLI would have printed for the same arguments, so
+ * callers that already parse the CLI's output keep working unchanged.
+ */
+public class ValidateServer {
+ private static final int MAX_REQUEST_BYTES = 1 << 20;
+
+ @Parameter(
+ names = {"--host"},
+ description = "Address to bind to. Loopback by default: this server does no authentication.")
+ private String host = "127.0.0.1";
+
+ @Parameter(
+ names = {"--port", "-p"},
+ description = "Port to listen on")
+ private int port = 5001;
+
+ @Parameter(
+ names = {"--threads", "-t"},
+ description =
+ "Maximum validations to run concurrently. Further requests queue. Each one holds a"
+ + " read buffer and a pairing Bloom filter, so this also bounds heap use.")
+ private int threads = Math.max(1, Runtime.getRuntime().availableProcessors() / 2);
+
+ @Parameter(
+ names = {"--warmup-rounds"},
+ description =
+ "Validations of synthetic reads to run before reporting healthy, so the first real"
+ + " request does not pay JIT warm-up. 0 disables warm-up.")
+ private int warmupRounds = 3;
+
+ @Parameter(
+ names = {"--warmup-reads"},
+ description = "Reads per synthetic warm-up file")
+ private int warmupReads = 100_000;
+
+ @Parameter(
+ names = {"--help", "-h"},
+ help = true)
+ private boolean help = false;
+
+ private final ObjectMapper mapper = new ObjectMapper();
+ private Semaphore validationSlots;
+ private HttpServer httpServer;
+ private ExecutorService executor;
+ private final AtomicBoolean ready = new AtomicBoolean(false);
+ private Path healthProbeFile;
+
+ public static void main(String[] args) {
+ ValidateServer server = configure(args);
+ if (server == null) {
+ return;
+ }
+
+ try {
+ server.run();
+ } catch (IOException e) {
+ System.err.println("ERROR: could not start server: " + e.getMessage());
+ System.exit(2);
+ }
+ }
+
+ /** Parses arguments into a configured server, or null if usage was all that was asked for. */
+ static ValidateServer configure(String... args) {
+ ValidateServer server = new ValidateServer();
+ JCommander jc =
+ JCommander.newBuilder().addObject(server).programName("readtools-validate server").build();
+
+ try {
+ jc.parse(args);
+ } catch (Exception e) {
+ System.err.println("ERROR: " + e.getMessage());
+ jc.usage();
+ System.exit(2);
+ }
+
+ if (server.help) {
+ jc.usage();
+ return null;
+ }
+ return server;
+ }
+
+ /** Starts listening and returns once the server is warmed up and serving. */
+ void run() throws IOException {
+ validationSlots = new Semaphore(threads);
+
+ httpServer = HttpServer.create(new InetSocketAddress(host, port), 0);
+ // Without an explicit executor HttpServer runs every handler on the dispatch thread, i.e.
+ // serially, which would give up the concurrency this server exists to provide.
+ //
+ // The pool is deliberately unbounded and the concurrency limit lives in a semaphore instead.
+ // A fixed pool of --threads would make /health queue behind in-flight validations, so a
+ // merely busy server would fail its liveness probe and get restarted -- which in the Loculus
+ // pod also evicts deacon's multi-gigabyte in-memory index. Threads are cheap; what needs
+ // bounding is the number of validations allocating read buffers at once, and that is exactly
+ // what the semaphore bounds.
+ executor = Executors.newCachedThreadPool();
+ httpServer.setExecutor(executor);
+
+ httpServer.createContext("/validate", this::handleValidate);
+ httpServer.createContext("/health", this::handleHealth);
+
+ Runtime.getRuntime().addShutdownHook(new Thread(this::stop));
+
+ httpServer.start();
+ System.err.printf(
+ "readtools validation server listening on %s:%d (%d threads)%n", host, port, threads);
+
+ healthProbeFile = writeHealthProbeFile();
+ warmUp();
+ ready.set(true);
+ System.err.println("readtools validation server ready");
+ }
+
+ /** Stops listening and releases the handler threads. */
+ void stop() {
+ if (httpServer != null) {
+ httpServer.stop(0);
+ }
+ if (executor != null) {
+ executor.shutdownNow();
+ }
+ }
+
+ private void handleValidate(HttpExchange exchange) throws IOException {
+ try {
+ if (!"POST".equals(exchange.getRequestMethod())) {
+ respondError(exchange, 405, "only POST is supported");
+ return;
+ }
+
+ JsonNode request;
+ try (InputStream body = exchange.getRequestBody()) {
+ byte[] raw = body.readNBytes(MAX_REQUEST_BYTES);
+ // Otherwise an oversized body arrives as truncated JSON and is reported as malformed.
+ if (body.read() != -1) {
+ respondError(exchange, 413, "request body larger than " + MAX_REQUEST_BYTES + " bytes");
+ return;
+ }
+ request = mapper.readTree(raw);
+ } catch (IOException | RuntimeException e) {
+ respondError(exchange, 400, "could not parse request body: " + e.getMessage());
+ return;
+ }
+
+ List files = new ArrayList<>();
+ JsonNode filesNode = request == null ? null : request.get("files");
+ if (filesNode == null || !filesNode.isArray() || filesNode.isEmpty()) {
+ respondError(exchange, 400, "'files' must be a non-empty array of paths");
+ return;
+ }
+ filesNode.forEach(node -> files.add(new File(node.asText())));
+
+ FileFormat format;
+ try {
+ format = FileFormat.valueOf(request.path("format").asText("FASTQ"));
+ } catch (IllegalArgumentException e) {
+ respondError(exchange, 400, "unknown format: " + request.path("format").asText());
+ return;
+ }
+ boolean full = request.path("full").asBoolean(false);
+
+ try {
+ validationSlots.acquire();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ respondError(exchange, 503, "server is shutting down");
+ return;
+ }
+ try {
+ respondJson(exchange, 200, validateToJson(files, format, full));
+ } finally {
+ validationSlots.release();
+ }
+ } catch (Throwable e) {
+ // Throwable, not RuntimeException: a validation large enough to exhaust the heap throws
+ // OutOfMemoryError, which is an Error. Letting it escape leaves the exchange unanswered, so
+ // the caller waits out its whole timeout instead of being told the request failed.
+ ObjectNode response = mapper.createObjectNode();
+ response.put("exitCode", 2);
+ response.put("stdout", "");
+ response.put("stderr", "ERROR: " + describe(e));
+ respondJson(exchange, 500, response);
+ }
+ }
+
+ /** Runs one validation, capturing what the CLI would have printed. */
+ private ObjectNode validateToJson(List files, FileFormat format, boolean full) {
+ ByteArrayOutputStream outBytes = new ByteArrayOutputStream();
+ ByteArrayOutputStream errBytes = new ByteArrayOutputStream();
+ int exitCode;
+ try (PrintStream out = new PrintStream(outBytes, true, StandardCharsets.UTF_8);
+ PrintStream err = new PrintStream(errBytes, true, StandardCharsets.UTF_8)) {
+ try {
+ exitCode = ValidationRunner.validate(files, format, full, out, err);
+ } catch (RuntimeException | StackOverflowError | OutOfMemoryError e) {
+ // ValidateCli would have died here and the caller would have seen a stack trace on
+ // stderr with no RESULT line; reproduce that shape rather than inventing a verdict.
+ exitCode = 2;
+ e.printStackTrace(new PrintWriter(err, true));
+ }
+ }
+
+ ObjectNode response = mapper.createObjectNode();
+ response.put("exitCode", exitCode);
+ response.put("stdout", outBytes.toString(StandardCharsets.UTF_8));
+ response.put("stderr", errBytes.toString(StandardCharsets.UTF_8));
+ return response;
+ }
+
+ /**
+ * Liveness plus a real validation of a one-read file, so a JVM that is up but whose validation
+ * path is broken (or wedged with every thread busy) does not report healthy.
+ */
+ private void handleHealth(HttpExchange exchange) throws IOException {
+ ObjectNode response = mapper.createObjectNode();
+
+ if (!ready.get()) {
+ response.put("status", "warming-up");
+ respondJson(exchange, 503, response);
+ return;
+ }
+
+ ObjectNode probe =
+ validateToJson(List.of(healthProbeFile.toFile()), FileFormat.FASTQ, /* full= */ false);
+ if (probe.get("exitCode").asInt() != 0) {
+ response.put("status", "unhealthy");
+ response.put("detail", probe.get("stdout").asText() + probe.get("stderr").asText());
+ respondJson(exchange, 503, response);
+ return;
+ }
+
+ response.put("status", "ok");
+ response.put("threads", threads);
+ response.put("availableSlots", validationSlots.availablePermits());
+ respondJson(exchange, 200, response);
+ }
+
+ /**
+ * Validates synthetic reads a few times so the hot parse/validate loop is JIT-compiled before the
+ * first real request. One pass is not enough: the profiling this server is based on saw steady
+ * state only after three or four.
+ */
+ private void warmUp() {
+ if (warmupRounds <= 0) {
+ return;
+ }
+ try {
+ Path dir = Files.createTempDirectory("readtools-warmup");
+ dir.toFile().deleteOnExit();
+ List pair =
+ List.of(
+ writeSyntheticFastq(dir.resolve("warmup_1.fastq"), 1).toFile(),
+ writeSyntheticFastq(dir.resolve("warmup_2.fastq"), 2).toFile());
+
+ for (int round = 0; round < warmupRounds; round++) {
+ long start = System.nanoTime();
+ ObjectNode result = validateToJson(pair, FileFormat.FASTQ, /* full= */ false);
+ System.err.printf(
+ "warm-up %d/%d: %.2fs (exit %d)%n",
+ round + 1,
+ warmupRounds,
+ (System.nanoTime() - start) / 1e9,
+ result.get("exitCode").asInt());
+ }
+
+ for (File file : pair) {
+ Files.deleteIfExists(file.toPath());
+ }
+ Files.deleteIfExists(dir);
+ } catch (IOException | UncheckedIOException e) {
+ // Warm-up is an optimisation; a failure here must not stop the server serving.
+ System.err.println("WARNING: warm-up skipped: " + describe(e));
+ }
+ }
+
+ private Path writeSyntheticFastq(Path path, int mate) throws IOException {
+ String bases = "ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT";
+ String quality = "I".repeat(bases.length());
+ StringBuilder buffer = new StringBuilder(1 << 16);
+ try (var writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
+ for (int i = 0; i < warmupReads; i++) {
+ buffer
+ .append('@')
+ .append("warmup")
+ .append(i)
+ .append('/')
+ .append(mate)
+ .append('\n')
+ .append(bases)
+ .append("\n+\n")
+ .append(quality)
+ .append('\n');
+ if (buffer.length() > 1 << 16) {
+ writer.write(buffer.toString());
+ buffer.setLength(0);
+ }
+ }
+ writer.write(buffer.toString());
+ }
+ path.toFile().deleteOnExit();
+ return path;
+ }
+
+ private Path writeHealthProbeFile() throws IOException {
+ Path path = Files.createTempFile("readtools-health", ".fastq");
+ Files.writeString(path, "@probe\nACGTACGTAC\n+\nIIIIIIIIII\n", StandardCharsets.UTF_8);
+ path.toFile().deleteOnExit();
+ return path;
+ }
+
+ private static String describe(Throwable e) {
+ return e.getMessage() == null ? e.getClass().getName() : e.getMessage();
+ }
+
+ private void respondError(HttpExchange exchange, int status, String message) throws IOException {
+ ObjectNode response = mapper.createObjectNode();
+ response.put("error", message);
+ respondJson(exchange, status, response);
+ }
+
+ private void respondJson(HttpExchange exchange, int status, ObjectNode body) throws IOException {
+ byte[] bytes = mapper.writeValueAsBytes(body);
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(status, bytes.length);
+ try (OutputStream out = exchange.getResponseBody()) {
+ out.write(bytes);
+ }
+ }
+}
diff --git a/src/main/java/uk/ac/ebi/ena/readtools/v2/cli/ValidationRunner.java b/src/main/java/uk/ac/ebi/ena/readtools/v2/cli/ValidationRunner.java
new file mode 100644
index 0000000..f4b59fe
--- /dev/null
+++ b/src/main/java/uk/ac/ebi/ena/readtools/v2/cli/ValidationRunner.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2010-2021 EMBL - European Bioinformatics Institute
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+ * file except in compliance with the License. You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package uk.ac.ebi.ena.readtools.v2.cli;
+
+import java.io.File;
+import java.io.PrintStream;
+import java.util.List;
+import uk.ac.ebi.ena.readtools.v2.FileFormat;
+import uk.ac.ebi.ena.readtools.v2.validator.ReadsValidationException;
+import uk.ac.ebi.ena.readtools.v2.validator.ValidatorWrapper;
+
+/**
+ * The validation run itself, factored out of {@link ValidateCli} so that {@link ValidateServer} can
+ * execute exactly the same code and produce byte-identical output.
+ *
+ * Callers parse the report text, so it is a compatibility surface: {@link ValidateServer}
+ * returns it verbatim over HTTP and the CLI prints it to stdout. Both must stay identical.
+ *
+ *
Stateless and safe to call concurrently: a fresh {@link ValidatorWrapper} is built per call
+ * and nothing is shared between calls.
+ */
+public final class ValidationRunner {
+ // webin-cli's own limits: quick = first 100k reads, extended = first 100M reads.
+ public static final long QUICK_READ_LIMIT = 100_000L;
+ public static final long EXTENDED_READ_LIMIT = 100_000_000L;
+
+ private ValidationRunner() {}
+
+ /**
+ * Validates the given files and writes the report to {@code out}.
+ *
+ * @return the exit code {@link ValidateCli} would return: 0 valid, 1 invalid, 2 bad input.
+ */
+ public static int validate(
+ List files, FileFormat format, boolean full, PrintStream out, PrintStream err) {
+ for (File f : files) {
+ if (!f.isFile()) {
+ err.println("ERROR: file not found: " + f.getPath());
+ return 2;
+ }
+ }
+
+ long limit = full ? EXTENDED_READ_LIMIT : QUICK_READ_LIMIT;
+ ValidatorWrapper wrapper = new ValidatorWrapper(files, format, limit);
+
+ try {
+ wrapper.run();
+ } catch (ReadsValidationException e) {
+ out.println("RESULT: INVALID");
+ out.println(" " + e.getMessage());
+ return 1;
+ } catch (RuntimeException e) {
+ out.println("RESULT: INVALID (file structure / parse error)");
+ out.println(" " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()));
+ return 1;
+ }
+
+ out.println("RESULT: VALID");
+ out.println(" format: " + format);
+ out.println(" mode: " + (full ? "full (<=100M reads)" : "quick (<=100k reads)"));
+ if (format == FileFormat.FASTQ && files.size() > 1) {
+ out.println(" paired: " + wrapper.isPaired());
+ }
+ wrapper
+ .getFileQualityStats()
+ .forEach(
+ s ->
+ out.printf(
+ " %s: reads=%d, highQuality(avg>=30)=%d%n",
+ s.getFile().getName(), s.getReadCount(), s.getHighQualityReadCount()));
+ return 0;
+ }
+}
diff --git a/src/test/java/uk/ac/ebi/ena/readtools/v2/cli/ValidateServerTest.java b/src/test/java/uk/ac/ebi/ena/readtools/v2/cli/ValidateServerTest.java
new file mode 100644
index 0000000..a7c986c
--- /dev/null
+++ b/src/test/java/uk/ac/ebi/ena/readtools/v2/cli/ValidateServerTest.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright 2010-2021 EMBL - European Bioinformatics Institute
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+ * file except in compliance with the License. You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package uk.ac.ebi.ena.readtools.v2.cli;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.net.HttpURLConnection;
+import java.net.ServerSocket;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import uk.ac.ebi.ena.readtools.v2.FileFormat;
+
+public class ValidateServerTest {
+ private static final String VALID_SINGLE =
+ "@seq1\nACGTACGTAC\n+\nIIIIIIIIII\n@seq2\nACGTACGTAC\n+\nIIIIIIIIII\n";
+ private static final String NON_IUPAC = "@seq1\nACGTAXGTAC\n+\nIIIIIIIIII\n";
+ private static final String FASTA_HEADER = ">seq1\nACGTACGTAC\n+\nIIIIIIIIII\n";
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static int port;
+ private static ValidateServer server;
+ private static Path tempDir;
+
+ @BeforeClass
+ public static void startServer() throws Exception {
+ tempDir = Files.createTempDirectory("validate-server-test");
+ try (ServerSocket socket = new ServerSocket(0)) {
+ port = socket.getLocalPort();
+ }
+
+ server =
+ ValidateServer.configure(
+ "--port", String.valueOf(port),
+ "--threads", "2",
+ // Warm-up only affects speed, and would make the test slow.
+ "--warmup-rounds", "0");
+ server.run();
+
+ if (statusOf("/health") != 200) {
+ throw new IllegalStateException("server did not become healthy");
+ }
+ }
+
+ @AfterClass
+ public static void stopServer() {
+ server.stop();
+ }
+
+ /** The port must be free again afterwards, i.e. stop() really stops it. */
+ @Test
+ public void stopReleasesThePort() throws Exception {
+ ValidateServer other = ValidateServer.configure("--port", "0", "--warmup-rounds", "0");
+ other.run();
+ other.stop();
+ try (ServerSocket socket = new ServerSocket(0)) {
+ assertTrue(socket.getLocalPort() > 0);
+ }
+ }
+
+ /**
+ * The whole point of returning CLI-shaped text: callers parse it, so the server and the CLI must
+ * not drift.
+ */
+ @Test
+ public void responseIsIdenticalToWhatTheCliWouldPrint() throws Exception {
+ for (String content : new String[] {VALID_SINGLE, NON_IUPAC, FASTA_HEADER}) {
+ File file = write("case" + content.hashCode() + ".fastq", content);
+
+ JsonNode response = validate(List.of(file));
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ByteArrayOutputStream err = new ByteArrayOutputStream();
+ int exitCode;
+ try (PrintStream outStream = new PrintStream(out, true, StandardCharsets.UTF_8);
+ PrintStream errStream = new PrintStream(err, true, StandardCharsets.UTF_8)) {
+ exitCode =
+ ValidationRunner.validate(
+ List.of(file), FileFormat.FASTQ, /* full= */ false, outStream, errStream);
+ }
+
+ assertEquals(exitCode, response.get("exitCode").asInt());
+ assertEquals(out.toString(StandardCharsets.UTF_8), response.get("stdout").asText());
+ }
+ }
+
+ @Test
+ public void reportsMissingFilesTheWayTheCliDoes() throws Exception {
+ JsonNode response = validate(List.of(new File(tempDir.toFile(), "absent.fastq")));
+
+ assertEquals(2, response.get("exitCode").asInt());
+ assertTrue(response.get("stderr").asText().startsWith("ERROR: file not found:"));
+ }
+
+ @Test
+ public void rejectsARequestWithoutFiles() throws Exception {
+ assertEquals(400, postStatus("{\"format\":\"FASTQ\",\"files\":[]}"));
+ }
+
+ @Test
+ public void rejectsAnUnknownFormat() throws Exception {
+ assertEquals(400, postStatus("{\"format\":\"VCF\",\"files\":[\"/tmp/x.fastq\"]}"));
+ }
+
+ /** Concurrent requests must each get their own verdict rather than another request's. */
+ @Test
+ public void servesConcurrentRequestsIndependently() throws Exception {
+ File valid = write("concurrent-valid.fastq", VALID_SINGLE);
+ File invalid = write("concurrent-invalid.fastq", NON_IUPAC);
+
+ ExecutorService pool = Executors.newFixedThreadPool(8);
+ try {
+ List> work = new ArrayList<>();
+ for (int i = 0; i < 24; i++) {
+ boolean expectValid = i % 2 == 0;
+ File file = expectValid ? valid : invalid;
+ work.add(
+ () -> new int[] {expectValid ? 0 : 1, validate(List.of(file)).get("exitCode").asInt()});
+ }
+
+ for (Future result : pool.invokeAll(work, 120, TimeUnit.SECONDS)) {
+ int[] expectedAndActual = result.get();
+ assertEquals(expectedAndActual[0], expectedAndActual[1]);
+ }
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ /** A liveness probe must not queue behind in-flight validations. */
+ @Test
+ public void healthStaysAvailableWhileEveryValidationSlotIsBusy() throws Exception {
+ File file = write("saturating.fastq", VALID_SINGLE);
+
+ ExecutorService pool = Executors.newFixedThreadPool(8);
+ try {
+ for (int i = 0; i < 8; i++) {
+ pool.submit(
+ () -> {
+ for (int round = 0; round < 5; round++) {
+ validate(List.of(file));
+ }
+ return null;
+ });
+ }
+
+ for (int i = 0; i < 5; i++) {
+ assertEquals(200, statusOf("/health"));
+ Thread.sleep(50);
+ }
+ } finally {
+ pool.shutdownNow();
+ pool.awaitTermination(60, TimeUnit.SECONDS);
+ }
+ }
+
+ private static File write(String name, String content) throws IOException {
+ Path path = tempDir.resolve(name);
+ Files.writeString(path, content, StandardCharsets.UTF_8);
+ return path.toFile();
+ }
+
+ private static JsonNode validate(List files) throws IOException {
+ StringBuilder body = new StringBuilder("{\"format\":\"FASTQ\",\"files\":[");
+ for (int i = 0; i < files.size(); i++) {
+ body.append(i == 0 ? "" : ",").append('"').append(files.get(i).getAbsolutePath()).append('"');
+ }
+ body.append("]}");
+
+ HttpURLConnection connection = post(body.toString());
+ try (var stream = connection.getInputStream()) {
+ return MAPPER.readTree(stream.readAllBytes());
+ }
+ }
+
+ private static int postStatus(String body) throws IOException {
+ HttpURLConnection connection = post(body);
+ int status = connection.getResponseCode();
+ connection.disconnect();
+ return status;
+ }
+
+ private static HttpURLConnection post(String body) throws IOException {
+ HttpURLConnection connection =
+ (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/validate").openConnection();
+ connection.setRequestMethod("POST");
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type", "application/json");
+ try (OutputStream out = connection.getOutputStream()) {
+ out.write(body.getBytes(StandardCharsets.UTF_8));
+ }
+ return connection;
+ }
+
+ private static int statusOf(String path) {
+ try {
+ HttpURLConnection connection =
+ (HttpURLConnection) new URL("http://127.0.0.1:" + port + path).openConnection();
+ connection.setConnectTimeout(2000);
+ connection.setReadTimeout(5000);
+ int status = connection.getResponseCode();
+ connection.disconnect();
+ return status;
+ } catch (IOException e) {
+ return -1;
+ }
+ }
+}