Skip to content

Commit c2f6706

Browse files
feat(cts): add spec-driven behavioural contract test suite
Introduce a second contract test suite for the Lance namespace REST API that exercises operation *semantics* in-process, complementing — not replacing — the existing wire-level WireMock suite. The two suites now have distinct, non-overlapping concerns and a clean directory layout. What's new ---------- * Authoritative spec under docs/src/cts-contracts/ split by domain: main.yaml, namespace.yaml, table.yaml, data.yaml, index.yaml, tag.yaml, transaction.yaml. Each case declares pre-conditions (`given`), the request (`when`) and the expected outcome (`then`: success / error_code / 4xx / 409 …) plus required capabilities. * JSON Schema (ci/cts/cts-contracts.schema.json) and strict linter (ci/cts/lint_contracts.py) enforcing single-file ownership per operation, capability validity and shape correctness. * Capability model: ci/cts/capabilities.py plus the per-impl manifest ci/cts/capabilities.directory.txt; cases requiring an unsupported capability are skipped at runtime instead of failing. * ci/cts/contract_loader.py parses & validates contracts; ci/cts/gen_contract_tests.py renders one Rust test module per operation under rust/lance-namespace-cts/tests/contracts/ (43 modules), post-processed with `rustfmt --edition 2024` so `cargo fmt --check` is a fixed point and `--check` mode catches drift in CI. * New workspace member rust/lance-namespace-cts/ hosting the in-process harness — Fixtures, ContractCaller, Capabilities, assert_contract_{ok,error} — driving DirectoryNamespace from the sibling `lance` repo via a cargo path dependency. No cdylib, no network, runs as a normal `cargo test` target. Disambiguation renames (the name "contract" now refers to the new behavioural suite; the wire-level suite is consistently "wiremock"): * ci/cts/gen_client_tests.py -> gen_wiremock_tests.py * templates/{rust,python,java_*}_contract.mustache -> *_wiremock.mustache * rust tests/contract.rs -> tests/wiremock.rs * python tests/test_contract.py -> tests/test_wiremock.py * java WireMockContractIT.java -> WireMockIT.java Build / CI surface ------------------ * Make: new `gen-cts-behavior` and `test-cts-behavior` targets; default `test-cts` now runs spec-lint + behavioural; opt-in alias `test-cts-wiremock` keeps the historical (multi-language) meaning. * GitHub Actions: contract-tests.yml runs the behavioural suite on every PR; the WireMock job becomes opt-in (push to long-lived branches and workflow_dispatch only) to keep PR latency low. * CONTRIBUTING.md gains a "Contract Tests (CTS)" section documenting the two suites, how to author a behavioural case, and the lint / codegen / run loop. Quality gates (all green locally) --------------------------------- cargo fmt --check OK cargo clippy -D warnings OK gen_contract_tests --check OK lint_contracts --strict OK make test-cts 170 passed make test-cts-wiremock 49 (rust) + 50 (python) + 49 (java)
1 parent f1ab249 commit c2f6706

88 files changed

Lines changed: 16468 additions & 589 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/contract-tests.yml

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
branches: [main, "feat/**", "feature/**"]
66
paths:
77
- "docs/src/spec.yaml"
8+
- "docs/src/cts-contracts/**"
89
- "ci/**"
910
- "java/**"
1011
- "python/**"
@@ -13,11 +14,18 @@ on:
1314
pull_request:
1415
paths:
1516
- "docs/src/spec.yaml"
17+
- "docs/src/cts-contracts/**"
1618
- "ci/**"
1719
- "java/**"
1820
- "python/**"
1921
- "rust/**"
2022
- ".github/workflows/contract-tests.yml"
23+
workflow_dispatch:
24+
inputs:
25+
run_wiremock:
26+
description: "Run the legacy WireMock client-conformance matrix"
27+
type: boolean
28+
default: false
2129

2230
concurrency:
2331
group: ${{ github.workflow }}-${{ github.ref }}
@@ -90,12 +98,92 @@ jobs:
9098
# ─────────────────────────────────────────────────────────────
9199

