diff --git a/.github/actions/setup-spice/action.yml b/.github/actions/setup-spice/action.yml new file mode 100644 index 0000000..c5c3a9e --- /dev/null +++ b/.github/actions/setup-spice/action.yml @@ -0,0 +1,70 @@ +name: Setup Spice +description: > + Install the Spice CLI (and optionally the runtime) with retries and binary + verification. The installer resolves the latest release via the GitHub API, + which intermittently fails from runner IPs — every call site shares this one + hardened implementation. The release/publish pipeline deliberately does NOT + use this action: it pins an exact version with checksum verification. + +inputs: + github-token: + description: Token for the installer's GitHub API release lookup. + default: ${{ github.token }} + install-runtime: + description: Also install the Spice runtime (spice install). + default: 'true' + +runs: + using: composite + steps: + - name: Install Spice CLI (Unix) + if: runner.os != 'Windows' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + run: | + for attempt in 1 2 3 4 5; do + if curl -fsSL https://install.spiceai.org | /bin/bash && "$HOME/.spice/bin/spice" version; then + break + fi + echo "Spice CLI install failed (attempt $attempt); retrying in $((attempt * 10))s" + sleep $((attempt * 10)) + done + "$HOME/.spice/bin/spice" version + echo "$HOME/.spice/bin" >> "$GITHUB_PATH" + + - name: Install Spice runtime (Unix) + if: runner.os != 'Windows' && inputs.install-runtime == 'true' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + run: $HOME/.spice/bin/spice install + + - name: Install Spice CLI (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + run: | + $ok = $false + foreach ($attempt in 1..5) { + try { + curl.exe -fsSL "https://install.spiceai.org/Install.ps1" -o Install.ps1 + PowerShell -ExecutionPolicy Bypass -File ./Install.ps1 + & (Join-Path $HOME ".spice\bin\spice.exe") version + if ($LASTEXITCODE -eq 0) { $ok = $true; break } + } catch { + Write-Host "Spice CLI install failed (attempt $attempt): $_" + } + Start-Sleep -Seconds ($attempt * 10) + } + if (-not $ok) { throw "Spice CLI install failed after retries" } + Add-Content $env:GITHUB_PATH (Join-Path $HOME ".spice\bin") + + # Native Windows runtime install is not supported by the Spice CLI + # ("Open WSL and run the Linux Spice CLI there instead") — Windows jobs + # install the CLI only and validate the SDK against the in-process suite. + - name: Runtime not installed (Windows) + if: runner.os == 'Windows' && inputs.install-runtime == 'true' + shell: pwsh + run: Write-Host "Spice runtime is not supported on native Windows; live-integration tests will self-skip." diff --git a/.github/actions/start-spice-app/action.yml b/.github/actions/start-spice-app/action.yml new file mode 100644 index 0000000..2eb38f0 --- /dev/null +++ b/.github/actions/start-spice-app/action.yml @@ -0,0 +1,55 @@ +name: Start Spice quickstart app +description: > + Initialize a spicepod with the taxi_trips quickstart dataset, start the + runtime in the background, and wait for /v1/ready — the single readiness + mechanism shared by every job that needs a live runtime (replaces the + flaky fixed sleeps that guessed at dataset load time). + +inputs: + app-dir: + description: Directory name for the spice app. + default: spice_qs + ready-timeout-seconds: + description: Maximum seconds to wait for /v1/ready. + default: '120' + +runs: + using: composite + steps: + - name: Init and start spice app (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + spice init "${{ inputs.app-dir }}" + cd "${{ inputs.app-dir }}" + # Define the dataset inline rather than fetching spiceai/quickstart from + # the Spicepod registry, which currently returns a body the CLI cannot + # unpack ("Failed to extract Spicepod archive: Could not find EOCD"). + cat >> spicepod.yaml <<'YAML' + + datasets: + - from: s3://spiceai-demo-datasets/taxi_trips/2024/ + name: taxi_trips + description: taxi trips in s3 + params: + file_format: parquet + acceleration: + enabled: true + YAML + spice run &> spice.log & + for i in $(seq 1 "${{ inputs.ready-timeout-seconds }}"); do + if [ "$(curl -s -m 5 http://localhost:8090/v1/ready)" = "ready" ]; then + echo "runtime ready after ${i}s" + break + fi + sleep 1 + done + [ "$(curl -s -m 5 http://localhost:8090/v1/ready)" = "ready" ] + + # The Spice runtime does not run on native Windows, so there is no app to + # start there: Windows jobs exercise the in-process test suite only (the + # availability-gated live tests self-skip). + - name: No runtime app on Windows + if: runner.os == 'Windows' + shell: pwsh + run: Write-Host "Skipping runtime app start on Windows (native runtime unsupported)." diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index cf7db81..aeb8d2b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -9,6 +9,8 @@ on: jobs: build_multi_os: + permissions: + contents: read name: Build and test ${{matrix.os}} runs-on: ${{ matrix.os }} timeout-minutes: 30 @@ -43,72 +45,11 @@ jobs: if: matrix.os == 'windows-latest' run: mvn --% install -DskipTests=true -Dgpg.skip -B -V # tell powershell to stop parsing with --% so it doesn't error with "Unknown lifecycle phase .skip" - - name: Install Spice (https://install.spiceai.org) (Unix) - if: matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - curl https://install.spiceai.org | /bin/bash - echo "$HOME/.spice/bin" >> $GITHUB_PATH - $HOME/.spice/bin/spice install + - name: Install Spice + uses: ./.github/actions/setup-spice - - name: install Spice (Windows) - if: matrix.os == 'windows-latest' - run: | - curl -L "https://install.spiceai.org/Install.ps1" -o Install.ps1 && PowerShell -ExecutionPolicy Bypass -File ./Install.ps1 - - - name: add Spice bin to PATH (Windows) - if: matrix.os == 'windows-latest' - run: | - Add-Content $env:GITHUB_PATH (Join-Path $HOME ".spice\bin") - shell: pwsh - - - name: Init and start spice app (Unix) - if: matrix.os != 'windows-latest' - run: | - spice init spice_qs - cd spice_qs - # Define the dataset inline rather than fetching spiceai/quickstart from - # the Spicepod registry, which currently returns a body the CLI cannot - # unpack ("Failed to extract Spicepod archive: Could not find EOCD"). - cat >> spicepod.yaml <<'YAML' - - datasets: - - from: s3://spiceai-demo-datasets/taxi_trips/2024/ - name: taxi_trips - description: taxi trips in s3 - params: - file_format: parquet - acceleration: - enabled: true - YAML - spiced &> spice.log & - # time to initialize added dataset - sleep 10 - - - name: Init and start spice app (Windows) - if: matrix.os == 'windows-latest' - run: | - spice init spice_qs - cd spice_qs - # Define the dataset inline rather than fetching spiceai/quickstart from - # the Spicepod registry, which currently returns a body the CLI cannot - # unpack ("Failed to extract Spicepod archive: Could not find EOCD"). - @' - - datasets: - - from: s3://spiceai-demo-datasets/taxi_trips/2024/ - name: taxi_trips - description: taxi trips in s3 - params: - file_format: parquet - acceleration: - enabled: true - '@ | Add-Content -Path spicepod.yaml - Start-Process -FilePath spice run - # time to initialize added dataset - Start-Sleep -Seconds 10 - shell: pwsh + - name: Init and start spice app + uses: ./.github/actions/start-spice-app - name: Test run: mvn test -B @@ -132,6 +73,8 @@ jobs: retention-days: 7 build: + permissions: + contents: read runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -186,32 +129,11 @@ jobs: - name: Build run: mvn install -DskipTests=true -Dgpg.skip -B -V - - name: Install Spice (https://install.spiceai.org) - run: | - curl https://install.spiceai.org | /bin/bash - echo "$HOME/.spice/bin" >> $GITHUB_PATH + - name: Install Spice + uses: ./.github/actions/setup-spice - name: Init and start spice app - run: | - spice init spice_qs - cd spice_qs - # Define the dataset inline rather than fetching spiceai/quickstart from - # the Spicepod registry, which currently returns a body the CLI cannot - # unpack ("Failed to extract Spicepod archive: Could not find EOCD"). - cat >> spicepod.yaml <<'YAML' - - datasets: - - from: s3://spiceai-demo-datasets/taxi_trips/2024/ - name: taxi_trips - description: taxi trips in s3 - params: - file_format: parquet - acceleration: - enabled: true - YAML - spice run &> spice.log & - # time to initialize added dataset - sleep 10 + uses: ./.github/actions/start-spice-app - name: Test run: | @@ -258,8 +180,8 @@ jobs: - name: SpotBugs run: mvn spotbugs:check -B - - name: Checkstyle - run: mvn checkstyle:check -B + - name: Checkstyle + API compatibility (japicmp vs latest release) + run: mvn checkstyle:check japicmp:cmp -B - name: Cache OWASP Dependency-Check data uses: actions/cache@v4 @@ -269,12 +191,40 @@ jobs: restore-keys: | dependency-check-data-${{ runner.os }}- - - name: OWASP Dependency-Check - # NVD API is unreliable (429s, timeouts without API key). Don't block CI. + # Split so network flakiness and the security gate can't mask each other: + # the NVD data refresh may fail or time out without blocking CI, but when + # it completes, the check below runs offline against that data and FAILS + # the build on real CVSS >= 7 findings. + # + # The update is incremental and resumable: even a timed-out attempt makes + # progress that actions/cache persists (the job still succeeds via the + # skip path below), so cold caches converge to a warm one across runs. + # Adding the NVD_API_KEY secret makes the update fast and the gate + # effectively always-on. + - name: OWASP NVD data update (best-effort) + id: nvd-update continue-on-error: true + timeout-minutes: 15 env: NVD_API_KEY: ${{ secrets.NVD_API_KEY }} - run: mvn dependency-check:check -B -DnvdApiKey="$NVD_API_KEY" + run: | + EXTRA="" + if [ -n "$NVD_API_KEY" ]; then + EXTRA="-DnvdApiKey=$NVD_API_KEY" + fi + mvn dependency-check:update-only -B $EXTRA + + # Gate only on a completed update: a partial database makes + # dependency-check force a full re-download regardless of + # autoUpdate=false, burning the timeout. + - name: OWASP Dependency-Check (gating) + if: steps.nvd-update.outcome == 'success' + timeout-minutes: 10 + run: mvn dependency-check:check -B -DautoUpdate=false + + - name: OWASP gate skipped notice + if: steps.nvd-update.outcome != 'success' + run: echo "::warning::NVD data update did not complete — vulnerability gate skipped this run (add the NVD_API_KEY secret to make it always-on)" - name: Upload dependency-check report if: always() @@ -283,3 +233,27 @@ jobs: name: dependency-check-report path: target/dependency-check-report.html retention-days: 30 + + e2e-chaos: + permissions: + contents: read + name: E2E chaos (runtime restart, frozen peer) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 (Oracle) + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: oracle + cache: maven + + - name: Install Spice + uses: ./.github/actions/setup-spice + + - name: Run chaos e2e tests + env: + SPICE_E2E_CHAOS: "1" + run: mvn test -B -Dtest=ChaosE2ETest -DfailIfNoTests=true diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml new file mode 100644 index 0000000..354d6aa --- /dev/null +++ b/.github/workflows/nightly.yaml @@ -0,0 +1,87 @@ +name: nightly + +on: + schedule: + # 08:00 UTC daily + - cron: '0 8 * * *' + workflow_dispatch: + +jobs: + soak: + permissions: + contents: read + name: Soak (30 min mixed workload, leak + latency stability) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 (Oracle) + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: oracle + cache: maven + + - name: Install Spice + uses: ./.github/actions/setup-spice + + - name: Init and start spice app + uses: ./.github/actions/start-spice-app + + - name: Run soak + env: + SPICE_SOAK_SECONDS: "1800" + run: mvn test -B -Dtest=SoakTest -DfailIfNoTests=true + + - name: Upload runtime log + if: always() + uses: actions/upload-artifact@v4 + with: + name: soak-spiced-log + path: spice_qs/spice.log + retention-days: 7 + + bench-trend: + name: Benchmark trend (in-process, tracked on gh-pages) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 (Oracle) + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: oracle + cache: maven + + # jacoco.skip: published latencies must measure the real SDK, not + # coverage-instrumented classes. + - name: Run benchmarks + env: + BENCH_JSON: ${{ github.workspace }}/bench.json + run: mvn test -B -Dtest=PerfBenchmarkTest -DfailIfNoTests=true -Djacoco.skip=true + + # Pinned to a full commit SHA (v1.22.1): this third-party action receives + # a repository-write token, so a mutable tag reference is not acceptable. + # Data is committed to the gh-pages branch (created ahead of first run). + # Note: pushes made with GITHUB_TOKEN do not trigger a Pages build — the + # trend data always accumulates in-branch; serving the dashboard requires + # enabling Pages for gh-pages in repo settings. + - name: Publish benchmark trend + uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 + with: + name: spice-java in-process benchmarks + tool: customSmallerIsBetter + output-file-path: bench.json + github-token: ${{ secrets.GITHUB_TOKEN }} + auto-push: true + # Runners are noisy: track the trend and call out big regressions, + # but never fail the nightly on wall-clock variance alone. + alert-threshold: '150%' + fail-on-alert: false + comment-on-alert: false + summary-always: true diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 953b15f..01d6828 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -8,6 +8,8 @@ on: jobs: publish: + permissions: + contents: read runs-on: ubuntu-latest timeout-minutes: 15 environment: production @@ -16,13 +18,14 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + # The GPG signing key is deliberately NOT imported here: it is imported + # in a dedicated step immediately before publishing, after every piece + # of downloaded third-party code has already run. - name: Set up JDK uses: actions/setup-java@v4 with: distribution: oracle java-version: 17 - gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} - gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} cache: maven - name: Verify version consistency @@ -35,17 +38,40 @@ jobs: echo "ERROR: Version mismatch between pom.xml ($POM_VERSION) and Version.java ($JAVA_VERSION)" exit 1 fi + # The japicmp gate must compare against the previous release, never + # the one being published (a self-comparison is vacuous). + JAPICMP_OLD=$(mvn help:evaluate -Dexpression=japicmp.oldVersion -q -DforceStdout) + echo "japicmp.oldVersion: $JAPICMP_OLD" + if [ "$JAPICMP_OLD" = "$POM_VERSION" ]; then + echo "ERROR: japicmp.oldVersion equals the version being released; bump it to the previous release" + exit 1 + fi - name: Build run: make build - - name: Install Spice (https://install.spiceai.org) + # Release-context install: pinned version, checksum-verified — the + # publish pipeline must never execute mutable remote code (the dev CI + # convenience installer is not used here). Bump the version and digests + # together when updating the runtime under test. + - name: Install Spice (pinned + checksum-verified) env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SPICE_VERSION: v2.1.2 + SPICE_CLI_SHA256: 0a4ced62280ddd17c895a6e2ac1d6007d581a4672cd147f24cf166ec2c4565d9 + SPICED_SHA256: bcafacedd24148d97b71cecb482812ced21d26dbbeaed195075fb91bc07c9242 run: | - curl https://install.spiceai.org | /bin/bash + set -euo pipefail + mkdir -p "$HOME/.spice/bin" + curl -fsSL --retry 5 --retry-delay 10 -o spice_cli.tar.gz \ + "https://github.com/spiceai/spiceai/releases/download/${SPICE_VERSION}/spice_linux_x86_64.tar.gz" + echo "${SPICE_CLI_SHA256} spice_cli.tar.gz" | sha256sum -c - + tar -xzf spice_cli.tar.gz -C "$HOME/.spice/bin" + curl -fsSL --retry 5 --retry-delay 10 -o spiced_rt.tar.gz \ + "https://github.com/spiceai/spiceai/releases/download/${SPICE_VERSION}/spiced_linux_x86_64.tar.gz" + echo "${SPICED_SHA256} spiced_rt.tar.gz" | sha256sum -c - + tar -xzf spiced_rt.tar.gz -C "$HOME/.spice/bin" + "$HOME/.spice/bin/spice" version echo "$HOME/.spice/bin" >> $GITHUB_PATH - $HOME/.spice/bin/spice install - name: Init and start spice app run: | @@ -74,6 +100,13 @@ jobs: env: API_KEY: ${{ secrets.SPICE_CLOUD_QUICKSTART_API_KEY }} + # Import the signing key only now — after the runtime download and the + # test run — so no earlier step ever executes with the key in the keyring. + - name: Import GPG signing key + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: echo "$GPG_PRIVATE_KEY" | gpg --batch --import + - name: Publish to Maven Central env: GPG_KEYNAME: ${{ secrets.GPG_KEYNAME }} diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..c8d0fce --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,49 @@ +# Testing + +The SDK is validated by tiered test suites. Everything below runs in GitHub +Actions; every tier can also be run locally. + +| Tier | What it proves | When it runs | +| ---- | -------------- | ------------ | +| Unit + in-process integration (~290 tests) | Both query paths, prepared-statement caching, retry/backoff/timeouts, auth re-handshake, reset races, multi-endpoint results, parameter types, TLS/mTLS handshakes, HikariCP/JDBC interop — against an in-process mock Flight SQL server with RPC counters and failure injection | Every PR/push, 14 jobs (3 OSes × 11 JDK/vendor combos) | +| Live-runtime integration | The same suites against a real `spiced` quickstart (taxi_trips): queries, parameterized queries, dataset refresh, health/readiness/status | Every PR/push (same jobs — the workflow starts the runtime first) | +| Chaos e2e (`ChaosE2ETest`) | Crash/restart recovery on one client (reconnect + prepared-statement re-prepare), queries during downtime recovering via retry backoff, clean failure when the runtime dies mid-stream, keep-alive detection of a frozen (SIGSTOP) runtime | Every PR/push (`e2e-chaos` job) | +| Performance (`PerfBenchmarkTest`) | Deterministic round-trip contracts (cached: 0 prepares per query; uncached: prepare+close per query), parameter-allocation bounds; latency percentiles tracked as a trend | Every PR/push (contracts); nightly trend on gh-pages with regression alerts | +| Soak (`SoakTest`) | 30 minutes of sustained mixed workload with periodic `reset()`: zero errors, zero Arrow buffer leaks (leak ⇒ `close()` throws), bounded thread growth, stable p99 across the run | Nightly | +| Quality gates | SpotBugs, Checkstyle, Maven Enforcer, OWASP dependency-check (CVSS ≥ 7 fails), japicmp API-compatibility vs the last release, CodeQL | Every PR/push | + +## Running locally + +```bash +# Unit + in-process integration (no runtime needed) +make test + +# With a live runtime: start a quickstart first, then the same command. +# Tests gate themselves on availability — a running runtime activates the +# live-integration paths automatically. +spice init qs && cd qs && spice run # add the taxi_trips quickstart dataset + +# Chaos e2e (manages its own spiced processes; needs the spice CLI installed) +SPICE_E2E_CHAOS=1 mvn test -Dtest=ChaosE2ETest + +# Soak (against a running quickstart runtime; duration in seconds) +SPICE_SOAK_SECONDS=120 mvn test -Dtest=SoakTest + +# Benchmarks with JSON output for trend tooling +BENCH_JSON=/tmp/bench.json mvn test -Dtest=PerfBenchmarkTest + +# API compatibility vs the last release +mvn package -DskipTests -Dgpg.skip japicmp:cmp +``` + +## Conventions + +- Availability-gated tests probe once per class with a single retry and skip + silently when no runtime is present — a missing runtime must never fail + `make test` for a contributor. +- Tests that mutate shared runtime state (e.g. dataset refresh with a + restricting `refresh_sql`) must restore that state in a `finally` block and + wait for the async restore, so suites are rerun-safe against one runtime. +- Failure injection belongs in `TestFlightSqlServer` (per-RPC counters, + injected statuses, handle invalidation, bearer expiry, TLS modes) so + resilience behavior stays testable without external infrastructure. diff --git a/pom.xml b/pom.xml index be77441..3439d79 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,14 @@ + + + + 0.6.0 @@ -230,6 +238,40 @@ false + + 24 + + + + + com.github.siom79.japicmp + japicmp-maven-plugin + 0.23.1 + + + + ai.spice + spiceai + ${japicmp.oldVersion} + + + + true + true + true + + + ai.spice.example + + + + org\.apache\.arrow\.adbc\..* + + diff --git a/src/main/java/ai/spice/SpiceClient.java b/src/main/java/ai/spice/SpiceClient.java index e9032af..f87f749 100644 --- a/src/main/java/ai/spice/SpiceClient.java +++ b/src/main/java/ai/spice/SpiceClient.java @@ -157,6 +157,12 @@ public class SpiceClient implements AutoCloseable { private static final Duration HTTP_CONNECT_TIMEOUT = Duration.ofSeconds(15); private static final Duration HTTP_REQUEST_TIMEOUT = Duration.ofSeconds(60); + // HTTP/2 keep-alive tuning for dead/unresponsive-peer detection. + // Package-visible so resilience tests derive their detection windows + // from the real values instead of restating them. + static final long KEEPALIVE_TIME_SECONDS = 30; + static final long KEEPALIVE_TIMEOUT_SECONDS = 10; + // Pre-computed parameter field names to avoid string concatenation in hot path private static final String[] PARAM_NAMES = new String[64]; static { @@ -497,8 +503,8 @@ private FlightChannel buildFlightChannel() { } channelBuilder // HTTP/2 keep-alive to detect dead/idle connections behind load balancers - .keepAliveTime(30, java.util.concurrent.TimeUnit.SECONDS) - .keepAliveTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .keepAliveTime(KEEPALIVE_TIME_SECONDS, java.util.concurrent.TimeUnit.SECONDS) + .keepAliveTimeout(KEEPALIVE_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS) .keepAliveWithoutCalls(true) .maxInboundMessageSize(MAX_INBOUND_MESSAGE_SIZE) .maxInboundMetadataSize(MAX_INBOUND_METADATA_SIZE); diff --git a/src/test/java/ai/spice/ChaosE2ETest.java b/src/test/java/ai/spice/ChaosE2ETest.java new file mode 100644 index 0000000..ed8f3fa --- /dev/null +++ b/src/test/java/ai/spice/ChaosE2ETest.java @@ -0,0 +1,292 @@ +/* +Copyright 2026 The Spice.ai OSS Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package ai.spice; + +import java.net.URI; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.arrow.flight.FlightRuntimeException; +import org.apache.arrow.flight.FlightStream; +import org.apache.arrow.vector.ipc.ArrowReader; + +import junit.framework.TestCase; + +/** + * End-to-end chaos tests against a real spiced process whose lifecycle the + * tests control: crash (SIGKILL), restart, and freeze (SIGSTOP). These prove + * the SDK's resilience behaviors — retry with backoff, reconnection after + * restart, prepared-statement re-prepare on stale handles, keep-alive + * detection of unresponsive peers — against the real runtime rather than a + * mock. + * + *

Gated: runs only when SPICE_E2E_CHAOS=1 and a spiced binary is available + * (SPICED_BIN, or ~/.spice/bin/spiced). Uses a dataset-free spicepod, so + * startup is fast and there is no network dependency beyond localhost.

+ */ +public class ChaosE2ETest extends TestCase { + + /** Wall-clock guard for calls that would hang forever if a feature is broken. */ + private static final long CALL_GUARD_SECONDS = 120; + + /** + * Daemon threads: if a guarded call ignores interruption (the very + * regression being hunted), the stuck worker must not keep the JVM alive. + */ + private static final ExecutorService GUARD_EXECUTOR = Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, "chaos-guard"); + thread.setDaemon(true); + return thread; + }); + + private SpicedProcess spiced; + + /** + * Skips silently when chaos testing isn't requested, but fails loudly when + * it IS requested and no spiced binary can be found — otherwise a broken + * CI install would turn the whole chaos suite into a passing no-op. + */ + private static boolean chaosEnabled() { + if (!"1".equals(System.getenv("SPICE_E2E_CHAOS"))) { + return false; + } + assertNotNull("SPICE_E2E_CHAOS=1 but no spiced binary found (set SPICED_BIN or install the Spice CLI)", + SpicedProcess.findBinary()); + return true; + } + + @Override + protected void tearDown() throws Exception { + if (spiced != null) { + spiced.destroy(); + spiced = null; + } + super.tearDown(); + } + + private SpiceClient newClient(int maxRetries) throws Exception { + return SpiceClient.builder() + .withFlightAddress(new URI("grpc://127.0.0.1:" + spiced.flightPort)) + .withHttpAddress(new URI("http://127.0.0.1:" + spiced.httpPort)) + .withMaxRetries(maxRetries) + .build(); + } + + private static long countRows(SpiceClient client, String sql) throws Exception { + try (FlightStream stream = client.query(sql)) { + return LocalFlightServerTest.countRows(stream); + } + } + + /** Runs the callable with a hang guard so a broken SDK cannot wedge the suite. */ + private static T guarded(Callable callable) throws Exception { + Future future = GUARD_EXECUTOR.submit(callable); + try { + return future.get(CALL_GUARD_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + throw new AssertionError( + "Call did not complete within " + CALL_GUARD_SECONDS + "s — likely hung"); + } catch (ExecutionException e) { + if (e.getCause() instanceof Exception) { + throw (Exception) e.getCause(); + } + throw e; + } + } + + /** + * The SDK survives a runtime crash and restart on the same address with no + * manual intervention: plain queries reconnect (fresh DNS/TCP), and cached + * prepared statements transparently re-prepare after their server-side + * handles died with the old process. + */ + public void testSurvivesRuntimeRestart() throws Exception { + if (!chaosEnabled()) { + return; + } + spiced = SpicedProcess.start(SpicedProcess.DATASET_FREE_SPICEPOD); + + try (SpiceClient client = newClient(3)) { + assertEquals(1, countRows(client, "SELECT 1")); + // Prime the prepared-statement cache so the restart invalidates a live handle. + try (ArrowReader reader = client.queryWithParams("SELECT $1", 42L)) { + assertTrue(reader.loadNextBatch()); + } + + spiced.kill(); + + // With the runtime down, queries must fail cleanly (no hang, no NPE). + try { + guarded(() -> countRows(client, "SELECT 1")); + fail("Expected query failure while the runtime is down"); + } catch (ExecutionException e) { + assertTrue("cause should be a Flight transport error, got: " + e.getCause(), + e.getCause() instanceof FlightRuntimeException); + } + + spiced = spiced.restart(); + + // Same client, no reset(): reconnect + re-prepare must be automatic. + assertEquals(1, (long) guarded(() -> countRows(client, "SELECT 1"))); + try (ArrowReader reader = guarded(() -> client.queryWithParams("SELECT $1", 43L))) { + assertTrue("cached statement must transparently re-prepare after restart", + reader.loadNextBatch()); + } + } + } + + /** + * A query issued while the runtime is down succeeds without any caller-side + * handling, as long as the runtime returns within the retry backoff budget + * (~7.75s for 5 retries) — the load-balancer-failover scenario. + */ + public void testQueryDuringDowntimeRecoversViaRetries() throws Exception { + if (!chaosEnabled()) { + return; + } + spiced = SpicedProcess.start(SpicedProcess.DATASET_FREE_SPICEPOD); + + try (SpiceClient client = newClient(5)) { + assertEquals(1, countRows(client, "SELECT 1")); + + spiced.kill(); + // Bring the runtime back concurrently, inside the retry window. + // The restarter is always joined (finally) so a failing assertion + // can't leave it racing teardown, and its failure is surfaced. + final AtomicReference restartFailure = new AtomicReference<>(); + Thread restarter = new Thread(() -> { + try { + Thread.sleep(1_500); + spiced = spiced.restart(); + } catch (Exception e) { + restartFailure.set(e); + } + }, "chaos-restarter"); + restarter.start(); + try { + long start = System.nanoTime(); + assertEquals("query issued during downtime should succeed via retries", + 1, (long) guarded(() -> countRows(client, "SELECT 1"))); + long elapsedSeconds = (System.nanoTime() - start) / 1_000_000_000L; + assertTrue("recovery should happen within the retry budget, took " + elapsedSeconds + "s", + elapsedSeconds < 30); + } finally { + restarter.join(TimeUnit.SECONDS.toMillis(90)); + assertFalse("restarter thread must finish before teardown", restarter.isAlive()); + } + if (restartFailure.get() != null) { + throw restartFailure.get(); + } + } + } + + /** + * Killing the runtime while a large result is streaming surfaces a clean + * transport error mid-consumption (never a hang), and the same client + * recovers once the runtime returns. + */ + public void testKillMidStreamFailsCleanlyAndRecovers() throws Exception { + if (!chaosEnabled()) { + return; + } + spiced = SpicedProcess.start(SpicedProcess.DATASET_FREE_SPICEPOD); + + // ~10^7 rows from pure SQL92 cross joins — no version-specific functions. + String bigSql = "WITH t AS (SELECT * FROM (VALUES (1),(2),(3),(4),(5),(6),(7),(8),(9),(10)) v(x)) " + + "SELECT a.x FROM t a, t b, t c, t d, t e, t f, t g"; + + try (SpiceClient client = newClient(0)) { + assertEquals(1, countRows(client, "SELECT 1")); + + try { + guarded(() -> { + try (FlightStream stream = client.query(bigSql)) { + assertTrue("stream should produce at least one batch", stream.next()); + spiced.kill(); + return LocalFlightServerTest.countRows(stream); + } + }); + fail("Expected a transport error when the runtime dies mid-stream"); + } catch (FlightRuntimeException expected) { + // Clean, classifiable failure — exactly what callers should see. + } + + spiced = spiced.restart(); + assertEquals("client must recover after the runtime returns", + 1, (long) guarded(() -> countRows(client, "SELECT 1"))); + } + } + + /** + * A frozen (SIGSTOP) runtime — the unresponsive-peer case TCP alone never + * detects — is caught by HTTP/2 keep-alive: the in-flight call fails with a + * transport error instead of hanging forever. Detection is measured on a + * retry-free client so the window is one keep-alive cycle; recovery after + * the thaw is then verified with a fresh client that retries while the + * transport re-establishes. + */ + public void testFrozenRuntimeDetectedByKeepAlive() throws Exception { + if (!chaosEnabled()) { + return; + } + spiced = SpicedProcess.start(SpicedProcess.DATASET_FREE_SPICEPOD); + + // Derived from the SDK's actual keep-alive tuning plus generous + // scheduler slack; not instant (that would be a refusal, not detection). + long detectionUpperBound = (SpiceClient.KEEPALIVE_TIME_SECONDS + + SpiceClient.KEEPALIVE_TIMEOUT_SECONDS) * 2 + 20; + + try (SpiceClient client = newClient(0)) { + assertEquals(1, countRows(client, "SELECT 1")); + + spiced.freeze(); + try { + long start = System.nanoTime(); + try { + guarded(() -> countRows(client, "SELECT 1")); + fail("Expected keep-alive to fail the call against a frozen runtime"); + } catch (ExecutionException e) { + long elapsedSeconds = (System.nanoTime() - start) / 1_000_000_000L; + assertTrue("cause should be a Flight transport error, got: " + e.getCause(), + e.getCause() instanceof FlightRuntimeException); + assertTrue("keep-alive detection took " + elapsedSeconds + "s (bound: " + + detectionUpperBound + "s)", + elapsedSeconds >= 2 && elapsedSeconds <= detectionUpperBound); + } + } finally { + spiced.thaw(); + } + } + + try (SpiceClient recovered = newClient(3)) { + assertEquals(1, (long) guarded(() -> countRows(recovered, "SELECT 1"))); + } + } +} diff --git a/src/test/java/ai/spice/MtlsTest.java b/src/test/java/ai/spice/MtlsTest.java new file mode 100644 index 0000000..a3fc7e6 --- /dev/null +++ b/src/test/java/ai/spice/MtlsTest.java @@ -0,0 +1,145 @@ +/* +Copyright 2026 The Spice.ai OSS Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package ai.spice; + +import java.net.URI; +import java.util.concurrent.ExecutionException; + +import javax.net.ssl.SSLException; + +import org.apache.arrow.flight.FlightStream; +import org.apache.arrow.vector.ipc.ArrowReader; + +import junit.framework.TestCase; + +/** + * TLS and mutual-TLS integration tests: a real TLS handshake against an + * in-process Flight SQL server using runtime-generated certificates — + * covering custom-CA trust, client-certificate authentication, and the + * corresponding rejection paths. This exercises the SDK's file-based TLS + * configuration end-to-end (previously only builder validation was tested). + */ +public class MtlsTest extends TestCase { + + private static TestCerts certs; + + @Override + protected void setUp() throws Exception { + super.setUp(); + if (certs == null) { + certs = TestCerts.generate(); + } + } + + private static SpiceClientBuilder clientFor(TestFlightSqlServer server) throws Exception { + // 127.0.0.1 on both sides (matching the server bind and the cert SAN): + // dialing "localhost" may try ::1 first and fail with connection-refused + // before any TLS handshake happens. + return SpiceClient.builder() + .withFlightAddress(new URI("grpc+tls://127.0.0.1:" + server.getPort())) + .withMaxRetries(0); + } + + private static void assertQueriesWork(SpiceClient client, TestFlightSqlServer server) throws Exception { + try (FlightStream stream = client.query("SELECT * FROM test")) { + assertEquals(server.expectedTotalRows(), LocalFlightServerTest.countRows(stream)); + } + // Parameterized queries run on the same TLS channel (prepared + // statements inherit the transport configuration). + try (ArrowReader reader = client.queryWithParams("SELECT * FROM test WHERE id > $1", 1L)) { + assertEquals(server.expectedTotalRows(), LocalFlightServerTest.countRows(reader)); + } + } + + /** Server TLS with a custom CA: the client trusts it via withTlsRootCertFile. */ + public void testTlsWithCustomRootCa() throws Exception { + try (TestFlightSqlServer server = new TestFlightSqlServer(certs, false); + SpiceClient client = clientFor(server) + .withTlsRootCertFile(certs.caCert.toString()) + .build()) { + assertQueriesWork(client, server); + } + } + + /** Full mutual TLS: server verifies the client certificate. */ + public void testMutualTls() throws Exception { + try (TestFlightSqlServer server = new TestFlightSqlServer(certs, true); + SpiceClient client = clientFor(server) + .withTlsRootCertFile(certs.caCert.toString()) + .withTlsClientCertFile(certs.clientCert.toString()) + .withTlsClientKeyFile(certs.clientKey.toString()) + .build()) { + assertQueriesWork(client, server); + } + } + + /** An mTLS server rejects clients that present no certificate. */ + public void testMissingClientCertificateRejected() throws Exception { + try (TestFlightSqlServer server = new TestFlightSqlServer(certs, true); + SpiceClient client = clientFor(server) + .withTlsRootCertFile(certs.caCert.toString()) + .build()) { + try { + client.query("SELECT 1"); + fail("Expected the TLS handshake to be rejected without a client certificate"); + } catch (ExecutionException e) { + assertTlsFailure(e); + } + } + } + + /** A client that trusts a different CA must reject the server. */ + public void testUntrustedServerCaRejected() throws Exception { + try (TestFlightSqlServer server = new TestFlightSqlServer(certs, false); + SpiceClient client = clientFor(server) + .withTlsRootCertFile(certs.otherCaCert.toString()) + .build()) { + try { + client.query("SELECT 1"); + fail("Expected certificate verification to fail against an untrusted CA"); + } catch (ExecutionException e) { + assertTlsFailure(e); + } + } + } + + /** + * The negative tests must fail *because of TLS* — an unrelated transport + * or query error passing for a certificate rejection would mask a broken + * verification path. Walks the cause chain for TLS evidence. + */ + private static void assertTlsFailure(Throwable failure) { + StringBuilder chain = new StringBuilder(); + for (Throwable cause = failure; cause != null; cause = cause.getCause()) { + if (cause instanceof SSLException) { + return; + } + chain.append(cause.getClass().getName()).append(": ").append(cause.getMessage()).append(" <- "); + String message = cause.getMessage() == null ? "" : cause.getMessage().toLowerCase(); + if (message.contains("ssl") || message.contains("certificate") || message.contains("handshake")) { + return; + } + } + fail("Expected a TLS/certificate failure, but the cause chain shows none: " + chain); + } +} diff --git a/src/test/java/ai/spice/PerfBenchmarkTest.java b/src/test/java/ai/spice/PerfBenchmarkTest.java index 24d6ab3..d69876e 100644 --- a/src/test/java/ai/spice/PerfBenchmarkTest.java +++ b/src/test/java/ai/spice/PerfBenchmarkTest.java @@ -22,12 +22,18 @@ of this software and associated documentation files (the "Software"), to deal package ai.spice; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; import java.util.concurrent.Callable; import org.apache.arrow.flight.FlightStream; import org.apache.arrow.vector.ipc.ArrowReader; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + import junit.framework.TestCase; /** @@ -78,20 +84,74 @@ private static String stats(String label, long[] sortedNanos) { p50 / 1_000, p95 / 1_000, p99 / 1_000); } + private static long p50Micros(long[] sortedNanos) { + return sortedNanos[sortedNanos.length / 2] / 1_000; + } + + /** + * Appends a data point to the file named by the BENCH_JSON env var, in + * github-action-benchmark's "customSmallerIsBetter" format. No-op when the + * variable is unset (normal test runs). + */ + private static synchronized void recordBench(String name, String unit, long value) throws Exception { + String path = System.getenv("BENCH_JSON"); + if (path == null || path.isEmpty()) { + return; + } + Path file = Path.of(path); + JsonArray entries = Files.exists(file) + ? JsonParser.parseString(Files.readString(file)).getAsJsonArray() + : new JsonArray(); + JsonObject entry = new JsonObject(); + entry.addProperty("name", name); + entry.addProperty("unit", unit); + entry.addProperty("value", value); + entries.add(entry); + Files.writeString(file, entries.toString()); + } + + /** + * Counts rows and asserts the full expected result arrived — a regression + * that drops results must never publish an "improved" latency. + */ + private static long checkedCountRows(FlightStream stream, long expectedRows) throws Exception { + long rows = LocalFlightServerTest.countRows(stream); + assertEquals(expectedRows, rows); + return rows; + } + + private static long checkedCountRows(ArrowReader reader, long expectedRows) throws Exception { + long rows = LocalFlightServerTest.countRows(reader); + assertEquals(expectedRows, rows); + return rows; + } + + private static Callable paramsOp(SpiceClient client, long expectedRows) { + return () -> { + try (ArrowReader reader = client.queryWithParams(SQL, 5L)) { + return checkedCountRows(reader, expectedRows); + } + }; + } + public void testBenchmarkPlainQuery() throws Exception { try (SpiceClient client = SpiceClient.builder().withFlightAddress(server.flightUri()).build()) { + final long expectedRows = server.expectedTotalRows(); Callable op = () -> { try (FlightStream stream = client.query("SELECT * FROM bench")) { - return LocalFlightServerTest.countRows(stream); + return checkedCountRows(stream, expectedRows); } }; measure(WARMUP_ITERATIONS, op); long getFlightInfoBefore = server.getFlightInfoCalls.get(); + long doGetBefore = server.doGetCalls.get(); long[] samples = measure(MEASURED_ITERATIONS, op); System.out.println("[bench] " + stats("query()", samples)); + recordBench("query() p50", "us", p50Micros(samples)); // The plain query path is exactly 2 RPCs: GetFlightInfo + DoGet. assertEquals(MEASURED_ITERATIONS, server.getFlightInfoCalls.get() - getFlightInfoBefore); + assertEquals(MEASURED_ITERATIONS, server.doGetCalls.get() - doGetBefore); } } @@ -103,16 +163,9 @@ public void testBenchmarkParameterizedQueryCachedVsUncached() throws Exception { .withFlightAddress(server.flightUri()) .withPreparedStatementCacheSize(0) .build()) { - Callable cachedOp = () -> { - try (ArrowReader reader = cachedClient.queryWithParams(SQL, 5L)) { - return LocalFlightServerTest.countRows(reader); - } - }; - Callable uncachedOp = () -> { - try (ArrowReader reader = uncachedClient.queryWithParams(SQL, 5L)) { - return LocalFlightServerTest.countRows(reader); - } - }; + final long expectedRows = server.expectedTotalRows(); + Callable cachedOp = paramsOp(cachedClient, expectedRows); + Callable uncachedOp = paramsOp(uncachedClient, expectedRows); // Warm both paths before measuring either, so JIT compilation of the // shared code doesn't bias whichever block runs second. @@ -133,6 +186,8 @@ public void testBenchmarkParameterizedQueryCachedVsUncached() throws Exception { System.out.println("[bench] " + stats("queryWithParams (cached)", cachedSamples)); System.out.println("[bench] " + stats("queryWithParams (uncached)", uncachedSamples)); + recordBench("queryWithParams cached p50", "us", p50Micros(cachedSamples)); + recordBench("queryWithParams uncached p50", "us", p50Micros(uncachedSamples)); System.out.printf( "[bench] RPCs per %d queries: cached=%d prepares, uncached=%d prepares + %d closes " + "(2 round trips saved per query on a real network)%n", @@ -165,6 +220,7 @@ public void testParameterBindAllocationStaysSmall() throws Exception { } System.out.printf("[bench] param-root buffer bytes for 100 binds of (long,string,double): %d%n", totalCapacityBytes); + recordBench("param-root bytes per 100 binds", "bytes", totalCapacityBytes); // Old behavior: >48KB per string vector alone → many MB over 100 binds. assertTrue("parameter roots should stay small, used " + totalCapacityBytes + " bytes", totalCapacityBytes < 200_000); diff --git a/src/test/java/ai/spice/SoakTest.java b/src/test/java/ai/spice/SoakTest.java new file mode 100644 index 0000000..d73d606 --- /dev/null +++ b/src/test/java/ai/spice/SoakTest.java @@ -0,0 +1,226 @@ +/* +Copyright 2026 The Spice.ai OSS Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package ai.spice; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.arrow.flight.FlightStream; +import org.apache.arrow.vector.ipc.ArrowReader; + +import junit.framework.TestCase; + +/** + * Long-running soak against a live Spice runtime: a sustained mixed workload + * (queries, cached parameterized queries, health probes, periodic resets) + * asserting zero errors, non-empty results throughout, no Arrow memory leaks + * (a leak makes close() throw), bounded thread growth, and stable tail + * latency over the whole run. + * + *

Gated: runs only when SPICE_SOAK_SECONDS is set to a positive number + * (the nightly workflow uses 1800). Connects to the runtime configured via + * the standard SPICE_FLIGHT_URL / SPICE_HTTP_URL environment (localhost + * defaults), and queries SPICE_SOAK_DATASET (default taxi_trips).

+ */ +public class SoakTest extends TestCase { + + private static final int WORKERS = 4; + private static final long RESET_INTERVAL_SECONDS = 120; + /** + * Latency samples retained per minute. At ~9k ops/s a 30-minute soak would + * otherwise retain ~16M boxed samples and risk exhausting the heap before + * the assertions run; a bounded prefix per minute is ample for p99 + * stability comparison. (Concurrent workers may overshoot the cap by at + * most WORKERS-1 samples — irrelevant at this size.) + */ + private static final int MAX_SAMPLES_PER_MINUTE = 5_000; + + public void testSoak() throws Exception { + long soakSeconds = Long.parseLong(System.getenv().getOrDefault("SPICE_SOAK_SECONDS", "0")); + if (soakSeconds <= 0) { + return; + } + String dataset = System.getenv().getOrDefault("SPICE_SOAK_DATASET", "taxi_trips"); + String querySql = "SELECT * FROM " + dataset + " LIMIT 50"; + String paramSql = "SELECT * FROM " + dataset + " WHERE $1 = 1 LIMIT 10"; + + final AtomicLong operations = new AtomicLong(); + final AtomicLong dataOperations = new AtomicLong(); + final AtomicLong rowsRead = new AtomicLong(); + final ConcurrentLinkedQueue errors = new ConcurrentLinkedQueue<>(); + // Bounded latency samples in micros, bucketed by minute of the run. + final Map> latenciesByMinute = new ConcurrentHashMap<>(); + + final long startNanos = System.nanoTime(); + final long deadlineNanos = startNanos + TimeUnit.SECONDS.toNanos(soakSeconds); + final int warmThreadBaseline; + + try (SpiceClient client = SpiceClient.builder().withMaxRetries(3).build()) { + // Fail fast (before the long run) if the runtime isn't serving. + assertTrue("runtime must be ready before soaking", client.isReady()); + try (FlightStream warm = client.query(querySql)) { + LocalFlightServerTest.countRows(warm); + } + // Baseline AFTER warm-up: gRPC/Netty event loops and the JDK HTTP + // client's selector are shared steady-state pools that appear on + // first use. The leak signal is growth beyond this warm baseline + // over the run (e.g. resets leaving channels behind), not the + // difference from a cold JVM. + warmThreadBaseline = liveThreadCount(); + + ExecutorService executor = Executors.newFixedThreadPool(WORKERS); + for (int w = 0; w < WORKERS; w++) { + executor.submit(() -> { + int roll = 0; + while (System.nanoTime() < deadlineNanos) { + long opStart = System.nanoTime(); + try { + int kind = roll++ % 20; + if (kind < 16) { + try (FlightStream stream = client.query(querySql)) { + rowsRead.addAndGet(LocalFlightServerTest.countRows(stream)); + } + dataOperations.incrementAndGet(); + } else if (kind < 19) { + try (ArrowReader reader = client.queryWithParams(paramSql, 1L)) { + rowsRead.addAndGet(LocalFlightServerTest.countRows(reader)); + } + dataOperations.incrementAndGet(); + } else { + if (!client.isHealthy() || !client.isReady()) { + errors.add("health probe reported unhealthy mid-soak"); + } + } + operations.incrementAndGet(); + long minute = TimeUnit.NANOSECONDS.toMinutes(opStart - startNanos); + List bucket = latenciesByMinute.computeIfAbsent(minute, + m -> Collections.synchronizedList(new ArrayList<>())); + if (bucket.size() < MAX_SAMPLES_PER_MINUTE) { + bucket.add((System.nanoTime() - opStart) / 1_000); + } + } catch (Throwable t) { + errors.add(t.getClass().getSimpleName() + ": " + t.getMessage()); + if (errors.size() > 20) { + return; // Failing hard; no point continuing. + } + } + } + }); + } + executor.shutdown(); + + // Periodic reset() proves transport rebuild under sustained load; + // awaitTermination doubles as the completion join. + while (!executor.awaitTermination(RESET_INTERVAL_SECONDS, TimeUnit.SECONDS)) { + if (System.nanoTime() < deadlineNanos) { + try { + client.reset(); + } catch (Throwable t) { + errors.add("reset: " + t.getMessage()); + } + } else { + assertTrue("workers must finish shortly after the deadline", + executor.awaitTermination(120, TimeUnit.SECONDS)); + break; + } + } + + printSummary(operations.get(), soakSeconds, latenciesByMinute); + + assertTrue("soak must complete with zero errors, got " + errors.size() + + " — first: " + errors.peek(), errors.isEmpty()); + // Guard against a silent-empty-results regression: every data + // operation queries with a LIMIT >= 10 against a populated dataset. + System.out.printf("[soak] data-ops=%d rows-read=%d%n", dataOperations.get(), rowsRead.get()); + assertTrue("every data operation must return rows: ops=" + dataOperations.get() + + " rows=" + rowsRead.get(), rowsRead.get() >= dataOperations.get() * 10); + assertTailLatencyStable(latenciesByMinute); + } + // Reaching here means close() did not throw: no Arrow buffers leaked + // over the whole run (a leak makes the allocator close throw). + + // Transport threads shut down with the client; allow scavenger slack. + Thread.sleep(3_000); + int threadsAfter = liveThreadCount(); + System.out.printf("[soak] threads: warm-baseline=%d after-close=%d%n", + warmThreadBaseline, threadsAfter); + assertTrue("thread count should not grow over the soak: warm-baseline=" + warmThreadBaseline + + " after-close=" + threadsAfter, threadsAfter <= warmThreadBaseline + 8); + } + + private static int liveThreadCount() { + return Thread.getAllStackTraces().size(); + } + + private static long percentile(List sortedMicros, double quantile) { + return sortedMicros.get((int) Math.min(sortedMicros.size() - 1, + (long) (sortedMicros.size() * quantile))); + } + + /** p99 of the last third of the run must stay within 3x of the first third. */ + private static void assertTailLatencyStable(Map> byMinute) { + List minutes = new ArrayList<>(byMinute.keySet()); + if (minutes.size() < 3) { + return; // Too short a run to compare thirds. + } + minutes.sort(Long::compare); + int third = minutes.size() / 3; + long firstThirdP99 = p99Of(byMinute, minutes.subList(0, third)); + long lastThirdP99 = p99Of(byMinute, minutes.subList(minutes.size() - third, minutes.size())); + System.out.printf("[soak] p99 first-third=%dus last-third=%dus%n", firstThirdP99, lastThirdP99); + assertTrue("tail latency degraded over the soak: first-third p99=" + firstThirdP99 + + "us, last-third p99=" + lastThirdP99 + "us", + lastThirdP99 <= firstThirdP99 * 3); + } + + private static long p99Of(Map> byMinute, List minutes) { + List samples = new ArrayList<>(); + for (long minute : minutes) { + samples.addAll(byMinute.get(minute)); + } + samples.sort(Long::compare); + return samples.isEmpty() ? 0 : percentile(samples, 0.99); + } + + private static void printSummary(long operations, long soakSeconds, Map> byMinute) { + List all = new ArrayList<>(); + byMinute.values().forEach(all::addAll); + all.sort(Long::compare); + long p50 = all.isEmpty() ? 0 : percentile(all, 0.50); + long p99 = all.isEmpty() ? 0 : percentile(all, 0.99); + System.out.printf("[soak] %d ops over %ds (%.1f ops/s), p50=%dus p99=%dus, %d minutes sampled%n", + operations, soakSeconds, operations / (double) soakSeconds, p50, p99, byMinute.size()); + System.out.println("[soak] per-minute p99 (us): " + + byMinute.keySet().stream().sorted() + .map(minute -> String.valueOf(p99Of(byMinute, List.of(minute)))) + .reduce((a, b) -> a + ", " + b).orElse("none")); + } +} diff --git a/src/test/java/ai/spice/SpicedProcess.java b/src/test/java/ai/spice/SpicedProcess.java new file mode 100644 index 0000000..fbcb1fc --- /dev/null +++ b/src/test/java/ai/spice/SpicedProcess.java @@ -0,0 +1,203 @@ +/* +Copyright 2026 The Spice.ai OSS Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package ai.spice; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +/** + * Manages a real spiced process for lifecycle-level tests: launch on fixed + * localhost ports with a caller-provided spicepod, wait for health, and + * crash (SIGKILL), freeze (SIGSTOP)/thaw (SIGCONT), restart on the same + * address (mirroring a crashed server replaced behind a stable VIP), or + * destroy. The process fixture counterpart to {@link TestFlightSqlServer}. + */ +final class SpicedProcess { + + /** A spicepod with no datasets: fast startup, no external dependencies. */ + static final String DATASET_FREE_SPICEPOD = "version: v1\nkind: Spicepod\nname: chaos\n"; + + private static final HttpClient HEALTH_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(1)) + .build(); + + final Path workspace; + final int httpPort; + final int flightPort; + final int metricsPort; + private Process process; + + private SpicedProcess(Path workspace, int httpPort, int flightPort, int metricsPort) { + this.workspace = workspace; + this.httpPort = httpPort; + this.flightPort = flightPort; + this.metricsPort = metricsPort; + } + + /** The spiced binary from SPICED_BIN or ~/.spice/bin, or null if absent. */ + static String findBinary() { + String env = System.getenv("SPICED_BIN"); + if (env != null && Files.isExecutable(Path.of(env))) { + return env; + } + Path home = Path.of(System.getProperty("user.home"), ".spice", "bin", "spiced"); + return Files.isExecutable(home) ? home.toString() : null; + } + + static SpicedProcess start(String spicepodYaml) throws Exception { + // Ephemeral ports are released before spiced binds them, so a rare + // port steal is possible; retry with fresh ports. + Exception lastFailure = null; + for (int attempt = 1; attempt <= 3; attempt++) { + Path workspace = Files.createTempDirectory("spice-chaos"); + Files.writeString(workspace.resolve("spicepod.yaml"), spicepodYaml, StandardCharsets.UTF_8); + SpicedProcess spiced = new SpicedProcess(workspace, freePort(), freePort(), freePort()); + try { + spiced.launch(); + return spiced; + } catch (Exception e) { + lastFailure = e; + } + } + throw lastFailure; + } + + /** + * Starts a fresh process on the same ports and workspace — clients must + * be able to reconnect to the same address. No port retry is possible here. + */ + SpicedProcess restart() throws Exception { + SpicedProcess fresh = new SpicedProcess(workspace, httpPort, flightPort, metricsPort); + fresh.launch(); + return fresh; + } + + private void launch() throws Exception { + ProcessBuilder builder = new ProcessBuilder( + findBinary(), + "--http", "127.0.0.1:" + httpPort, + "--flight", "127.0.0.1:" + flightPort, + "--metrics", "127.0.0.1:" + metricsPort); + builder.directory(workspace.toFile()); + builder.redirectErrorStream(true); + builder.redirectOutput(workspace.resolve("spiced.log").toFile()); + process = builder.start(); + try { + waitUntilHealthy(); + } catch (Exception startupFailure) { + // Never orphan a half-started process the caller has no handle to. + try { + destroy(); + } catch (Exception cleanup) { + startupFailure.addSuppressed(cleanup); + } + throw startupFailure; + } + } + + private void waitUntilHealthy() throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(new URI("http://127.0.0.1:" + httpPort + "/health")) + .timeout(Duration.ofSeconds(2)) + .GET() + .build(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(60); + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException("spiced exited during startup; see " + + workspace.resolve("spiced.log")); + } + try { + HttpResponse response = HEALTH_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200) { + return; + } + } catch (IOException retry) { + // Not up yet. + } catch (InterruptedException interrupted) { + // Preserve the interrupt and stop waiting: cancellation must + // not be silently converted into more polling. + Thread.currentThread().interrupt(); + throw interrupted; + } + Thread.sleep(250); + } + throw new IllegalStateException("spiced did not become healthy within 60s"); + } + + /** SIGKILL — an abrupt crash, no graceful shutdown. */ + void kill() throws Exception { + process.destroyForcibly(); + if (!process.waitFor(10, TimeUnit.SECONDS)) { + throw new IllegalStateException( + "spiced pid " + process.pid() + " did not exit within 10s of SIGKILL"); + } + } + + /** SIGSTOP — the process is alive but completely unresponsive. */ + void freeze() throws Exception { + signal("STOP"); + } + + /** SIGCONT — resume a frozen process. */ + void thaw() throws Exception { + signal("CONT"); + } + + private void signal(String name) throws Exception { + Process kill = new ProcessBuilder("kill", "-" + name, Long.toString(process.pid())).start(); + if (!kill.waitFor(5, TimeUnit.SECONDS) || kill.exitValue() != 0) { + throw new IllegalStateException("kill -" + name + " failed for pid " + process.pid()); + } + } + + void destroy() throws Exception { + if (process != null && process.isAlive()) { + // A frozen process ignores SIGKILL delivery ordering with SIGSTOP + // pending on some platforms; thaw first, best-effort. + try { + signal("CONT"); + } catch (Exception ignored) { + // Already dead or never frozen. + } + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + } + + private static int freePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } + } +} diff --git a/src/test/java/ai/spice/TestCerts.java b/src/test/java/ai/spice/TestCerts.java new file mode 100644 index 0000000..64ec3f9 --- /dev/null +++ b/src/test/java/ai/spice/TestCerts.java @@ -0,0 +1,173 @@ +/* +Copyright 2026 The Spice.ai OSS Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package ai.spice; + +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** + * Self-signed certificate fixtures for TLS/mTLS tests, generated at runtime + * with BouncyCastle (already a compile dependency of the SDK): a CA, a server + * certificate for localhost/127.0.0.1, a client certificate signed by the same + * CA, and a second, unrelated CA for negative tests. All materialized as PEM + * files, matching the SDK's file-based TLS configuration. + */ +final class TestCerts { + + private static final AtomicLong SERIAL = new AtomicLong(System.currentTimeMillis()); + + final Path caCert; + final Path serverCert; + final Path serverKey; + final Path clientCert; + final Path clientKey; + /** A CA unrelated to any issued certificate, for trust-failure tests. */ + final Path otherCaCert; + + private TestCerts(Path caCert, Path serverCert, Path serverKey, + Path clientCert, Path clientKey, Path otherCaCert) { + this.caCert = caCert; + this.serverCert = serverCert; + this.serverKey = serverKey; + this.clientCert = clientCert; + this.clientKey = clientKey; + this.otherCaCert = otherCaCert; + } + + static TestCerts generate() throws Exception { + Path dir = Files.createTempDirectory("spice-test-certs"); + + KeyPair caKeys = newKeyPair(); + X509Certificate ca = newCaCert("CN=Spice Test CA", caKeys); + + KeyPair serverKeys = newKeyPair(); + X509Certificate server = newLeafCert("CN=localhost", serverKeys, ca, caKeys.getPrivate(), + KeyPurposeId.id_kp_serverAuth, true); + + KeyPair clientKeys = newKeyPair(); + X509Certificate client = newLeafCert("CN=spice-test-client", clientKeys, ca, caKeys.getPrivate(), + KeyPurposeId.id_kp_clientAuth, false); + + KeyPair otherCaKeys = newKeyPair(); + X509Certificate otherCa = newCaCert("CN=Unrelated Test CA", otherCaKeys); + + return new TestCerts( + writePem(dir.resolve("ca.pem"), ca), + writePem(dir.resolve("server.pem"), server), + writeKeyPem(dir.resolve("server-key.pem"), serverKeys.getPrivate()), + writePem(dir.resolve("client.pem"), client), + writeKeyPem(dir.resolve("client-key.pem"), clientKeys.getPrivate()), + writePem(dir.resolve("other-ca.pem"), otherCa)); + } + + private static KeyPair newKeyPair() throws Exception { + // EC P-256: ~300x faster to generate than RSA-2048 (which shows + // multi-second outliers on shared CI runners), equally supported by + // Netty/gRPC and the SDK's PEM parsing. + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + return generator.generateKeyPair(); + } + + private static X509Certificate newCaCert(String subject, KeyPair keys) throws Exception { + X500Name name = new X500Name(subject); + X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + name, BigInteger.valueOf(SERIAL.incrementAndGet()), + notBefore(), notAfter(), name, keys.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + builder.addExtension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + return sign(builder, keys.getPrivate()); + } + + private static X509Certificate newLeafCert(String subject, KeyPair keys, + X509Certificate issuer, PrivateKey issuerKey, KeyPurposeId purpose, + boolean withLocalhostSan) throws Exception { + X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, BigInteger.valueOf(SERIAL.incrementAndGet()), + notBefore(), notAfter(), new X500Name(subject), keys.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + builder.addExtension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment)); + builder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(purpose)); + if (withLocalhostSan) { + builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames( + new GeneralName[] { + new GeneralName(GeneralName.dNSName, "localhost"), + new GeneralName(GeneralName.iPAddress, "127.0.0.1"), + })); + } + return sign(builder, issuerKey); + } + + private static X509Certificate sign(X509v3CertificateBuilder builder, PrivateKey key) throws Exception { + ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").build(key); + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } + + private static Date notBefore() { + return new Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)); + } + + private static Date notAfter() { + return new Date(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1)); + } + + private static Path writePem(Path path, Object pemObject) throws Exception { + try (JcaPEMWriter writer = new JcaPEMWriter(Files.newBufferedWriter(path))) { + writer.writeObject(pemObject); + } + return path; + } + + private static Path writeKeyPem(Path path, PrivateKey key) throws Exception { + // PKCS#8 ("PRIVATE KEY"), accepted by both Netty (Flight path) and + // the SDK's BouncyCastle PEM parsing (HTTP path). + return writePem(path, new JcaPKCS8Generator(key, null)); + } +} diff --git a/src/test/java/ai/spice/TestFlightSqlServer.java b/src/test/java/ai/spice/TestFlightSqlServer.java index 0d47562..f9989c1 100644 --- a/src/test/java/ai/spice/TestFlightSqlServer.java +++ b/src/test/java/ai/spice/TestFlightSqlServer.java @@ -22,8 +22,13 @@ of this software and associated documentation files (the "Software"), to deal package ai.spice; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -104,7 +109,7 @@ final class TestFlightSqlServer implements AutoCloseable { /** Starts an unauthenticated server on an ephemeral port. */ TestFlightSqlServer() throws Exception { - this(null, null); + this(null, null, null, false); } /** @@ -113,9 +118,34 @@ final class TestFlightSqlServer implements AutoCloseable { * bearer tokens for subsequent calls (mirroring Spice's auth flow). */ TestFlightSqlServer(String expectedUser, String expectedPassword) throws Exception { + this(expectedUser, expectedPassword, null, false); + } + + /** + * Starts a TLS server using the fixture's server certificate. With + * requireClientCert, clients must present a certificate signed by the + * fixture CA (mutual TLS). + */ + TestFlightSqlServer(TestCerts certs, boolean requireClientCert) throws Exception { + this(null, null, certs, requireClientCert); + } + + private TestFlightSqlServer(String expectedUser, String expectedPassword, + TestCerts certs, boolean requireClientCert) throws Exception { this.allocator = new RootAllocator(Long.MAX_VALUE); - FlightServer.Builder builder = FlightServer.builder( - allocator, Location.forGrpcInsecure("localhost", 0), new Producer()); + // TLS tests bind and dial 127.0.0.1 explicitly: "localhost" can resolve + // to ::1 first, and a connection-refused there would let negative TLS + // tests pass without ever reaching the handshake under test. + Location location = certs != null + ? Location.forGrpcTls("127.0.0.1", 0) + : Location.forGrpcInsecure("localhost", 0); + FlightServer.Builder builder = FlightServer.builder(allocator, location, new Producer()); + if (certs != null) { + builder.useTls(pemStream(certs.serverCert), pemStream(certs.serverKey)); + if (requireClientCert) { + builder.useMTlsClientVerification(pemStream(certs.caCert)); + } + } if (expectedUser != null) { CallHeaderAuthenticator inner = new GeneratedBearerTokenAuthenticator( new BasicCallHeaderAuthenticator((username, password) -> { @@ -163,6 +193,14 @@ void rejectNextBearerToken() { rejectNextBearer.set(true); } + /** + * PEM file as an in-memory stream: the FlightServer builder defers reading + * its TLS streams until build(), so file streams would be closed by then. + */ + private static InputStream pemStream(Path pem) throws IOException { + return new ByteArrayInputStream(Files.readAllBytes(pem)); + } + long expectedTotalRows() { return (long) endpointCount * batchesPerEndpoint * rowsPerBatch; }