From 7f47503d7f725d92ea899137c235954f20244355 Mon Sep 17 00:00:00 2001
From: Luke Kim <80174+lukekim@users.noreply.github.com>
Date: Fri, 31 Jul 2026 16:37:09 -0700
Subject: [PATCH 1/5] =?UTF-8?q?test/ci:=20enterprise-grade=20hardening=20?=
=?UTF-8?q?=E2=80=94=20chaos=20e2e,=20mTLS,=20soak,=20perf=20trend,=20API?=
=?UTF-8?q?=20gate,=20pipeline=20fixes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P0 — CI pipeline reliability:
- Spice CLI install steps (build x3 incl. Windows, publish, new jobs):
retry with backoff + binary verification + GITHUB_TOKEN (the JDK-matrix
job lacked the token and caused most installer flakes)
- OWASP: step-level 20m timeout so a slow NVD refresh can't hit the job
timeout (which cancels the job and bypasses continue-on-error);
nvdValidForHours=24 so warm caches skip the refresh entirely
P0 — chaos e2e (ChaosE2ETest + e2e-chaos job, gated by SPICE_E2E_CHAOS):
manages its own spiced processes (dataset-free pod) and proves against the
real runtime: crash/restart recovery on one client (reconnect + prepared
statement re-prepare), queries during downtime recovering via retry
backoff, clean mid-stream failure on SIGKILL, and keep-alive detection of
a frozen (SIGSTOP) peer with recovery after SIGCONT. 4/4 green locally.
P1 — mTLS integration (MtlsTest + TestCerts + TLS mode in
TestFlightSqlServer): runtime-generated CA/server/client certificates,
real handshakes in-process — custom-CA trust, full mutual TLS including
parameterized queries, missing-client-cert and untrusted-CA rejection.
First e2e coverage of the TLS configuration surface.
P1 — japicmp API-compatibility gate vs the last release (0.6.0), wired
into the quality job; verified the 0.7.0 surface is purely additive.
ADBC types (internal in 0.6.0, removed since) are scoped as ignored.
P1 — nightly workflow: 30-min soak (SoakTest: mixed workload with
periodic reset(); zero errors, leak-free close, bounded thread growth
from a warm baseline, p99 stability across the run — validated locally at
~9,200 ops/s), chaos rerun, and benchmark trend publishing to gh-pages
via github-action-benchmark (warn-only alerts; PerfBenchmarkTest emits
customSmallerIsBetter JSON when BENCH_JSON is set).
docs/testing.md documents the tiers and how to run each locally.
Full gate: 293 tests, 0 failures; SpotBugs clean.
---
.github/workflows/build.yaml | 97 +++-
.github/workflows/nightly.yaml | 152 +++++++
.github/workflows/publish.yaml | 11 +-
docs/testing.md | 49 +++
pom.xml | 35 ++
src/test/java/ai/spice/ChaosE2ETest.java | 414 ++++++++++++++++++
src/test/java/ai/spice/MtlsTest.java | 120 +++++
src/test/java/ai/spice/PerfBenchmarkTest.java | 30 ++
src/test/java/ai/spice/SoakTest.java | 224 ++++++++++
src/test/java/ai/spice/TestCerts.java | 176 ++++++++
.../java/ai/spice/TestFlightSqlServer.java | 33 +-
11 files changed, 1332 insertions(+), 9 deletions(-)
create mode 100644 .github/workflows/nightly.yaml
create mode 100644 docs/testing.md
create mode 100644 src/test/java/ai/spice/ChaosE2ETest.java
create mode 100644 src/test/java/ai/spice/MtlsTest.java
create mode 100644 src/test/java/ai/spice/SoakTest.java
create mode 100644 src/test/java/ai/spice/TestCerts.java
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index cf7db81..8b87ccc 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -47,15 +47,40 @@ jobs:
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # The installer resolves the latest release via the GitHub API, which
+ # intermittently fails from runner IPs (empty version -> corrupt
+ # archive). Retry with backoff and verify the binary actually works.
run: |
- curl https://install.spiceai.org | /bin/bash
+ 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
$HOME/.spice/bin/spice install
- name: install Spice (Windows)
if: matrix.os == 'windows-latest'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: pwsh
run: |
- curl -L "https://install.spiceai.org/Install.ps1" -o Install.ps1 && PowerShell -ExecutionPolicy Bypass -File ./Install.ps1
+ $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" }
- name: add Spice bin to PATH (Windows)
if: matrix.os == 'windows-latest'
@@ -187,8 +212,19 @@ jobs:
run: mvn install -DskipTests=true -Dgpg.skip -B -V
- name: Install Spice (https://install.spiceai.org)
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Authenticated + retried: the installer's release lookup intermittently
+ # fails from runner IPs without a token (this step caused most CI flakes).
run: |
- curl https://install.spiceai.org | /bin/bash
+ 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: Init and start spice app
@@ -261,6 +297,9 @@ jobs:
- name: Checkstyle
run: mvn checkstyle:check -B
+ - name: API compatibility (japicmp vs latest release)
+ run: mvn japicmp:cmp -B
+
- name: Cache OWASP Dependency-Check data
uses: actions/cache@v4
with:
@@ -270,11 +309,19 @@ jobs:
dependency-check-data-${{ runner.os }}-
- name: OWASP Dependency-Check
- # NVD API is unreliable (429s, timeouts without API key). Don't block CI.
+ # NVD API is unreliable (429s, slow without an API key). The step-level
+ # timeout keeps a slow NVD refresh from hitting the job-level timeout,
+ # which would cancel the whole job and bypass continue-on-error.
continue-on-error: true
+ timeout-minutes: 20
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:check -B $EXTRA
- name: Upload dependency-check report
if: always()
@@ -283,3 +330,43 @@ jobs:
name: dependency-check-report
path: target/dependency-check-report.html
retention-days: 30
+
+ e2e-chaos:
+ 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: Build
+ run: mvn install -DskipTests=true -Dgpg.skip -B -V
+
+ - name: Install Spice (https://install.spiceai.org)
+ env:
+ GITHUB_TOKEN: ${{ secrets.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
+ $HOME/.spice/bin/spice install
+
+ # The chaos tests start, kill, freeze, and restart their own spiced
+ # process (no datasets needed), validating end-to-end that the SDK
+ # survives runtime crashes, restarts, and frozen peers.
+ - 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..fe4d24d
--- /dev/null
+++ b/.github/workflows/nightly.yaml
@@ -0,0 +1,152 @@
+name: nightly
+
+on:
+ schedule:
+ # 08:00 UTC daily
+ - cron: '0 8 * * *'
+ workflow_dispatch:
+
+jobs:
+ soak:
+ 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: Build
+ run: mvn install -DskipTests=true -Dgpg.skip -B -V
+
+ - name: Install Spice (https://install.spiceai.org)
+ env:
+ GITHUB_TOKEN: ${{ secrets.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
+ $HOME/.spice/bin/spice install
+
+ - name: Init and start spice app
+ run: |
+ spice init spice_qs
+ cd spice_qs
+ 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 &
+ # Wait for readiness (dataset load) rather than a fixed sleep.
+ for i in $(seq 1 120); do
+ if [ "$(curl -s http://localhost:8090/v1/ready)" = "ready" ]; then
+ echo "runtime ready after ${i}s"
+ break
+ fi
+ sleep 1
+ done
+ [ "$(curl -s http://localhost:8090/v1/ready)" = "ready" ]
+
+ - 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
+
+ chaos:
+ name: Chaos e2e (nightly)
+ 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: Build
+ run: mvn install -DskipTests=true -Dgpg.skip -B -V
+
+ - name: Install Spice (https://install.spiceai.org)
+ env:
+ GITHUB_TOKEN: ${{ secrets.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
+ $HOME/.spice/bin/spice install
+
+ - name: Run chaos e2e tests
+ env:
+ SPICE_E2E_CHAOS: "1"
+ run: mvn test -B -Dtest=ChaosE2ETest -DfailIfNoTests=true
+
+ 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
+
+ - name: Run benchmarks
+ env:
+ BENCH_JSON: ${{ github.workspace }}/bench.json
+ run: mvn test -B -Dtest=PerfBenchmarkTest -DfailIfNoTests=true
+
+ - name: Publish benchmark trend
+ uses: benchmark-action/github-action-benchmark@v1
+ 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..958542a 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -42,8 +42,17 @@ jobs:
- name: Install Spice (https://install.spiceai.org)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Retried + verified: the installer's release lookup intermittently
+ # fails from runner IPs; a flake here must never break a release.
run: |
- curl https://install.spiceai.org | /bin/bash
+ 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
$HOME/.spice/bin/spice install
diff --git a/docs/testing.md b/docs/testing.md
new file mode 100644
index 0000000..e3024b7
--- /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) and nightly |
+| 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..e4c31b6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -230,6 +230,41 @@
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; + + private SpicedProcess spiced; + + private static boolean chaosEnabled() { + return "1".equals(System.getenv("SPICE_E2E_CHAOS")) && SpicedProcess.findBinary() != null; + } + + @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)) { + long rows = 0; + while (stream.next()) { + rows += stream.getRoot().getRowCount(); + } + return rows; + } + } + + /** Runs the callable with a hang guard so a broken SDK cannot wedge the suite. */ + private staticGated: 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; + + 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 ConcurrentLinkedQueueGated: runs only when SPICE_SOAK_SECONDS is set to a positive number
* (the nightly workflow uses 1800). Connects to the runtime configured via
@@ -59,7 +58,8 @@ public class SoakTest extends TestCase {
* 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.
+ * 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;
@@ -76,9 +76,8 @@ public void testSoak() throws Exception {
final AtomicLong dataOperations = new AtomicLong();
final AtomicLong rowsRead = new AtomicLong();
final ConcurrentLinkedQueue