92100
# ─────────────────────────────────────────────────────────────
93-
# Job 3: Client conformance — matrix over java / python / rust.
94-
# Each language generates WireMock stubs from the spec
95-
# and runs its own thin contract runner independently.
101+
# Job 3: Behavioural contract conformance — drives the spec-driven
102+
# CTS YAML bundle through the in-process Rust harness in
103+
# `rust/lance-namespace-cts` against `DirectoryNamespace`.
104+
# This is the primary PR-blocking signal; it covers behaviour
105+
# (state changes, error codes, capability gating) — not just
106+
# HTTP wire shape. Runs on every push and PR.
107+
# ─────────────────────────────────────────────────────────────
108+
behavior-conformance:
109+
name: Behaviour Conformance (Rust in-process)
110+
runs-on: ubuntu-latest
111+
needs: spec
112+
steps:
113+
- uses: actions/checkout@v4
114+
115+
- name: Setup Python
116+
uses: actions/setup-python@v5
117+
with:
118+
python-version: "3.11"
119+
120+
- name: Install uv
121+
uses: astral-sh/setup-uv@v3
122+
123+
- name: Install Python workspace (loader/lint/codegen need pyyaml + jsonschema)
124+
run: uv sync --all-packages
125+
126+
- name: Setup Rust
127+
uses: dtolnay/rust-toolchain@stable
128+
with:
129+
toolchain: stable
130+
131+
# `lance-namespace-cts` pulls in `lance-namespace-impls` from the sibling
132+
# `lance` repository via cargo `path`, which transitively depends on
133+
# `lance-encoding`. `lance-encoding`'s build script invokes `protoc` to
134+
# compile its `.proto` files, so the GitHub-hosted runner needs the
135+
# protobuf compiler on PATH before we run `cargo test`. Locally this
136+
# works because contributors typically have `protoc` installed; CI does
137+
# not, so we install it explicitly here.
138+
- name: Install protoc
139+
uses: arduino/setup-protoc@v3
140+
with:
141+
version: "27.x"
142+
repo-token: ${{ secrets.GITHUB_TOKEN }}
143+
144+
- name: Cache Rust build
145+
uses: Swatinem/rust-cache@v2
146+
with:
147+
workspaces: "rust -> rust/target"
148+
149+
- name: Lint contract bundle (strict)
150+
run: uv run python ci/cts/lint_contracts.py --strict
151+
152+
- name: Verify generated tests are up-to-date
153+
run: uv run python ci/cts/gen_contract_tests.py --check
154+
155+
- name: Run behavioural contract suite
156+
run: make test-cts-behavior
157+
158+
- name: Upload behavioural test logs
159+
if: always()
160+
uses: actions/upload-artifact@v4
161+
with:
162+
name: contract-test-behavior
163+
path: rust/lance-namespace-cts/target/nextest/**/*.xml
164+
if-no-files-found: ignore
165+
166+
# ─────────────────────────────────────────────────────────────
167+
# Job 4: Client conformance (legacy WireMock) — matrix over
168+
# java / python / rust. Each language generates WireMock
169+
# stubs from the spec and runs its thin contract runner.
170+
#
171+
# As of P5 (the CI-finalisation phase of the behavioural CTS rollout)
172+
# this job is **opt-in**: it runs on push to `main` only and
173+
# on manual workflow_dispatch with `run_wiremock=true`. It is
174+
# skipped on pull_request events and on push to feature
175+
# branches, to keep PR/dev-branch feedback focused on the
176+
# (much faster) behavioural job above.
96177
# ─────────────────────────────────────────────────────────────
97178
client-conformance:
98179
name: Client Conformance (${{ matrix.lang }})
180+
# Opt-in only: WireMock matrix is expensive (java + python + rust),
181+
# so we restrict push triggering to `main` and otherwise require an
182+
# explicit workflow_dispatch with run_wiremock=true. PRs never run
183+
# this job; the behavioural suite above is the PR-blocking signal.
184+
if: >-
185+
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
186+
(github.event_name == 'workflow_dispatch' && inputs.run_wiremock)
99187
runs-on: ubuntu-latest
100188
needs: spec
101189
strategy:
@@ -169,7 +257,7 @@ jobs:
169257

170258
# ── Run contract tests ──────────────────────────────────────
171259
- name: Run contract tests
172-
run: make test-client-${{ matrix.lang }}
260+
run: make test-clients-wiremock-${{ matrix.lang }}
173261

174262
# ── Upload reports ──────────────────────────────────────────
175263
- name: Upload test reports

