Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ jobs:
restore-keys: |
${{ runner.os }}-maven-

# The integration tests download Rust-written fixtures from S3 (a snapshot pinned to the
# vortex-jni version) and LocalHttpCache keeps them here, so a cache hit takes the S3 round
# trips, and their occasional connection resets, out of the run. Windows skips integration.
- name: Cache downloaded test fixtures
if: runner.os != 'Windows'
uses: actions/cache@v6
with:
path: /tmp/vortex-compat-fixtures
key: fixtures-${{ runner.os }}-${{ hashFiles('integration/src/test/java/io/github/dfa1/vortex/integration/RustFixtures.java') }}

# vortex-jni 0.74.0 ships native libs for darwin-aarch64 + linux-aarch64
# + linux-amd64 only. No Windows binary, so the modules that pull vortex-jni
# (integration, performance) are skipped on Windows. Being the only Vortex
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package io.github.dfa1.vortex.integration;

import javax.net.ssl.SSLException;

import java.io.EOFException;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;

import static org.junit.jupiter.api.Assumptions.assumeTrue;

Expand All @@ -16,35 +23,89 @@ final class LocalHttpCache {
private LocalHttpCache() {
}

/// Downloads `url` into `tmp` if `cacheDir` doesn't already have a file named `name`,
/// leaving no copy in `cacheDir` (callers that want the download persisted for reuse by
/// later runs must pass a `cacheDir` that already exists and is writable — this method
/// only reads from it).
/// How a download opens its connection; replaced in tests so retry behavior needs no network.
@FunctionalInterface
interface Connector {
HttpURLConnection open(URI url) throws IOException;
}

private static final int ATTEMPTS = 3;
private static final Duration BACKOFF = Duration.ofSeconds(2);
private static final Duration TIMEOUT = Duration.ofSeconds(30);

/// Returns the fixture `name`: the copy already in `cacheDir`, or a download of `url` that is
/// kept there for the next test, so each fixture is fetched once per run instead of once per
/// test class. A `cacheDir` that cannot be written falls back to a copy in `tmp`.
///
/// A 5xx response skips the test via `assumeTrue` (assumed transient infrastructure
/// noise, not a real signal); a 4xx or other `IOException` fails it (the fixture is
/// genuinely missing or broken).
/// Connection-level failures (a reset, a timeout, a TLS or EOF error) and 5xx responses are
/// infrastructure noise: they are retried up to three times with a growing pause, and if every
/// attempt fails the test is skipped via `assumeTrue`, not failed. A 4xx or any other
/// `IOException` fails it (the fixture is genuinely missing or broken). On CI, S3 resets
/// failed one run in ten before this retried.
///
/// @param tmp the test's `@TempDir`, used as the download destination when the
/// fixture isn't already in `cacheDir`
/// @param cacheDir persistent local cache directory (e.g. `/tmp/pco-fixtures/v0.75.0`)
/// @param tmp the test's `@TempDir`, the fallback destination when `cacheDir` is unwritable
/// @param cacheDir persistent local cache directory (e.g. `/tmp/vortex-compat-fixtures/v0.86.1`)
/// @param url the fixture's download URL
/// @param name the fixture's file name, used for both the cache lookup and dest file
/// @return the local path to the fixture: the cached copy, or the freshly downloaded one
/// @throws Exception if the download fails for a reason other than a transient 5xx
/// @throws Exception if the download fails for a reason other than transient noise
static Path downloadIfMissing(Path tmp, Path cacheDir, URI url, String name) throws Exception {
return downloadIfMissing(tmp, cacheDir, url, name, LocalHttpCache::open, BACKOFF);
}

static Path downloadIfMissing(Path tmp, Path cacheDir, URI url, String name, Connector connector,
Duration backoff) throws Exception {
Path cached = cacheDir.resolve(name);
if (Files.exists(cached)) {
return cached;
}
Path dest = tmp.resolve(name);
String lastFailure = "no attempt";
for (int attempt = 1; attempt <= ATTEMPTS; attempt++) {
try {
HttpURLConnection conn = connector.open(url);
int code = conn.getResponseCode();
if (code < 500) {
return save(conn, tmp, cacheDir, name);
}
lastFailure = "HTTP " + code;
} catch (SocketException | SocketTimeoutException | SSLException | EOFException e) {
lastFailure = e.toString();
}
if (attempt < ATTEMPTS) {
Thread.sleep(backoff.multipliedBy(attempt));
}
}
String reason = lastFailure;
assumeTrue(false, () -> "could not download " + name + " after " + ATTEMPTS + " attempts: " + reason);
throw new AssertionError("unreachable");
}

private static HttpURLConnection open(URI url) throws IOException {
var conn = (HttpURLConnection) url.toURL().openConnection();
int code = conn.getResponseCode();
assumeTrue(code < 500, () -> "transient server error " + code + " for " + name);
conn.setConnectTimeout((int) TIMEOUT.toMillis());
conn.setReadTimeout((int) TIMEOUT.toMillis());
return conn;
}

/// Streams the body to a temporary file and moves it into place atomically, so a download cut
/// short never leaves a truncated fixture that a later test would trust.
private static Path save(HttpURLConnection conn, Path tmp, Path cacheDir, String name) throws IOException {
Path dir;
try {
Files.createDirectories(cacheDir);
dir = cacheDir;
} catch (IOException _) {
dir = tmp;
}
Path part = Files.createTempFile(dir, name, ".part");
try (var in = conn.getInputStream()) {
Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING);
Files.copy(in, part, StandardCopyOption.REPLACE_EXISTING);
Path dest = dir.resolve(name);
Files.move(part, dest, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
return dest;
} finally {
Files.deleteIfExists(part);
}
return dest;
}

/// Like [#downloadIfMissing] but skips the test (rather than failing it) on ANY download
Expand Down
Loading
Loading