.github/workflows/spec.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ jobs:
5454
make gen
5555
# Contract tests (CTS) live alongside generated client code and are
5656
# wiped by `make clean` (which uses `rm -rf <module>/**`). Re-run the
57-
# CTS test generator so the working tree matches the committed sources.
58-
# Use gen-client-tests (not gen-cts) to avoid downloading the WireMock
59-
# jar, which is only needed for actually running the contract tests.
60-
make gen-client-tests
57+
# WireMock-layer test generator so the working tree matches the
58+
# committed sources. We use `gen-wiremock-tests` rather than the
59+
# full `gen-cts` target so this job stays light: we only need the
60+
# generated source files to diff against — actually *running* the
61+
# tests would also require downloading the WireMock standalone jar.
62+
make gen-wiremock-tests
6163
- name: Check no difference in codegen
6264
run: |
6365
output=$(git diff)

CONTRIBUTING.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,91 @@ You can also run `make <command>-<language>-<module>` inside a language folder t
144144
- `make gen-rust-reqwest-client`: codegen and lint the Rust reqwest client module
145145
- `make build-java-springboot-server`: build the Java Spring Boot server module
146146

147+
## Contract Tests (CTS)
148+
149+
This repository ships **two** contract test suites; together they form the
150+
Compatibility Test Suite (CTS):
151+
152+
| Suite | Source of truth | What it verifies | Driver | Default? |
153+
|---|---|---|---|---|
154+
| **Behavioural CTS** | `docs/src/cts-contracts/*.yaml` | Operation semantics: state transitions, error codes, capability gating | In-process Rust harness in `rust/lance-namespace-cts` against `DirectoryNamespace` | ✅ runs on every PR via `make test-cts` |
155+
| **WireMock CTS** | `docs/src/spec.yaml` | HTTP wire shape: paths, methods, status codes, JSON serde | Per-language thin runner (Java / Python / Rust) against a WireMock standalone jar | ⚠️ opt-in via `make test-cts-wiremock`; CI runs it on push to long-lived branches and on manual dispatch only |
156+
157+
Design background and implementation progress notes for the behavioural
158+
CTS are maintained outside this repository (in the maintainer's working
159+
notes); the entry points below describe the **stable** authoring
160+
contract and should be sufficient to add cases without consulting them.
161+
162+
### Authoring a Behavioural Contract Case
163+
164+
1. **Pick the right domain file** under `docs/src/cts-contracts/`:
165+
`namespace.yaml` / `table.yaml` / `data.yaml` / `index.yaml` /
166+
`tag.yaml` / `transaction.yaml`. Each operation's contracts must
167+
live in exactly one file (lint enforces this).
168+
2. **Write the case** in YAML using the schema in
169+
`ci/cts/cts-contracts.schema.json`. A minimal shape is:
170+
```yaml
171+
- id: drop_namespace_nonexistent_must_404
172+
description: Dropping a non-existent namespace must surface
173+
NamespaceNotFound (4).
174+
given:
175+
state: empty_catalog
176+
when:
177+
request:
178+
id: ["{{NsMissing}}"]
179+
then:
180+
error_code: 4
181+
```
182+
Capabilities the case requires (e.g. `supports_table_tags`) go in
183+
`requires_capabilities`; the harness skips the case at runtime
184+
when an implementation does not advertise them.
185+
3. **Run lint locally**:
186+
```
187+
uv run python ci/cts/lint_contracts.py --strict
188+
```
189+
`--strict` checks that every `(operation, error_code)` row in
190+
`docs/src/namespace/operations/errors.md` is covered by at least
191+
one case. CI runs lint in `--strict` mode.
192+
4. **Regenerate the Rust test sources**:
193+
```
194+
make gen-cts-behavior # rewrites rust/lance-namespace-cts/tests/contracts/
195+
```
196+
The generated files **are** checked in (so a fresh checkout
197+
`cargo test`s without manual steps); CI verifies they are not stale
198+
via `gen_contract_tests.py --check`.
199+
5. **Run the suite**:
200+
```
201+
make test-cts-behavior # cargo test -p lance-namespace-cts
202+
```
203+
`SKIP: missing capabilities: [...]` lines indicate cases that an
204+
implementation advertises it does not support — those are
205+
informational, not failures.
206+
207+
### Adding a New Operation Field or Capability
208+
209+
- **New request field referenced by a case?** Extend
210+
`_render_request_literal` in `ci/cts/gen_contract_tests.py` with
211+
the field's optionality and Rust literal shape; mirror the
212+
Mustache template `ci/cts/templates/rust_inproc_contract.mustache`
213+
if a new request struct needs importing.
214+
- **New operation?** Register it in `_SUPPORTED_OPS` (also in
215+
`gen_contract_tests.py`), add a forwarder method on
216+
`ContractCaller` and `InProcessDirectoryCaller` in
217+
`rust/lance-namespace-cts/src/caller.rs`, then write the YAML
218+
cases. Lint + `--check` will tell you what is missing.
219+
- **New capability?** Add the ID to
220+
`ci/cts/capabilities.directory.txt` (or whichever target's
221+
capability file applies) and reference it from
222+
`requires_capabilities` on cases.
223+
224+
### When to Use the Legacy WireMock Suite
225+
226+
Run `make test-cts-wiremock` (or `make test-clients-wiremock-{java,python,rust}`)
227+
when changing **client serialization** or **HTTP wire shape**. It is
228+
not a substitute for the behavioural suite for namespace
229+
implementations; behavioural contracts should always be authored
230+
under `docs/src/cts-contracts/`.
231+
147232
## Documentation
148233

149234
### Setup

Makefile

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -120,25 +120,25 @@ $(WIREMOCK_JAR):
120120
https://repo1.maven.org/maven2/org/wiremock/wiremock-standalone/$(WIREMOCK_VER)/wiremock-standalone-$(WIREMOCK_VER).jar \
121121
-o $(WIREMOCK_JAR)
122122

123-
# Generate client contract test files for all 4 clients (Rust, Python, Java Apache, Java Async).
123+
# Generate WireMock contract test files for all 4 clients (Rust, Python, Java Apache, Java Async).
124124
# Depends on gen-wiremock so the mappings directory already exists when the script runs.
125-
gen-client-tests: gen-wiremock
126-
uv run python ci/cts/gen_client_tests.py \
125+
gen-wiremock-tests: gen-wiremock
126+
uv run python ci/cts/gen_wiremock_tests.py \
127127
--mappings-dir $(WIREMOCK_MAPPINGS) \
128-
--out-rust rust/lance-namespace-reqwest-client/tests/contract.rs \
129-
--out-python python/lance_namespace_urllib3_client/tests/test_contract.py \
130-
--out-java-apache java/lance-namespace-apache-client/src/test/java/org/lance/namespace/client/apache/cts/WireMockContractIT.java \
131-
--out-java-async java/lance-namespace-async-client/src/test/java/org/lance/namespace/client/async/cts/WireMockContractIT.java
128+
--out-rust rust/lance-namespace-reqwest-client/tests/wiremock.rs \
129+
--out-python python/lance_namespace_urllib3_client/tests/test_wiremock.py \
130+
--out-java-apache java/lance-namespace-apache-client/src/test/java/org/lance/namespace/client/apache/cts/WireMockIT.java \
131+
--out-java-async java/lance-namespace-async-client/src/test/java/org/lance/namespace/client/async/cts/WireMockIT.java
132132
# Apply spotless formatting to the generated Java test files so the committed
133133
# version satisfies spotless:check on CI (which does not run apply beforehand).
134134
cd java && ./mvnw -q spotless:apply \
135135
-pl lance-namespace-apache-client,lance-namespace-async-client -am \
136136
-DskipTests || true
137137

138-
.PHONY: gen-client-tests
138+
.PHONY: gen-wiremock-tests
139139

140140
# Run all CTS generation steps
141-
gen-cts: $(AUTO_OVERLAY) $(SPEC_MERGED) gen-wiremock gen-client-tests $(WIREMOCK_JAR)
141+
gen-cts: $(AUTO_OVERLAY) $(SPEC_MERGED) gen-wiremock gen-wiremock-tests $(WIREMOCK_JAR)
142142
@echo "CTS artifacts generated in $(CTS_OUT)"
143143

144144
.PHONY: gen-cts
@@ -154,7 +154,7 @@ gen-cts: $(AUTO_OVERLAY) $(SPEC_MERGED) gen-wiremock gen-client-tests $(WIREMOCK
154154
# files into the freshly produced trees.
155155
#
156156
# Finally, run `test-compile` on the two Java client modules so the freshly
157-
# written `WireMockContractIT.java` is actually compiled (the per-module
157+
# written `WireMockIT.java` is actually compiled (the per-module
158158
# `build` targets above only ran `mvn install`, which happens *before* the
159159
# contract tests are written).
160160
build-cts: build-rust build-python build-java gen-cts
@@ -172,6 +172,7 @@ test-spec-lint: verify-spec-untouched
172172
--fail-severity error \
173173
--format junit \
174174
--output build/spectral-report.xml
175+
uv run python ci/cts/lint_contracts.py
175176

176177
.PHONY: test-spec-lint
177178

@@ -185,37 +186,73 @@ test-schemathesis: $(SPEC_MERGED)
185186

186187
.PHONY: test-schemathesis
187188

188-
# Java client contract tests
189-
test-client-java: gen-wiremock $(WIREMOCK_JAR)
189+
# Java client WireMock contract tests
190+
test-clients-wiremock-java: gen-wiremock $(WIREMOCK_JAR)
190191
cd java && ./mvnw -pl lance-namespace-apache-client,lance-namespace-async-client \
191-
test -Dtest="WireMockContractIT" \
192+
test -Dtest="WireMockIT" \
192193
-Dsurefire.failIfNoSpecifiedTests=false \
193194
--no-transfer-progress
194195

195-
.PHONY: test-client-java
196+
.PHONY: test-clients-wiremock-java
196197

197-
# Python client contract tests
198-
test-client-python: gen-wiremock $(WIREMOCK_JAR)
198+
# Python client WireMock contract tests
199+
test-clients-wiremock-python: gen-wiremock $(WIREMOCK_JAR)
199200
cd python && uv run pytest \
200-
lance_namespace_urllib3_client/tests/test_contract.py \
201+
lance_namespace_urllib3_client/tests/test_wiremock.py \
201202
-v --tb=short
202203

203-
.PHONY: test-client-python
204+
.PHONY: test-clients-wiremock-python
204205

205-
# Rust client contract tests
206-
test-client-rust: gen-wiremock $(WIREMOCK_JAR)
206+
# Rust client WireMock contract tests
207+
test-clients-wiremock-rust: gen-wiremock $(WIREMOCK_JAR)
207208
cd rust/lance-namespace-reqwest-client && \
208-
cargo test --test contract -- --nocapture
209+
cargo test --test wiremock -- --nocapture
210+
211+
.PHONY: test-clients-wiremock-rust
212+
213+
# All client WireMock tests
214+
test-clients-wiremock: test-clients-wiremock-java test-clients-wiremock-python test-clients-wiremock-rust
215+
.PHONY: test-clients-wiremock
216+
217+
# Default `make test-cts` now runs the behavioural-contract suite
218+
# (in-process Rust harness against `DirectoryNamespace`). WireMock
219+
# was historically the only client-conformance signal but covers
220+
# only HTTP wire shape, not behaviour, so it now ships as an
221+
# explicitly opt-in alias `make test-cts-wiremock` (== the existing
222+
# `test-clients-wiremock` target). CI runs both; PR-blocking is
223+
# limited to the behavioural job (see .github/workflows/contract-tests.yml).
224+
test-cts: test-spec-lint test-cts-behavior
225+
.PHONY: test-cts
226+
227+
# Opt-in WireMock alias \u2014 kept so the historical `make test-cts`
228+
# meaning is one keystroke away and so existing local muscle memory
229+
# keeps working. Equivalent to `make test-clients-wiremock`.
230+
test-cts-wiremock: test-spec-lint build-cts test-clients-wiremock
231+
.PHONY: test-cts-wiremock
232+
233+
# ============================================================
234+
# Behavioural-contract CTS (Phase 2+, see the maintainer's
235+
# behavioural-contract design notes — kept outside this repo)
236+
#
237+
# These targets drive the in-process Rust harness in
238+
# `rust/lance-namespace-cts`, which depends via cargo path on the
239+
# sibling `lance` repository's `lance-namespace-impls` crate. No
240+
# WireMock, no JVM, no Spring Boot — just `cargo test`.
241+
# ============================================================
209242

210-
.PHONY: test-client-rust
243+
# Regenerate the per-operation contract test sources from
244+
# `docs/src/cts-contracts/*.yaml`. Idempotent; safe to re-run.
245+
gen-cts-behavior:
246+
uv run python ci/cts/gen_contract_tests.py
211247

212-
# All client tests
213-
test-clients: test-client-java test-client-python test-client-rust
214-
.PHONY: test-clients
248+
.PHONY: gen-cts-behavior
215249

216-
# All CTS tests (client-side; add test-schemathesis when a server is running)
217-
test-cts: test-spec-lint build-cts test-clients
218-
.PHONY: test-cts
250+
# Run the in-process behavioural contract suite. Depends on
251+
# gen-cts-behavior so a fresh checkout works without manual steps.
252+
test-cts-behavior: gen-cts-behavior
253+
cd rust && cargo test -p lance-namespace-cts --test cts -- --nocapture
254+
255+
.PHONY: test-cts-behavior
219256

220257
# Verify spec was not modified
221258
verify-spec-untouched:

0 commit comments

Comments
 (0)