diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 12575c1be..387f924b9 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,6 +5,21 @@
version: 2
updates:
+ - package-ecosystem: "cargo"
+ directory: "/rust"
+ cooldown:
+ default-days: 7
+ groups:
+ rust-dependencies:
+ update-types:
+ - "minor"
+ - "patch"
+ schedule:
+ interval: "weekly"
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
- package-ecosystem: "gomod"
directories:
- "**/*"
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index c0d5af86e..ed4b4d223 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -410,6 +410,23 @@ jobs:
- name: Run lint
run: make lint
+ conformance_artifacts:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/setup-go@v6
+ with:
+ go-version: "1.27"
+
+ # Fails when a Go feature, migration, or protocol value changes
+ # without its conformance fixture, Rust mirror, or feature inventory
+ # classification being updated.
+ - name: Verify conformance fixtures, feature inventory, and Rust migrations
+ run: make verify/conformance verify/feature-inventory verify/rust-migrations
+
migration_and_sqlc_verify:
runs-on: ubuntu-latest
timeout-minutes: 2
diff --git a/.github/workflows/release-candidate.yaml b/.github/workflows/release-candidate.yaml
new file mode 100644
index 000000000..b99190ef9
--- /dev/null
+++ b/.github/workflows/release-candidate.yaml
@@ -0,0 +1,65 @@
+name: Release candidate
+
+# Manual gate before releasing the Rust crates: blocking performance gates and
+# a one-hour soak for Go with Rust on every supported PostgreSQL version.
+
+on:
+ workflow_dispatch:
+ inputs:
+ soak-duration:
+ default: 1h
+ description: Soak duration per job
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+env:
+ # Keep the cross-run cache small; Cargo still reuses compiled dependencies.
+ CARGO_INCREMENTAL: "0"
+
+jobs:
+ release-candidate:
+ runs-on: ubuntu-latest
+ timeout-minutes: 240
+ strategy:
+ fail-fast: false
+ matrix:
+ postgres-version: [14, 15, 16, 17, 18]
+ env:
+ RIVER_CONFORMANCE_DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_release_candidate?sslmode=disable
+ RIVER_CONFORMANCE_REQUIRED: "1"
+
+ services:
+ postgres:
+ image: postgres:${{ matrix.postgres-version }}
+ env:
+ POSTGRES_PASSWORD: postgres
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 2s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-go@v6
+ with:
+ go-version: "1.27"
+ - uses: dtolnay/rust-toolchain@stable
+ - name: Create test database
+ run: PGPASSWORD=postgres createdb -h localhost -U postgres river_release_candidate
+ - name: Go and Rust release performance
+ env:
+ RIVER_CONFORMANCE_PERFORMANCE: "1"
+ run: make test/conformance/performance
+ - name: Go and Rust soak
+ env:
+ RIVER_CONFORMANCE_SOAK_DURATION: ${{ inputs.soak-duration }}
+ # Keeps the soak's `go test` backstop inside this job's timeout. A
+ # soak that can't finish within it fails at startup.
+ CONFORMANCE_SOAK_TIMEOUT: 3h
+ run: make test/conformance/soak
diff --git a/.github/workflows/rust-soak.yaml b/.github/workflows/rust-soak.yaml
new file mode 100644
index 000000000..0867b45c9
--- /dev/null
+++ b/.github/workflows/rust-soak.yaml
@@ -0,0 +1,67 @@
+name: Rust scheduled soak
+
+on:
+ schedule:
+ - cron: "17 3 * * 0"
+ workflow_dispatch:
+ inputs:
+ duration:
+ default: 6h
+ description: Mixed Go/Rust soak duration
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+env:
+ # Keep the cross-run cache small; Cargo still reuses compiled dependencies.
+ CARGO_INCREMENTAL: "0"
+
+jobs:
+ soak:
+ runs-on: ubuntu-latest
+ timeout-minutes: 390
+ strategy:
+ fail-fast: false
+ matrix:
+ postgres-version: [14, 15, 16, 17, 18]
+ env:
+ RIVER_CONFORMANCE_DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_rust_soak?sslmode=disable
+ RIVER_CONFORMANCE_REQUIRED: "1"
+ RIVER_CONFORMANCE_SOAK_DURATION: ${{ inputs.duration || '6h' }}
+
+ services:
+ postgres:
+ image: postgres:${{ matrix.postgres-version }}
+ env:
+ POSTGRES_PASSWORD: postgres
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 2s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-go@v6
+ with:
+ go-version: "1.27"
+ - uses: dtolnay/rust-toolchain@stable
+ id: rust
+ - name: Cache Rust dependencies and build artifacts
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry/index
+ ~/.cargo/registry/cache
+ ~/.cargo/git/db
+ rust/target
+ key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }}
+ restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-
+ - name: Create test database
+ run: PGPASSWORD=postgres createdb -h localhost -U postgres river_rust_soak
+ - name: Mixed soak
+ run: make test/conformance/soak
diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml
new file mode 100644
index 000000000..fd7d513f0
--- /dev/null
+++ b/.github/workflows/rust.yaml
@@ -0,0 +1,191 @@
+name: Rust
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+
+permissions:
+ contents: read
+
+env:
+ # Keep the cross-run cache small; Cargo still reuses compiled dependencies.
+ CARGO_INCREMENTAL: "0"
+
+jobs:
+ quality:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - uses: dtolnay/rust-toolchain@stable
+ id: rust
+ with:
+ components: clippy,rustfmt
+ - name: Cache Rust dependencies and build artifacts
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry/index
+ ~/.cargo/registry/cache
+ ~/.cargo/git/db
+ rust/target
+ key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }}
+ restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-
+ - uses: taiki-e/install-action@v2
+ with:
+ tool: cargo-deny@0.20.2
+ - uses: taiki-e/install-action@v2
+ with:
+ tool: cargo-semver-checks@0.49.0
+
+ - name: Lint, including PostgreSQL-only and SQLite-only builds
+ run: make lint/rust
+
+ - name: Documentation and examples
+ run: make doc/rust
+
+ - name: Dependency and license policy
+ run: make check/rust/dependencies
+
+ - name: Package archives
+ run: make check/rust/package
+
+ # Compare with the latest published Rust release tag, which the full
+ # checkout above includes. Before the first release there is no
+ # baseline and the step reports that instead of failing.
+ - name: Public API compatibility
+ run: make check/rust/semver
+
+ # docs.rs builds with a nightly toolchain and `--cfg docsrs`, which
+ # enables the crates' `doc_cfg` feature badges. Last, since installing
+ # nightly makes it the default toolchain for later steps.
+ - uses: dtolnay/rust-toolchain@nightly
+ - name: Documentation as docs.rs builds it
+ run: make doc/rust/docsrs
+
+ msrv:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ rust-version: ["1.95", "1.96", "1.97"]
+
+ steps:
+ - uses: actions/checkout@v6
+ - uses: dtolnay/rust-toolchain@master
+ id: rust
+ with:
+ toolchain: ${{ matrix.rust-version }}
+ - name: Cache Rust dependencies and build artifacts
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry/index
+ ~/.cargo/registry/cache
+ ~/.cargo/git/db
+ rust/target
+ key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }}
+ restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-
+ - name: Check every target and feature
+ run: cargo check --manifest-path rust/Cargo.toml --workspace --all-targets --all-features --locked
+ - name: Unit, doc, and SQLite tests
+ run: make test/rust/sqlite
+
+ sqlite-conformance:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ env:
+ RIVER_CONFORMANCE_REQUIRED: "1"
+
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-go@v6
+ with:
+ go-version: "1.27"
+ - uses: dtolnay/rust-toolchain@stable
+ id: rust
+ - name: Cache Rust dependencies and build artifacts
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry/index
+ ~/.cargo/registry/cache
+ ~/.cargo/git/db
+ rust/target
+ key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }}
+ restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-
+ - name: SQLite storage and runtime conformance
+ run: make test/conformance/sqlite
+
+ postgres-conformance:
+ runs-on: ubuntu-latest
+ # Builds plus the Rust suite (~10m), mixed conformance (~5m), the
+ # ten-minute soak (up to its 20m backstop), and the advisory performance
+ # tier (~10m on PostgreSQL 18) need more than 45 minutes on a slow runner.
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ postgres-version: [14, 15, 16, 17, 18]
+ env:
+ RIVER_CONFORMANCE_DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_rust_test?sslmode=disable
+ RIVER_CONFORMANCE_REQUIRED: "1"
+ RIVER_CONFORMANCE_SOAK_DURATION: 10m
+ # Keeps the soak's `go test` backstop inside this job's timeout.
+ CONFORMANCE_SOAK_TIMEOUT: 20m
+ RIVER_RUST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_rust_test?sslmode=disable
+
+ services:
+ postgres:
+ image: postgres:${{ matrix.postgres-version }}
+ env:
+ POSTGRES_PASSWORD: postgres
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 2s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-go@v6
+ with:
+ go-version: "1.27"
+ - uses: dtolnay/rust-toolchain@stable
+ id: rust
+ - name: Cache Rust dependencies and build artifacts
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry/index
+ ~/.cargo/registry/cache
+ ~/.cargo/git/db
+ rust/target
+ key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }}
+ restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-
+ - name: Create test database
+ run: PGPASSWORD=postgres createdb -h localhost -U postgres river_rust_test
+ - name: Rust unit, SQLite, and PostgreSQL tests
+ run: make test/rust
+ - name: Mixed correctness and chaos
+ run: make test/conformance
+ - name: Insert-only profile
+ run: make test/conformance/insert-only
+ - name: Ten-minute mixed soak
+ run: make test/conformance/soak
+ # Shared runners are too noisy to gate every pull request on timing.
+ # This run reports regressions; the release candidate workflow gates.
+ - name: Release performance (advisory)
+ if: matrix.postgres-version == 18
+ continue-on-error: true
+ env:
+ RIVER_CONFORMANCE_PERFORMANCE: "1"
+ run: make test/conformance/performance
diff --git a/.gitignore b/.gitignore
index 94b880868..d09a4cac6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
/river
/riverdriver/riverdrivertest/example_libsql_test.libsql
/sqlite/
+/rust/**/target/
diff --git a/Makefile b/Makefile
index 58cb24008..d74e1bc42 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,7 @@
.DEFAULT_GOAL := help
+SQLC ?= sqlc
+
.PHONY: db/reset
db/reset: ## Drop, create, and migrate dev and test databases
db/reset: db/reset/dev
@@ -17,18 +19,33 @@ db/reset/test: ## Drop, create, and migrate test databases
.PHONY: generate
generate: ## Generate generated artifacts
+generate: generate/feature-inventory
+generate: generate/conformance
generate: generate/migrations
+generate: generate/rust-migrations
generate: generate/sqlc
+.PHONY: generate/conformance
+generate/conformance: ## Generate language-neutral protocol fixtures
+ go run ./internal/cmd/generateconformance
+
+.PHONY: generate/feature-inventory
+generate/feature-inventory: ## Refresh the cross-language feature inventory and matrix
+ go run ./internal/cmd/generatefeatureinventory
+
.PHONY: generate/migrations
generate/migrations: ## Sync changes of pgxv5 migrations to database/sql
rsync -au --delete "riverdriver/riverpgxv5/migration/" "riverdriver/riverdatabasesql/migration/"
+.PHONY: generate/rust-migrations
+generate/rust-migrations: ## Sync database migrations and hashes to Rust
+ go run ./internal/cmd/syncrustmigrations
+
.PHONY: generate/sqlc
generate/sqlc: ## Generate sqlc
- cd riverdriver/riverdatabasesql/internal/dbsqlc && sqlc generate
- cd riverdriver/riverpgxv5/internal/dbsqlc && sqlc generate
- cd riverdriver/riversqlite/internal/dbsqlc && sqlc generate
+ cd riverdriver/riverdatabasesql/internal/dbsqlc && $(SQLC) generate
+ cd riverdriver/riverpgxv5/internal/dbsqlc && $(SQLC) generate
+ cd riverdriver/riversqlite/internal/dbsqlc && $(SQLC) generate
# Looks at comments using ## on targets and uses them to produce a help output.
.PHONY: help
@@ -42,6 +59,8 @@ help: ## Print this message
submodules := $(shell go list -f '{{.Dir}}' -m)
ITERATIONS ?= 100
+RUST_BENCH_ARGS ?=
+RUST_SEMVER_BASELINE_REV ?= $(shell git tag --list 'riverqueue-v*' --sort=-v:refname | head -n 1)
TEST_DATABASE ?= all
@@ -72,6 +91,22 @@ define lint-target
endef
$(foreach mod,$(submodules),$(eval $(call lint-target,$(mod))))
+# Rust targets are separate from `lint` and `test` so Go-only contributors and
+# the Go CI jobs do not need a Rust toolchain; the Rust workflow runs them.
+.PHONY: lint/rust
+lint/rust: ## Run Rust formatting and clippy checks, including single-backend builds
+ cd rust && cargo fmt --all -- --check
+ cd rust && cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
+ cd rust && cargo clippy -p riverqueue -p riverqueue-migrate -p riverqueue-cli -p riverqueue-test --no-default-features --features postgres --all-targets --locked -- -D warnings
+ cd rust && cargo clippy -p riverqueue -p riverqueue-migrate -p riverqueue-cli -p riverqueue-test --no-default-features --features sqlite --all-targets --locked -- -D warnings
+ cd rust && $(RUST_POSTGRES_TESTS_ENV) cargo clippy -p riverqueue -p riverqueue-migrate --all-targets --all-features --locked -- -D warnings
+
+.PHONY: lint/conformance
+lint/conformance: ## Lint the opt-in shared interoperability suite
+ golangci-lint run --build-tags riverconformance ./conformance/harness
+
+lint:: lint/conformance
+
.PHONY: test
test:: ## Run tests (TEST_DATABASE=all, postgres, or sqlite)
define test-target
@@ -85,6 +120,114 @@ ifneq ($(TEST_DATABASE),sqlite)
test:: ; cd ./riverdriver/riverdrivertest && RIVER_USE_LEGACY_SUBTRANSACTIONS=1 go test . -run '^TestDriverRiverPgxV5$$/.*/WithTx$$' -timeout 2m
endif
+# `--cfg river_postgres_tests` builds the Rust PostgreSQL integration tests.
+# It goes to both rustc and rustdoc so any doctest gated on it runs too, and
+# into its own target directory so switching it on and off doesn't rebuild
+# the ordinary build's artifacts. The default is absolute: trybuild resolves a
+# relative target directory from the macros crate's directory.
+RUST_POSTGRES_TESTS_ENV = RUSTFLAGS="$$RUSTFLAGS --cfg river_postgres_tests" \
+ RUSTDOCFLAGS="$$RUSTDOCFLAGS --cfg river_postgres_tests" \
+ CARGO_TARGET_DIR="$${CARGO_TARGET_DIR:-$(CURDIR)/rust/target}/postgres-tests"
+
+# PostgreSQL integration tests need RIVER_RUST_DATABASE_URL. Without it
+# test/rust still runs unit, doc, and SQLite integration tests, and fails in CI
+# so a missing URL cannot turn the PostgreSQL suite into a silent pass.
+.PHONY: test/rust
+test/rust: ## Run Rust unit and SQLite tests, plus PostgreSQL tests when RIVER_RUST_DATABASE_URL is set
+ @if [ -n "$$RIVER_RUST_DATABASE_URL" ]; then \
+ cd rust && $(RUST_POSTGRES_TESTS_ENV) cargo test --workspace --all-features --locked; \
+ elif [ -n "$$CI" ]; then \
+ echo "RIVER_RUST_DATABASE_URL is required in CI to run the Rust PostgreSQL tests" >&2; exit 1; \
+ else \
+ echo "RIVER_RUST_DATABASE_URL is unset; skipping Rust PostgreSQL integration tests"; \
+ cd rust && cargo test --workspace --features riverqueue/sqlite,riverqueue-migrate/sqlite --locked; \
+ fi
+
+.PHONY: test/rust/postgres
+test/rust/postgres: ## Run all Rust tests, including PostgreSQL integration tests (requires RIVER_RUST_DATABASE_URL)
+ @test -n "$$RIVER_RUST_DATABASE_URL" || { echo "RIVER_RUST_DATABASE_URL is required" >&2; exit 1; }
+ cd rust && $(RUST_POSTGRES_TESTS_ENV) cargo test --workspace --all-features --locked
+
+.PHONY: test/rust/sqlite
+test/rust/sqlite: ## Run Rust unit, doc, and SQLite integration tests without a PostgreSQL database
+ cd rust && cargo test --workspace --features riverqueue/sqlite,riverqueue-migrate/sqlite --locked
+
+# `go test -timeout` backstops for the conformance targets. The harness bounds
+# each adapter request (two minutes) and exit (thirty seconds) itself, so a
+# hung adapter fails with a message naming it long before these fire. Soaks
+# check at startup that their duration plus five minutes to finish fits in
+# CONFORMANCE_SOAK_TIMEOUT, so raise it with the soak duration.
+CONFORMANCE_TIMEOUT ?= 30m
+CONFORMANCE_SOAK_TIMEOUT ?= 6h20m
+
+.PHONY: test/conformance
+test/conformance: ## Run Go and configured candidate conformance (requires database URL)
+ go test -tags riverconformance ./conformance/harness -run '^Test(Maintenance|Mixed|Resilience)Conformance$$' -count=1 -timeout $(CONFORMANCE_TIMEOUT)
+
+.PHONY: test/conformance/insert-only
+test/conformance/insert-only: ## Run the insert-only-v1 profile against the configured candidate (requires database URL)
+ go test -tags riverconformance ./conformance/harness -run '^TestInsertOnlyConformance$$' -count=1 -timeout $(CONFORMANCE_TIMEOUT)
+
+.PHONY: test/conformance/sqlite
+test/conformance/sqlite: ## Run candidate-neutral SQLite storage and runtime conformance
+ go test -tags riverconformance ./conformance/harness -run '^Test(MixedSQLite|MixedSQLiteRuntime|ResilienceSQLite)Conformance$$' -count=1 -timeout $(CONFORMANCE_TIMEOUT)
+
+.PHONY: test/conformance/performance
+test/conformance/performance: ## Run Go and configured candidate performance gates
+ go test -tags riverconformance ./conformance/harness -run '^TestPerformanceGate$$' -count=1 -timeout $(CONFORMANCE_TIMEOUT)
+
+.PHONY: test/conformance/soak
+test/conformance/soak: ## Run mixed soak for RIVER_CONFORMANCE_SOAK_DURATION
+ go test -tags riverconformance ./conformance/harness -run '^TestMixedSoak$$' -count=1 -timeout $(CONFORMANCE_SOAK_TIMEOUT)
+
+.PHONY: test/conformance/multi-engine
+test/conformance/multi-engine: ## Run direct multi-engine competition, failover, fault, and SQLite pair checks
+ go test -tags riverconformance ./conformance/harness -run '^TestMultiEngine(Conformance|SQLiteConformance)$$' -count=1 -timeout $(CONFORMANCE_TIMEOUT)
+
+.PHONY: test/conformance/multi-engine/performance
+test/conformance/multi-engine/performance: ## Compare release-built reference and candidate adapters together
+ go test -tags riverconformance ./conformance/harness -run '^TestMultiEnginePerformanceGate$$' -count=1 -timeout $(CONFORMANCE_TIMEOUT)
+
+.PHONY: test/conformance/multi-engine/soak
+test/conformance/multi-engine/soak: ## Run direct multi-engine soak
+ go test -tags riverconformance ./conformance/harness -run '^TestMultiEngineSoak$$' -count=1 -timeout $(CONFORMANCE_SOAK_TIMEOUT)
+
+.PHONY: doc/rust
+doc/rust: ## Build Rust API documentation, compiled examples, and doctests for each backend feature set
+ cd rust && RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps --locked
+ cd rust && RUSTDOCFLAGS="-D warnings" cargo test --workspace --all-features --doc --locked
+ cd rust && RUSTDOCFLAGS="-D warnings" cargo test -p riverqueue -p riverqueue-migrate -p riverqueue-cli -p riverqueue-test --no-default-features --features postgres --doc --locked
+ cd rust && RUSTDOCFLAGS="-D warnings" cargo test -p riverqueue -p riverqueue-migrate -p riverqueue-cli -p riverqueue-test --no-default-features --features sqlite --doc --locked
+ cd rust && cargo check --workspace --examples --all-features --locked
+
+.PHONY: doc/rust/docsrs
+doc/rust/docsrs: ## Build Rust API documentation as docs.rs does (nightly toolchain, `--cfg docsrs`)
+ cd rust && RUSTDOCFLAGS="--cfg docsrs -D warnings" CARGO_TARGET_DIR="$${CARGO_TARGET_DIR:-target}/docsrs" cargo +nightly doc -p riverqueue -p riverqueue-migrate -p riverqueue-test --all-features --no-deps --locked
+
+.PHONY: check/rust/dependencies
+check/rust/dependencies: ## Audit Rust advisories, licenses, bans, and sources
+ cd rust && cargo deny check
+
+.PHONY: check/rust/package
+check/rust/package: ## Build and verify publishable crate archives without publishing
+ cd rust && cargo package --workspace --exclude riverqueue-conformance --allow-dirty --locked
+
+# The baseline is the latest published riverqueue-v* tag, and
+# cargo-semver-checks infers the allowed change from the version bump. It
+# skips every lint while the workspace version is a pre-release, so
+# comparing unreleased revisions with each other checks nothing. Until a
+# Rust release is tagged the check reports that there is no baseline. Set
+# RUST_SEMVER_BASELINE_REV to compare with another revision.
+.PHONY: check/rust/semver
+check/rust/semver: ## Check Rust APIs against RUST_SEMVER_BASELINE_REV (default: latest Rust tag)
+ @if test -z "$(RUST_SEMVER_BASELINE_REV)"; then \
+ echo "No published Rust release tag (riverqueue-v*); no public API baseline to compare"; \
+ elif ! git cat-file -e "$(RUST_SEMVER_BASELINE_REV):rust/Cargo.toml" 2>/dev/null; then \
+ echo "Baseline $(RUST_SEMVER_BASELINE_REV) predates the Rust crates; no public API to compare"; \
+ else \
+ cd rust && cargo semver-checks --workspace --exclude riverqueue-conformance --baseline-rev "$(RUST_SEMVER_BASELINE_REV)"; \
+ fi
+
.PHONY: test/race
test/race:: ## Run tests with race detector (TEST_DATABASE=all, postgres, or sqlite)
define test-race-target
@@ -104,6 +247,10 @@ define bench-target
endef
$(foreach mod,$(submodules),$(eval $(call bench-target,$(mod))))
+.PHONY: bench/rust
+bench/rust: ## Run the destructive Rust PostgreSQL throughput benchmark
+ cd rust && cargo run --release --locked -p riverqueue-cli --bin riverqueue -- bench $(if $(DATABASE_URL),--database-url "$(DATABASE_URL)") $(RUST_BENCH_ARGS)
+
.PHONY: tidy
tidy:: ## Run `go mod tidy` for all submodules
define tidy-target
@@ -121,15 +268,30 @@ update-mod-version: ## Update River packages in all submodules to $VERSION
.PHONY: verify
verify: ## Verify generated artifacts
+verify: verify/conformance
+verify: verify/feature-inventory
verify: verify/migrations
+verify: verify/rust-migrations
verify: verify/sqlc
+.PHONY: verify/conformance
+verify/conformance: ## Verify language-neutral protocol fixtures
+ go run ./internal/cmd/generateconformance -check
+
+.PHONY: verify/feature-inventory
+verify/feature-inventory: ## Fail on Go features missing from the cross-language inventory
+ go run ./internal/cmd/generatefeatureinventory -check
+
.PHONY: verify/migrations
verify/migrations: ## Verify synced migrations
diff -qr riverdriver/riverpgxv5/migration riverdriver/riverdatabasesql/migration
+.PHONY: verify/rust-migrations
+verify/rust-migrations: ## Verify Rust migrations and protocol hashes
+ go run ./internal/cmd/syncrustmigrations -check
+
.PHONY: verify/sqlc
verify/sqlc: ## Verify generated sqlc
- cd riverdriver/riverdatabasesql/internal/dbsqlc && sqlc diff
- cd riverdriver/riverpgxv5/internal/dbsqlc && sqlc diff
- cd riverdriver/riversqlite/internal/dbsqlc && sqlc diff
+ cd riverdriver/riverdatabasesql/internal/dbsqlc && $(SQLC) diff
+ cd riverdriver/riverpgxv5/internal/dbsqlc && $(SQLC) diff
+ cd riverdriver/riversqlite/internal/dbsqlc && $(SQLC) diff
diff --git a/conformance/README.md b/conformance/README.md
new file mode 100644
index 000000000..27f26957c
--- /dev/null
+++ b/conformance/README.md
@@ -0,0 +1,176 @@
+# River cross-language conformance
+
+This directory describes the database protocol shared by River implementations.
+It complements language-specific unit tests; it does not make the internal Go
+`riverdriver` interface public.
+
+`manifest.json` declares matched implementation versions in an extensible map
+of package identities and registries, and enumerates protocol capabilities.
+`schema/protocol.schema.json` validates that manifest.
+`feature-matrix.md` records the backend scope decision for each area.
+Canonical migration hashes, codec goldens, declarative scenarios, and the
+process-adapter contract live alongside them.
+
+`scenarios/core.json`, `scenarios/sqlite-storage.json`, and
+`scenarios/sqlite-runtime.json` are checked against an executable Go registry.
+Every ID has exactly one owning harness test, which runs the scenario as its
+own subtest named after the ID. An ID is credited only when that subtest's
+own assertions complete, and an owner fails unless every ID it owns ran.
+Missing, stale, duplicate, mis-tiered, or merely declarative entries therefore
+fail validation.
+
+`fixtures/unique_keys.json` holds unique-key goldens generated by Go. With
+`by_args`, the key hashes the arguments exactly as the producer encoded them,
+so two implementations deduplicate the same job only when they write
+identical argument bytes: the same key order for struct fields and object
+properties, the same escaping, and the same number formatting. Adapters must
+reproduce every entry in `cases`. An entry with `expected_error` instead of
+`expected_sha256` must fail with that contract error, as Go rejects all-args
+uniqueness for arguments that aren't a JSON object (an empty array still
+hashes as `{}`). Entries in `typed_only_cases` use typed
+arguments whose byte order a producer built on dynamic objects can't write,
+such as a map with integer-like keys, which Go writes in sorted order (`"10"`
+before `"2"`) but JavaScript objects enumerate first in ascending numeric
+order. Implementations with typed serializers check those in their own tests.
+
+Database-backed and opt-in tiers skip locally when their environment is
+missing. CI sets `RIVER_CONFORMANCE_REQUIRED=1`, which turns every such skip
+into a failure, rejects `-run` patterns that exclude registered scenarios, and
+fails a run in which no conformance test executed.
+
+An implementation may claim compatibility only when its protocol revision and
+capabilities match this manifest and its implementation-local and mixed adapter
+suites pass. A capability that is not `complete` must record why in
+`capability_decisions`; `postgres-full-v1` adapters advertise exactly the
+complete capabilities.
+
+The mixed harness is candidate-neutral. It always runs Go as the reference and
+uses the checked Rust descriptor by default. Nothing in the harness names a
+candidate language: thresholds, supported profiles, optional start tuning,
+and build steps come from the candidate's descriptor. `RIVER_CONFORMANCE_CANDIDATE_FILE`
+can point it at a descriptor supplied by another repository, while
+`RIVER_CONFORMANCE_CANDIDATE` accepts the same object inline. See
+[`adapter/README.md`](adapter/README.md) for the candidate descriptor. This is
+the entry point for JavaScript and future implementations; it does not require
+copying another engine's language-specific tests.
+
+The normal artifact gate is `make verify/conformance`. The full PostgreSQL tier
+uses an externally provisioned disposable URL:
+
+```sh
+RIVER_CONFORMANCE_DATABASE_URL=postgres://localhost/river_conformance \
+ make test/conformance
+```
+
+The PostgreSQL tier also runs a resilience suite. The harness starts a second
+Go adapter and the candidate behind its own TCP proxy, so it can make the
+database unavailable to one worker (resetting its connections and refusing new
+ones) while the reference adapter keeps working, and it injects completion
+failures, row locks, and unusual rows with direct SQL. The database URL must be
+in URL form for the proxy to rewrite its address.
+
+The SQLite gate runs both the backend-neutral `portable-storage-v1` subset and
+the `sqlite-runtime-v1` worker/queue profile. It provisions an isolated
+temporary database per test, enables WAL and a five-second busy timeout in both
+adapters, and needs no database environment variable:
+
+```sh
+make test/conformance/sqlite
+```
+
+Both commands use either candidate setting when supplied. This lets a
+JavaScript adapter run the same PostgreSQL contract and SQLite profiles without
+a language-specific checklist.
+
+Performance and soak gates are explicit because they take longer:
+
+```sh
+RIVER_CONFORMANCE_DATABASE_URL=postgres://localhost/river_conformance \
+RIVER_CONFORMANCE_PERFORMANCE=1 make test/conformance/performance
+
+RIVER_CONFORMANCE_DATABASE_URL=postgres://localhost/river_conformance \
+RIVER_CONFORMANCE_SOAK_DURATION=10m make test/conformance/soak
+```
+
+The harness bounds every adapter request to two minutes and every adapter
+exit to thirty seconds, killing an adapter that overruns, so a hung adapter
+fails the scenario with a message naming it. Each make target also passes
+`go test` an explicit `-timeout` as a backstop: `CONFORMANCE_TIMEOUT` (default
+`30m`) for ordinary tiers and `CONFORMANCE_SOAK_TIMEOUT` (default `6h20m`) for
+soaks. A soak fails at startup, with a message saying so, when its duration
+plus five minutes to finish doesn't fit in the remaining timeout, so set
+`CONFORMANCE_SOAK_TIMEOUT` along with a longer soak duration.
+
+Direct multi-engine tiers start the Go reference and every configured
+candidate simultaneously against one PostgreSQL database. The ordinary
+candidate descriptor is joined by one or more peer descriptors from
+`RIVER_CONFORMANCE_PEER` (an inline descriptor object or array) or
+`RIVER_CONFORMANCE_PEER_FILE` (descriptor paths separated by the platform's
+path-list separator); the checked Rust descriptor is the default peer. At
+least two distinct candidates are required so the tier cannot degrade into a
+duplicated pairwise test. The smoke tier fills one blocked worker slot in
+every engine, moves leadership through every runtime, terminates each
+engine's database connections, runs work, notification, and cancellation
+directly between every ordered pair of candidates, and kills each candidate
+in turn so a different implementation assumes leadership and rescues the
+abandoned attempt. `TestMultiEngineSQLiteConformance` runs the SQLite
+storage and runtime checks between every pair of candidates without the
+reference:
+
+```sh
+RIVER_CONFORMANCE_DATABASE_URL=postgres://localhost/river_conformance \
+RIVER_CONFORMANCE_CANDIDATE_FILE=/path/to/javascript.json \
+ make test/conformance/multi-engine
+
+RIVER_CONFORMANCE_DATABASE_URL=postgres://localhost/river_conformance \
+RIVER_CONFORMANCE_CANDIDATE_FILE=/path/to/javascript.json \
+RIVER_CONFORMANCE_MULTI_ENGINE_PERFORMANCE=1 \
+ make test/conformance/multi-engine/performance
+
+RIVER_CONFORMANCE_DATABASE_URL=postgres://localhost/river_conformance \
+RIVER_CONFORMANCE_CANDIDATE_FILE=/path/to/javascript.json \
+RIVER_CONFORMANCE_MULTI_ENGINE_SOAK_DURATION=10m \
+ make test/conformance/multi-engine/soak
+```
+
+Some scenarios simulate what cannot be forced quickly. Leader death and
+cross-engine rescue kill a real adapter process and then expire its lease
+with `fault_expire_leader`, standing in for the lease TTL running out. The
+rolling deployment scenario replaces each engine's process in turn while both
+implementations keep inserting and working; "version skew" here means
+independently built and restarted implementations at the same protocol
+revision and migration line, not different protocol revisions, which the
+handshake rejects. Skew between released versions is exercised when an
+implementation maintained in another repository runs the suite against a
+pinned River revision. Stuck-job detection asserts only that the runtime reports the
+job stuck; what happens to the stuck attempt afterwards is
+implementation-specific.
+
+The worker and mixed release benchmarks use the same deterministic 10 ms
+timed worker in both languages. Mixed mode provisions enough worker slots to
+keep p95 focused on insertion-to-execution latency rather than incidental
+queue backlog; throughput still covers the complete concurrent pipeline.
+
+## Continuous integration
+
+- `ci.yaml` runs the harness unit tests with the Go suite and verifies the
+ generated fixtures, the feature inventory, and the Rust migration mirrors
+ (`make verify/conformance verify/feature-inventory verify/rust-migrations`).
+- `rust.yaml` lints (including PostgreSQL-only and SQLite-only builds),
+ documents, packages, and semver-checks the Rust crates, runs the unit and
+ SQLite tests on each supported Rust version, runs the SQLite tiers, and for
+ PostgreSQL 14 through 18 runs the Rust PostgreSQL tests, the mixed and
+ insert-only tiers, and a ten-minute soak. Performance runs there are
+ advisory.
+- `release-candidate.yaml` is started manually before a release and gates on
+ the Go with Rust performance tier and a one-hour soak;
+ `rust-soak.yaml` runs a six-hour soak weekly.
+
+River CI runs only Go, the Rust implementation in this repository, and the
+language-neutral artifacts. It never checks out another repository. An
+implementation maintained elsewhere, such as JavaScript, runs this harness
+from its own CI against a pinned River revision, with its own candidate
+descriptor, and adds the multi-engine tiers there, since they need at least
+two candidates.
+
+Every CI conformance job sets `RIVER_CONFORMANCE_REQUIRED=1`.
diff --git a/conformance/adapter/README.md b/conformance/adapter/README.md
new file mode 100644
index 000000000..95ad59ab3
--- /dev/null
+++ b/conformance/adapter/README.md
@@ -0,0 +1,366 @@
+# Conformance adapter protocol
+
+River implementations expose a private test adapter using JSON-RPC 2.0. Each
+request and response is one JSON object followed by a newline. Standard output
+is reserved for protocol messages; all diagnostics and library logs go to
+standard error.
+
+The harness starts each adapter with `RIVER_CONFORMANCE_DATABASE_URL`, an
+explicit `RIVER_CONFORMANCE_DATABASE_KIND` (`postgres` or `sqlite`), and, for
+SQLite, `RIVER_CONFORMANCE_PROFILE`. PostgreSQL
+uses an externally provisioned disposable database. The SQLite harness creates
+one temporary file and both adapters enable WAL, foreign keys, a five-second
+busy timeout, and a one-connection pool. Requests are sequential within an
+adapter process, while the harness may call different adapters concurrently.
+IDs and transaction-independent records returned by one implementation may be
+passed to any other implementation attached to the database.
+Job IDs are exact signed 64-bit JSON integer tokens, not JavaScript `number`
+values. Adapters must accept and emit values above `Number.MAX_SAFE_INTEGER`
+without rounding in CRUD parameters, normalized rows, list filters, or opaque
+cursors.
+
+On PostgreSQL the harness also sets `RIVER_CONFORMANCE_APPLICATION_NAME` to a
+name unique to the adapter process: the descriptor's `application_name`
+followed by a process suffix. An adapter should use it as the
+`application_name` of every PostgreSQL connection it opens, report it in the
+handshake's optional `application_name` field, and use it to find its own
+backends in `listener_count`, `connection_count`, and
+`fault_disconnect_listeners`. The harness then keys lock-wait observations and
+`fault_disconnect_application` on that name, so they never count or terminate
+the connections of another process of the same implementation, such as a
+restarted or multi-engine peer. An adapter that doesn't report the name is
+identified by its descriptor's shared `application_name` instead.
+
+A PostgreSQL URL may carry an `options` query parameter, which the adapter
+must pass to the server with its connections. `simulated_yugabyte_polling`
+starts a second pair of adapters whose URL sets `options=-c
+search_path=river_conformance_yugabyte,pg_catalog`. That schema shadows
+`version()`, `current_setting(text, boolean)`, and `pg_notify` so the server
+looks like YugabyteDB without `LISTEN`/`NOTIFY`, and River's tables live in it
+as the connections' current schema. Each implementation must detect this by
+itself, write unique jobs with a `river:unique_nonce` metadata value instead
+of relying on `xmax`, send no notifications, and, when started without
+`poll_only`, poll for cancellations of its running jobs every two seconds.
+
+The Go implementation is the reference side. By default the candidate is the
+Rust adapter described by [`candidates/rust.json`](candidates/rust.json). A
+JavaScript or future implementation can run the same suite by placing an object
+matching [`candidate.schema.json`](../schema/candidate.schema.json) in its own
+repository and setting `RIVER_CONFORMANCE_CANDIDATE_FILE` to its path:
+
+```json
+{
+ "application_name": "river-conformance-javascript",
+ "command": ["node", "dist/conformance-adapter.js"],
+ "implementation": "javascript",
+ "performance": {
+ "enqueue": { "max_p95_ratio": 3, "min_throughput_ratio": 0.25 }
+ },
+ "profiles": ["portable-storage-v1", "postgres-full-v1", "sqlite-runtime-v1"],
+ "start_options": ["elect_interval_ms", "rescuer_interval_ms", "scheduler_interval_ms"],
+ "version": "0.49.0-alpha.1"
+}
+```
+
+For one-off runs, `RIVER_CONFORMANCE_CANDIDATE` accepts the descriptor as an
+inline JSON object. Set only one of the file and inline variables. Relative
+descriptor paths and every candidate command run from the River repository
+root, so a descriptor outside this checkout should use an absolute adapter path
+or a command whose arguments select that external project. Command arguments
+may reference environment variables as `${NAME}` or `${NAME:-default}`; the
+Rust descriptor uses this to follow `CARGO_TARGET_DIR`. Unknown descriptor
+fields are rejected.
+
+- `command` starts an adapter process. `build_command`, when present, runs
+ once per test process before any adapter starts, so `command` can run the
+ built executable directly.
+- `restart_command` starts a prebuilt process for crash and restart
+ scenarios, which cannot rely on a build wrapper surviving process
+ termination. It defaults to `command`, and its executable must exist once
+ the build has run, so a stale binary in another target directory is never
+ picked up silently.
+- `release_build_command` and `release_command` replace the build and
+ commands for performance tiers.
+- `application_name` is the PostgreSQL `application_name` of the adapter's
+ connections, and the base of the per-process name the harness passes in
+ `RIVER_CONFORMANCE_APPLICATION_NAME`. It must start with
+ `river-conformance-`; fault injection only terminates connections with that
+ prefix. Keep it short enough that the per-process name stays within
+ PostgreSQL's 63 byte limit.
+- `version`, if present, must equal the handshake's implementation version.
+- `profiles` lists the profiles the adapter serves (default
+ `portable-storage-v1`, `postgres-full-v1`, and `sqlite-runtime-v1`).
+- `start_options` lists optional `start` tuning parameters the adapter
+ honors. The harness sends `elect_interval_ms`, `rescuer_interval_ms`, and
+ `scheduler_interval_ms` only to adapters that declare them and otherwise
+ waits for the implementation's defaults. Go declares none because it does
+ not expose those intervals as configuration.
+- `performance` declares the candidate's release bounds relative to the
+ reference per benchmark mode; omitted modes use the harness defaults.
+
+For PostgreSQL, the candidate must advertise the exact versioned method set in
+`contract.json`. For SQLite, it must advertise the exact capabilities and
+methods in either `profiles/sqlite.json` or `profiles/sqlite-runtime.json`, as
+selected by the profile environment variable. Missing and extra methods both
+fail before behavioral scenarios run.
+
+The SQLite `portable-storage-v1` profile intentionally reuses the same adapter
+methods and harness helpers for deterministic controls, unique keys, migrations,
+insertion, job CRUD/list cursors, raw timestamp encoding, and transactions. It
+does not claim custom schemas, queue/runtime behavior, notifications,
+leadership, PostgreSQL transaction-abort semantics, `SKIP LOCKED`, fault
+injection, performance, or soak coverage.
+
+The `sqlite-runtime-v1` profile is a tested superset. It adds cross-language
+workers, competing claims, queue CRUD and dynamic reconfiguration, pause/resume
+behavior, durable insert/control notification wakeups, remote cancellation,
+leadership and failover, scheduler and periodic work, local subscriptions,
+cross-client pause/resume subscription delivery, extensions, and graceful
+lifecycle behavior. PostgreSQL-specific schemas,
+`COPY`, `SKIP LOCKED`, backend disconnect/transaction-abort fault injection,
+reindexing, rescuer/cleaner maintenance, performance, and soak remain outside
+that profile.
+
+## Insert-only clients
+
+The `insert-only-v1` profile (`profiles/insert-only.json`) is for clients that
+only enqueue jobs, such as producer libraries in languages without a River
+worker runtime. Its methods are `handshake`, `insert`, `insert_many`,
+`tx_begin`, `tx_insert`, `tx_insert_many`, `tx_commit`, `tx_rollback`, and
+`unique_key`, served over PostgreSQL with `RIVER_CONFORMANCE_PROFILE` set to
+`insert-only-v1`. The Go reference migrates, observes, and works every job, so
+the adapter needs no migrator, reader, or runtime. `TestInsertOnlyConformance`
+compares each insert with the reference's own insert field by field, checks
+batch order and duplicate reporting, requires transactional inserts to become
+visible and notify only on commit, checks unique keys against the goldens and
+against reference inserts in both orders, and requires a candidate insert to
+wake a reference worker. A descriptor opts in by listing `insert-only-v1` in
+`profiles`; full implementations can serve it as a subset.
+
+`profiles/postgres-full.json` names the complete PostgreSQL profile: every
+method in `contract.json` and every complete manifest capability.
+
+## Params, results, and errors
+
+`contract.json` gives every method a `params` and a `result` JSON Schema
+(shared shapes live in its `$defs`, and normalized jobs and queues reference
+`../schema/normalized-job.schema.json` and
+`../schema/normalized-queue.schema.json`). The harness validates every request
+it sends and every result it receives against them, so a response with a
+missing, extra, or mistyped field fails even when no scenario inspects it.
+Adapters must reject parameters their method does not declare, including
+nested ones, with `invalid_params` instead of ignoring them.
+
+Errors use the stable JSON-RPC codes listed under `errors` in `contract.json`.
+Scenarios assert codes, never message text:
+
+| Code | Name | Meaning |
+|---|---|---|
+| -32700 | `parse_error` | The request line is not JSON. |
+| -32600 | `invalid_request` | Not a JSON-RPC 2.0 request. |
+| -32601 | `method_not_found` | The method is outside the advertised profile. |
+| -32602 | `invalid_params` | Params do not match the method schema. |
+| -32000 | `internal` | The adapter itself failed. |
+| -32001 | `not_found` | A job, queue, transaction handle, or barrier does not exist. |
+| -32002 | `rejected` | The implementation refused or could not complete the request. |
+| -32003 | `database_error` | The database reported an error. |
+| -32004 | `unsupported` | A valid optional parameter the implementation cannot honor. |
+
+The optional `start` tuning parameters `elect_interval_ms`,
+`rescuer_interval_ms`, and `scheduler_interval_ms` return `unsupported` from
+an adapter whose implementation does not expose them; the Go reference is one.
+`rescue_after_ms` is required of every runtime adapter.
+
+## Discovery and administration
+
+- `handshake`: protocol and adapter versions, implementation identity,
+ capabilities, and migration lines.
+- `migrate`, `reset`.
+- `clock_set`, `rng_seed`, and `retry_delay` evaluate the implementation's
+ production default retry policy at a fixed clock. The delay must fall within
+ the bounds in `fixtures/protocol_values.json`, which are generated from
+ River's Go retry policy. Implementations with seedable jitter use the seed;
+ the Go reference's jitter is process-random and ignores it.
+- `cron_next` takes `expression`, an RFC 3339 `from` time, and `count`, and
+ returns up to `count` successive occurrences as RFC 3339 strings. The
+ schedule is evaluated and formatted in the reference time's fixed offset,
+ whatever the host's time zone. It must accept exactly River Go's documented cron
+ syntax (robfig/cron `ParseStandard`) and reject everything else; the
+ `cron_cases` and `cron_invalid` sections of
+ `fixtures/maintenance_values.json` are the goldens.
+- `leader`, `request_resign`, `listener_count`, and `connection_count`.
+
+## Jobs and queues
+
+- `insert`, typed `insert_many`, `get`, `list`, `update`, `retry`, `cancel`,
+ `delete`, and `delete_many`. Typed batch results preserve input order and
+ include each normalized job and its unique-conflict flag.
+- `queue_get`, `queue_list`, `queue_pause`, `queue_resume`, `queue_update`, and
+ runtime `queue_add`/`queue_remove`. Like River Go, `queue_pause`,
+ `queue_resume`, and `queue_update` don't validate the queue name: a name
+ with no queue record, including one that could never be a valid queue name
+ (for example one containing a space or longer than 128 characters), returns
+ `not_found` rather than `rejected`.
+- `start`, `stop`, `wait`, and the compatibility shorthand `work`. `start`
+ also accepts optional maintenance tuning: `cancelled_job_retention_ms`,
+ `completed_job_retention_ms`, and `discarded_job_retention_ms` (`-1` keeps
+ that state forever), `job_timeout_disabled`, `rescue_after_ms`,
+ `reindexer_index_names`, and `reindexer_interval_ms`. Interval keys that
+ River Go does not expose (`elect_interval_ms`, `job_cleaner_interval_ms`,
+ `queue_cleaner_interval_ms`, `rescuer_interval_ms`,
+ `scheduler_interval_ms`) only shorten waits and may be ignored.
+- A client started by `start` or `work` uses a one-millisecond client fetch
+ cooldown, as the Go reference's does, and a queue added with `queue_add`
+ uses the client's. Like River Go, the cooldown also paces insert
+ notifications: a client sends at most one per queue per cooldown, whichever
+ insertion, transaction, or scheduler pass sends it. Requests made without a
+ running client must not have an insert notification withheld because of an
+ earlier request; the Go reference builds a new client for each.
+- `runtime_stats` exposes normalized hook, middleware, periodic, resumable,
+ stuck-job, and event-subscription observations without exposing
+ language-specific API shapes. `stuck_jobs` counts jobs the runtime reported
+ as stuck after ignoring cancellation beyond the stuck threshold. Version 1 observes delivered event kinds but does not expose
+ subscriber lag counters; adding normalized lag observations requires a
+ contract revision.
+- `barrier_create` and `barrier_release` coordinate the `barrier_wait` and
+ output-recording `barrier_output` workers without timing races.
+- `benchmark_enqueue` performs an in-process insertion workload so JSON-RPC
+ framing is not included in enqueue timings.
+
+The built-in `conformance_echo` job accepts `message`, `behavior`, and
+`duration_ms`. Behaviors cover success, retryable error, panic, worker cancel,
+discard, one-time snooze, recorded output, barrier waiting, timed work,
+cooperative remote cancellation, and intentionally ignored cancellation.
+`cooperative_cancel` waits for its job context to be cancelled and returns the
+implementation's cancellation error (Go's `context.Canceled`, Rust's
+`WorkCancelled`), while `cancel_error` and `cancel_panic` wait the same way and
+then return an ordinary error or panic, so shutdown can distinguish a
+cooperative stop from a genuine failure. The
+suite also covers a snoozed job that is immediately refetched and then
+cancelled, which exercises cancellation registration and stale-attempt cleanup
+in both directions. The last behavior is only run in a disposable adapter
+process that the harness may kill.
+
+`start` registers that worker under `conformance_echo` unless `worker_kinds`
+names other kinds. `conformance_echo_peer` is the same worker under a second
+kind. `conformance_echo_renamed` is the same worker after a safe rename from
+`conformance_echo`, which it keeps as a kind alias, as Go's
+`JobArgsWithKindAliases` does, so it also works jobs of the old kind and can't
+be registered alongside it. With `fetch_only_known_kinds`, the client claims
+only jobs of its registered kinds and their aliases, like Go's
+`Config.FetchOnlyKnownKinds`, so clients that know different kinds can share a
+queue. Scenarios make jobs of other kinds with `raw_insert_no_notify`'s `kind`,
+which doesn't check the kind against a running client, or `raw_set_kind`.
+`kind_alias_rename` and its SQLite variant have one implementation insert jobs
+of the old and the new kind and the other work both with the renamed worker,
+both without and with `fetch_only_known_kinds`, whose claim filter must then
+include the alias and leave a job of an unknown kind untouched.
+`heterogeneous_fleet_known_kinds` and its SQLite variant start a client of each
+implementation that knows only its own kind on one queue: the first runs
+alone with the other's jobs ahead of its own in claim order and must leave
+them available at attempt 0, and then each works only its own kind.
+`rescuer_unknown_kind_discard` and its SQLite variant kill a Go process
+holding a job of each kind and have each implementation in turn lead with a
+worker for one of them. Its rescuer must retry the known job on its retry
+policy and discard the other, leaving both rows as Go's does.
+
+The `resumable_cursor` behavior preserves `first_attempt`, records cursor `7`
+in its second step, and fails the second and third steps once each. The harness
+moves successive attempts between implementations and asserts that completed
+steps stay skipped and consumed cursors are cleared. `resumable_duplicate`
+repeats a step name and must fail even when the repeated step is being skipped.
+The ordinary `resumable` behavior also accepts an empty saved checkpoint and
+rejects a malformed cursor object before user work begins.
+
+## Transaction handles
+
+`tx_begin` creates a connection-local transaction under a caller-chosen
+handle. Transaction operations cover insert, typed `tx_insert_many`,
+get/list/update/delete/bulk delete, cancel/retry, and queue
+get/list/update/pause/resume. `tx_commit` and `tx_rollback` consume a
+handle. `tx_fail` deliberately aborts PostgreSQL state to verify rollback
+behavior. Handles never cross adapter processes because a database transaction
+is connection-local. Their effects are deliberately observed from the other
+language before and after commit. Transactional insert notifications are also
+commit-bound: jobs remain invisible before commit, commit wakes an opposite-
+language worker whose poll interval is 60 seconds, and rollback produces no
+wakeup.
+
+Job lists accept shared ID/kind/metadata/priority/queue/state/tag filters,
+ordering, direction, limits, and opaque `after` cursors. Responses return the
+last-row cursor so page tokens emitted by one language can be consumed by the
+other. Cursor text must match River Go's `JobListCursor.MarshalText` byte for
+byte: padded URL-safe Base64 of Go's `encoding/json` encoding of `id`, `kind`,
+`queue`, `sort_field`, and `time`, with Go's string escaping and RFC 3339 time
+with trailing fractional zeros trimmed. `job_list_cursor_interchange` and its
+SQLite and multi-engine variants compare cursor text for every sort field and
+resume each engine from the other's cursor, including a
+`raw_insert_no_notify` kind (`conformance_cursor<>&~~~`) that Go escapes and
+whose cursor text always contains `-`.
+
+`delete_finalized` runs one batch of the job cleaner's deletion outside a
+client, the way an extension's own cleaner pass reuses it, with the cleaner's
+excluded queues and an optional included list (`null` matches every queue, an
+empty list none). `job_cleaner_queue_filters` and its SQLite variant put a
+backlog of excluded or non-included jobs, larger than a batch, ahead of
+deletable ones, and require every batch to skip it, so that retained jobs
+never stall cleanup of other queues.
+
+`claim_order` and its SQLite variant check the order in which a client claims
+available jobs: by priority, then `scheduled_at`, then ID. One implementation
+inserts six due jobs whose orders by each of these differ, and the other works
+them with one worker slot after its scheduler makes them available together;
+their `attempted_at` times must follow that order.
+
+`scheduler_unique_conflict_discard` and its SQLite variant have Go prepare
+due retries of unique jobs for each implementation's leader: one whose key a
+live job holds, and two that share a key with no live job. The leader's
+scheduler must discard the first and the later of the two, marking them with
+`unique_key_conflict: scheduler_discarded`, and make the rest available, as
+Go's does.
+
+`exhausted_job_retry` and its SQLite variant have one implementation work a job
+that fails on its only attempt and one that cancels itself with attempts left,
+and the other retry both. Like Go, a retry makes each available again and
+raises `max_attempts` by one only for the job that used every attempt.
+
+`raw_set_kind` rewrites a job's kind out of band and leaves its unique key as
+stored. `unique_skip_keeps_existing_kind` and its SQLite variant use it to give
+a job inserted unique by args with `exclude_kind` a kind other than
+`conformance_echo`, then have the other implementation insert the same args
+singly and in a batch. Both insertions must be skipped as duplicates and return
+the existing job unchanged, keeping its kind rather than taking their own.
+
+## Fault injection
+
+- `raw_insert_no_notify` proves polling recovers work when notification
+ delivery is lost.
+- `raw_finalize` forces a running row to an external terminal state, with
+ `finalized_at` set to the database's current time, so the suite can prove
+ late worker completion preserves that state and error while merging worker
+ metadata and delivering the canonical worker-outcome event. A current
+ timestamp keeps leader cleaners from deleting the row mid-scenario.
+- `raw_replace_json_text` replaces one of a SQLite job's JSON columns with
+ text stored as TEXT, which need not be valid JSON, as an out-of-band change
+ could, and returns the column's previous value. The suite uses it to prove
+ a job with an invalid JSON value is failed without stalling its queue, and
+ that the value is left in place.
+- `fault_disconnect_listeners` terminates the adapter's PostgreSQL listener
+ backends and the harness waits for reconnection.
+- `fault_disconnect_application` terminates all non-caller connections for one
+ adapter application name, which must start with `river-conformance-`. The
+ harness passes the target process's own name.
+- `fault_expire_leader` forces the current lease to expire before a replacement
+ client starts.
+
+The harness may also kill a disposable adapter process. Process kill is the
+only safe way to test a worker that deliberately ignores cancellation.
+
+Normalized jobs include every persisted field. Timestamps use UTC RFC 3339,
+unique keys use lowercase hexadecimal, absent values use JSON null, and JSONB
+objects remain objects. These representations remove driver-specific byte and
+time encodings while retaining protocol-visible data.
+
+Protocol additions must be implemented by every current adapter before its
+capability is advertised. Backward-incompatible message changes require a new
+`protocol_revision` and a matched-version manifest update.
diff --git a/conformance/adapter/candidates/rust.json b/conformance/adapter/candidates/rust.json
new file mode 100644
index 000000000..55d2e7dd8
--- /dev/null
+++ b/conformance/adapter/candidates/rust.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "../../schema/candidate.schema.json",
+ "application_name": "river-conformance-rust",
+ "build_command": ["cargo", "build", "--quiet", "--locked", "--manifest-path", "rust/Cargo.toml", "-p", "riverqueue-conformance"],
+ "command": ["${CARGO_TARGET_DIR:-rust/target}/debug/riverqueue-conformance"],
+ "implementation": "rust",
+ "performance": {
+ "enqueue": { "max_p95_ratio": 2, "min_throughput_ratio": 0.4 },
+ "mixed": { "max_p95_ratio": 1.25, "min_throughput_ratio": 0.8 },
+ "worker": { "max_p95_ratio": 1.25, "min_throughput_ratio": 0.8 }
+ },
+ "profiles": ["insert-only-v1", "portable-storage-v1", "postgres-full-v1", "sqlite-runtime-v1"],
+ "release_build_command": ["cargo", "build", "--release", "--quiet", "--locked", "--manifest-path", "rust/Cargo.toml", "-p", "riverqueue-conformance"],
+ "release_command": ["${CARGO_TARGET_DIR:-rust/target}/release/riverqueue-conformance"],
+ "start_options": ["elect_interval_ms", "rescuer_interval_ms", "scheduler_interval_ms"],
+ "version": "0.49.0-alpha.1"
+}
diff --git a/conformance/adapter/contract.json b/conformance/adapter/contract.json
new file mode 100644
index 000000000..361f7e994
--- /dev/null
+++ b/conformance/adapter/contract.json
@@ -0,0 +1,2862 @@
+{
+ "$schema": "../schema/adapter-contract.schema.json",
+ "$defs": {
+ "count_result": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "minimum": 0,
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "count"
+ ]
+ },
+ "empty": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ },
+ "handle": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "insert_job": {
+ "additionalProperties": false,
+ "properties": {
+ "behavior": {
+ "description": "Built-in `conformance_echo` worker behavior; empty completes immediately.",
+ "enum": [
+ "",
+ "barrier_output",
+ "barrier_wait",
+ "cancel",
+ "cancel_error",
+ "cancel_panic",
+ "cooperative_cancel",
+ "discard",
+ "error",
+ "ignored_cancel",
+ "output",
+ "panic",
+ "resumable",
+ "resumable_cursor",
+ "resumable_duplicate",
+ "sleep",
+ "snooze_once",
+ "snooze_then_cancel",
+ "transactional_complete"
+ ]
+ },
+ "duration_ms": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "message": {
+ "type": "string"
+ },
+ "opts": {
+ "$ref": "#/$defs/insert_opts"
+ }
+ },
+ "type": "object"
+ },
+ "insert_opts": {
+ "additionalProperties": false,
+ "properties": {
+ "max_attempts": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "metadata": {
+ "type": "object"
+ },
+ "pending": {
+ "type": "boolean"
+ },
+ "priority": {
+ "type": "integer"
+ },
+ "queue": {
+ "type": "string"
+ },
+ "scheduled_at": {
+ "$ref": "#/$defs/timestamp"
+ },
+ "tags": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "unique": {
+ "additionalProperties": false,
+ "properties": {
+ "by_args": {
+ "type": "boolean"
+ },
+ "by_period_ms": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "by_queue": {
+ "type": "boolean"
+ },
+ "by_state": {
+ "items": {
+ "$ref": "#/$defs/job_state"
+ },
+ "type": "array"
+ },
+ "exclude_kind": {
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "insert_result": {
+ "additionalProperties": false,
+ "properties": {
+ "job": {
+ "$ref": "../schema/normalized-job.schema.json"
+ },
+ "unique_skipped_as_duplicate": {
+ "type": "boolean"
+ }
+ },
+ "type": "object",
+ "required": [
+ "job",
+ "unique_skipped_as_duplicate"
+ ]
+ },
+ "job": {
+ "$ref": "../schema/normalized-job.schema.json"
+ },
+ "job_id": {
+ "description": "Exact signed 64-bit job ID.",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "job_state": {
+ "enum": [
+ "available",
+ "cancelled",
+ "completed",
+ "discarded",
+ "pending",
+ "retryable",
+ "running",
+ "scheduled"
+ ]
+ },
+ "jobs_result": {
+ "additionalProperties": false,
+ "properties": {
+ "jobs": {
+ "items": {
+ "$ref": "#/$defs/job"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "jobs"
+ ]
+ },
+ "list_result": {
+ "additionalProperties": false,
+ "properties": {
+ "cursor": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "jobs": {
+ "items": {
+ "$ref": "#/$defs/job"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "cursor",
+ "jobs"
+ ]
+ },
+ "migration_result": {
+ "additionalProperties": false,
+ "properties": {
+ "applied": {
+ "items": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "existing": {
+ "items": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "valid": {
+ "type": "boolean"
+ }
+ },
+ "type": "object",
+ "required": [
+ "applied",
+ "existing",
+ "valid"
+ ]
+ },
+ "queue": {
+ "$ref": "../schema/normalized-queue.schema.json"
+ },
+ "schema_name": {
+ "description": "Custom PostgreSQL schema; empty or absent selects the default. Implementations reject invalid names.",
+ "type": "string"
+ },
+ "timestamp": {
+ "description": "RFC 3339 timestamp.",
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "adapter_version": 22,
+ "errors": [
+ {
+ "code": -32700,
+ "description": "The request line is not valid JSON.",
+ "name": "parse_error"
+ },
+ {
+ "code": -32600,
+ "description": "The request is not a JSON-RPC 2.0 request.",
+ "name": "invalid_request"
+ },
+ {
+ "code": -32601,
+ "description": "The adapter does not implement the method in its advertised profile.",
+ "name": "method_not_found"
+ },
+ {
+ "code": -32602,
+ "description": "The params do not match the method's params schema, including unknown parameters.",
+ "name": "invalid_params"
+ },
+ {
+ "code": -32000,
+ "description": "The adapter itself failed; not a River outcome.",
+ "name": "internal"
+ },
+ {
+ "code": -32001,
+ "description": "A requested job or queue does not exist, or a transaction handle or barrier is unknown.",
+ "name": "not_found"
+ },
+ {
+ "code": -32002,
+ "description": "The implementation rejected the request or could not complete it: validation, an invalid state such as an already running client, or a wait that did not reach its states.",
+ "name": "rejected"
+ },
+ {
+ "code": -32003,
+ "description": "The database reported an error, such as a statement failing in an aborted transaction.",
+ "name": "database_error"
+ },
+ {
+ "code": -32004,
+ "description": "The adapter cannot honor a valid optional parameter or feature, such as a start tuning option its implementation does not expose.",
+ "name": "unsupported"
+ }
+ ],
+ "methods": [
+ {
+ "capability": "barriers",
+ "description": "Create a named worker barrier.",
+ "name": "barrier_create",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "barriers",
+ "description": "Release a named worker barrier.",
+ "name": "barrier_release",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "insert",
+ "description": "Measure in-process insertion without RPC framing overhead.",
+ "name": "benchmark_enqueue",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "jobs": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "jobs"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "duration_ns": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "p95_ns": {
+ "minimum": 0,
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "duration_ns",
+ "p95_ns"
+ ]
+ }
+ },
+ {
+ "capability": "cancel",
+ "description": "Cancel a job outside a transaction.",
+ "name": "cancel",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "deterministic_controls",
+ "description": "Set the adapter's deterministic test clock.",
+ "name": "clock_set",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "now": {
+ "$ref": "#/$defs/timestamp"
+ }
+ },
+ "type": "object",
+ "required": [
+ "now"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "lifecycle",
+ "description": "Report application connections owned by the adapter.",
+ "name": "connection_count",
+ "params": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ },
+ "result": {
+ "$ref": "#/$defs/count_result"
+ }
+ },
+ {
+ "capability": "deterministic_controls",
+ "description": "Calculate successive occurrences of a standard cron expression from a reference time, using River Go's documented cron semantics.",
+ "name": "cron_next",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "expression": {
+ "type": "string"
+ },
+ "from": {
+ "$ref": "#/$defs/timestamp"
+ }
+ },
+ "required": [
+ "count",
+ "expression",
+ "from"
+ ],
+ "type": "object"
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "next": {
+ "items": {
+ "$ref": "#/$defs/timestamp"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "next"
+ ],
+ "type": "object"
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Delete one job.",
+ "name": "delete",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Run one batch of River's job cleaner deletion outside a client, as an extension's own cleaner pass would: delete up to `limit` cancelled, completed, and discarded jobs finalized before `before`, lowest IDs first. Jobs in `queues_excluded` are kept. When `queues_included` is present and not null, only jobs in those queues are deleted, so an empty list deletes nothing; exclusion wins over inclusion. Queue filters apply before `limit`, so retained jobs never use up a batch. Returns how many jobs were deleted.",
+ "name": "delete_finalized",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "before": {
+ "$ref": "#/$defs/timestamp"
+ },
+ "limit": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "queues_excluded": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "queues_included": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "before",
+ "limit"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "deleted": {
+ "minimum": 0,
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "deleted"
+ ]
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Delete jobs using safe filters or an explicit all flag.",
+ "name": "delete_many",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "all": {
+ "type": "boolean"
+ },
+ "ids": {
+ "items": {
+ "$ref": "#/$defs/job_id"
+ },
+ "type": "array"
+ },
+ "kinds": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "limit": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "queues": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "states": {
+ "items": {
+ "$ref": "#/$defs/job_state"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "result": {
+ "$ref": "#/$defs/jobs_result"
+ }
+ },
+ {
+ "capability": "fault_injection",
+ "description": "Disconnect allow-listed application connections.",
+ "name": "fault_disconnect_application",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "application_name": {
+ "pattern": "^river-conformance-",
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "application_name"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/count_result"
+ }
+ },
+ {
+ "capability": "fault_injection",
+ "description": "Disconnect the adapter's listener connections.",
+ "name": "fault_disconnect_listeners",
+ "params": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ },
+ "result": {
+ "$ref": "#/$defs/count_result"
+ }
+ },
+ {
+ "capability": "fault_injection",
+ "description": "Expire the current leader lease.",
+ "name": "fault_expire_leader",
+ "params": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "get",
+ "description": "Read one normalized job.",
+ "name": "get",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ },
+ "schema": {
+ "$ref": "#/$defs/schema_name"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "lifecycle",
+ "description": "Describe adapter, implementation, protocol, methods, and capabilities. A PostgreSQL adapter that honors RIVER_CONFORMANCE_APPLICATION_NAME reports the name in application_name.",
+ "name": "handshake",
+ "params": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "adapter_version": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "application_name": {
+ "pattern": "^river-conformance-",
+ "type": "string"
+ },
+ "backend": {
+ "enum": [
+ "postgres",
+ "sqlite"
+ ]
+ },
+ "capabilities": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "implementation": {
+ "pattern": "^[a-z][a-z0-9_-]*$",
+ "type": "string"
+ },
+ "implementation_version": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "methods": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "migration_lines": {
+ "additionalProperties": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "type": "object"
+ },
+ "profile": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "protocol_revision": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "adapter_version",
+ "backend",
+ "capabilities",
+ "implementation",
+ "implementation_version",
+ "methods",
+ "migration_lines",
+ "profile",
+ "protocol_revision"
+ ]
+ }
+ },
+ {
+ "capability": "insert",
+ "description": "Insert one conformance job.",
+ "name": "insert",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "behavior": {
+ "description": "Built-in `conformance_echo` worker behavior; empty completes immediately.",
+ "enum": [
+ "",
+ "barrier_output",
+ "barrier_wait",
+ "cancel",
+ "cancel_error",
+ "cancel_panic",
+ "cooperative_cancel",
+ "discard",
+ "error",
+ "ignored_cancel",
+ "output",
+ "panic",
+ "resumable",
+ "resumable_cursor",
+ "resumable_duplicate",
+ "sleep",
+ "snooze_once",
+ "snooze_then_cancel",
+ "transactional_complete"
+ ]
+ },
+ "duration_ms": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "message": {
+ "type": "string"
+ },
+ "opts": {
+ "$ref": "#/$defs/insert_opts"
+ },
+ "schema": {
+ "$ref": "#/$defs/schema_name"
+ }
+ },
+ "type": "object"
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "insert",
+ "description": "Atomically insert a typed job batch and return ordered normalized results.",
+ "name": "insert_many",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "jobs": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "behavior": {
+ "description": "Built-in `conformance_echo` worker behavior; empty completes immediately.",
+ "enum": [
+ "",
+ "barrier_output",
+ "barrier_wait",
+ "cancel",
+ "cancel_error",
+ "cancel_panic",
+ "cooperative_cancel",
+ "discard",
+ "error",
+ "ignored_cancel",
+ "output",
+ "panic",
+ "resumable",
+ "resumable_cursor",
+ "resumable_duplicate",
+ "sleep",
+ "snooze_once",
+ "snooze_then_cancel",
+ "transactional_complete"
+ ]
+ },
+ "duration_ms": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "message": {
+ "type": "string"
+ },
+ "opts": {
+ "$ref": "#/$defs/insert_opts"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "jobs"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "results": {
+ "items": {
+ "$ref": "#/$defs/insert_result"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "results"
+ ]
+ }
+ },
+ {
+ "capability": "leadership",
+ "description": "Read the active leader and election term.",
+ "name": "leader",
+ "params": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "elected_at": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "leader_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "elected_at",
+ "leader_id"
+ ]
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "List normalized jobs with filters and a portable cursor.",
+ "name": "list",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "after": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "direction": {
+ "enum": [
+ "asc",
+ "desc"
+ ]
+ },
+ "ids": {
+ "items": {
+ "$ref": "#/$defs/job_id"
+ },
+ "type": "array"
+ },
+ "kinds": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "limit": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "metadata": {
+ "type": "object"
+ },
+ "order_by": {
+ "enum": [
+ "finalized_at",
+ "id",
+ "scheduled_at",
+ "time"
+ ]
+ },
+ "priorities": {
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "queues": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "states": {
+ "items": {
+ "$ref": "#/$defs/job_state"
+ },
+ "type": "array"
+ },
+ "tags_all": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "tags_any": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "result": {
+ "$ref": "#/$defs/list_result"
+ }
+ },
+ {
+ "capability": "notifications",
+ "description": "Report active listener connections.",
+ "name": "listener_count",
+ "params": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ },
+ "result": {
+ "$ref": "#/$defs/count_result"
+ }
+ },
+ {
+ "capability": "migrate",
+ "description": "Run an up or down migration with target, step, and dry-run controls.",
+ "name": "migrate",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "direction": {
+ "enum": [
+ "down",
+ "up"
+ ]
+ },
+ "dry_run": {
+ "type": "boolean"
+ },
+ "max_steps": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "schema": {
+ "$ref": "#/$defs/schema_name"
+ },
+ "target_version": {
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "result": {
+ "$ref": "#/$defs/migration_result"
+ }
+ },
+ {
+ "capability": "queues",
+ "description": "Add or reconfigure a runtime queue.",
+ "name": "queue_add",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "max_workers": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "queues",
+ "description": "Read one normalized persisted queue.",
+ "name": "queue_get",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/queue"
+ }
+ },
+ {
+ "capability": "queues",
+ "description": "List normalized persisted queues.",
+ "name": "queue_list",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "limit": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "queues": {
+ "items": {
+ "$ref": "#/$defs/queue"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "queues"
+ ]
+ }
+ },
+ {
+ "capability": "queues",
+ "description": "Pause a persisted queue.",
+ "name": "queue_pause",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "queues",
+ "description": "Remove a runtime queue.",
+ "name": "queue_remove",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "queues",
+ "description": "Resume a persisted queue.",
+ "name": "queue_resume",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "queues",
+ "description": "Update persisted queue metadata. Without `metadata` the queue's metadata is unchanged.",
+ "name": "queue_update",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "metadata": {
+ "type": "object"
+ },
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/queue"
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Force a running job into an external terminal state for completion-race tests.",
+ "name": "raw_finalize",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ },
+ "metadata": {
+ "type": "object"
+ },
+ "state": {
+ "enum": [
+ "completed",
+ "discarded"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "state"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Insert exact JSON numeric fixtures with optional exact signed-64-bit job ID and raw metadata object text, and return that ID.",
+ "name": "raw_insert_exact_json",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ },
+ "metadata_json": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Insert a normalized full-field row for codec checks.",
+ "name": "raw_insert_full_row",
+ "params": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "notifications",
+ "description": "Insert directly without a notification.",
+ "name": "raw_insert_no_notify",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "behavior": {
+ "description": "Built-in `conformance_echo` worker behavior; empty completes immediately.",
+ "enum": [
+ "",
+ "barrier_output",
+ "barrier_wait",
+ "cancel",
+ "cancel_error",
+ "cancel_panic",
+ "cooperative_cancel",
+ "discard",
+ "error",
+ "ignored_cancel",
+ "output",
+ "panic",
+ "resumable",
+ "resumable_cursor",
+ "resumable_duplicate",
+ "sleep",
+ "snooze_once",
+ "snooze_then_cancel",
+ "transactional_complete"
+ ]
+ },
+ "duration_ms": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "message": {
+ "type": "string"
+ },
+ "opts": {
+ "$ref": "#/$defs/insert_opts"
+ },
+ "kind": {
+ "description": "Job kind; defaults to `conformance_echo`.",
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Read exact numeric tokens from job JSON through the implementation driver.",
+ "name": "raw_job_exact_json",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "big_integer": {
+ "type": "string"
+ },
+ "beyond_float": {
+ "type": "string"
+ },
+ "decimal": {
+ "type": "string"
+ },
+ "integer": {
+ "type": "string"
+ },
+ "negative": {
+ "type": "string"
+ },
+ "long_decimal": {
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "decimal",
+ "integer",
+ "negative"
+ ]
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Read a job's JSON and timestamp columns exactly as the database renders them: SQLite `json(column)` and `CAST(column AS TEXT)`, PostgreSQL `column::text`. The harness compares JSON columns as decoded values and timestamp text as written. On SQLite, `jsonb` also holds each JSONB column's stored bytes as `hex(column)`, so the harness can check that each column is stored as JSONB with the same value; it is null on PostgreSQL. `unique_key` holds the stored unique key as uppercase hex and `unique_states` the stored state mask as the database renders it as text (PostgreSQL `bit(8)` text such as `11110101`, SQLite the integer), both null when the job isn't unique. On SQLite, `unique_key_type` and `unique_states_type` hold each column's `typeof`; they are null on PostgreSQL.",
+ "name": "raw_job_row",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "args": {
+ "type": "string"
+ },
+ "attempted_at": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "attempted_by": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "errors": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "finalized_at": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "jsonb": {
+ "additionalProperties": false,
+ "properties": {
+ "args": {
+ "type": "string",
+ "pattern": "^([0-9A-F]{2})*$"
+ },
+ "attempted_by": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^([0-9A-F]{2})*$"
+ },
+ "errors": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^([0-9A-F]{2})*$"
+ },
+ "metadata": {
+ "type": "string",
+ "pattern": "^([0-9A-F]{2})*$"
+ },
+ "tags": {
+ "type": "string",
+ "pattern": "^([0-9A-F]{2})*$"
+ }
+ },
+ "type": [
+ "object",
+ "null"
+ ],
+ "required": [
+ "args",
+ "attempted_by",
+ "errors",
+ "metadata",
+ "tags"
+ ]
+ },
+ "metadata": {
+ "type": "string"
+ },
+ "scheduled_at": {
+ "type": "string"
+ },
+ "tags": {
+ "type": "string"
+ },
+ "unique_key": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^([0-9A-F]{2})*$"
+ },
+ "unique_key_type": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "unique_states": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "unique_states_type": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "args",
+ "attempted_at",
+ "attempted_by",
+ "created_at",
+ "errors",
+ "finalized_at",
+ "jsonb",
+ "metadata",
+ "scheduled_at",
+ "tags",
+ "unique_key",
+ "unique_key_type",
+ "unique_states",
+ "unique_states_type"
+ ]
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Read the database's raw persisted job timestamp representation.",
+ "name": "raw_job_timestamps",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "scheduled_at": {
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "created_at",
+ "scheduled_at"
+ ]
+ }
+ },
+ {
+ "capability": "notifications",
+ "description": "Read SQLite notification outbox rows with an ID greater than `after_id`, in ID order, exactly as stored: the `topic` text, the `payload` text, and SQLite's `typeof(payload)`. This compares the notification bytes implementations write, such as a cancellation's control payload. Only SQLite has an outbox; PostgreSQL adapters validate the params and report `unsupported`.",
+ "name": "raw_notifications",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "after_id": {
+ "minimum": 0,
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "after_id"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "notifications": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "payload": {
+ "type": "string"
+ },
+ "payload_type": {
+ "type": "string"
+ },
+ "topic": {
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "payload",
+ "payload_type",
+ "topic"
+ ]
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "notifications"
+ ]
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Replace one of a SQLite job's JSON columns with `text` stored as SQLite TEXT rather than JSONB, or with NULL when `text` is null, as an out-of-band change could. The text doesn't need to be valid JSON. Returns the column's previous value, as stored when it was TEXT and rendered with `json(column)` otherwise, and its SQLite `typeof`. PostgreSQL adapters report `unsupported`.",
+ "name": "raw_replace_json_text",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "column": {
+ "enum": [
+ "args",
+ "attempted_by",
+ "errors",
+ "metadata",
+ "tags"
+ ]
+ },
+ "id": {
+ "$ref": "#/$defs/job_id"
+ },
+ "text": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "column",
+ "id",
+ "text"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "previous": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "previous_type": {
+ "enum": [
+ "blob",
+ "integer",
+ "null",
+ "real",
+ "text"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "previous",
+ "previous_type"
+ ]
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Rewrite one job's kind out of band, leaving every other column, including `unique_key`, as stored. Unique scenarios use it to give an existing job a kind other than `conformance_echo` while it keeps its unique key.",
+ "name": "raw_set_kind",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ },
+ "kind": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "kind"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "leadership",
+ "description": "Request leader resignation.",
+ "name": "request_resign",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ }
+ },
+ "type": "object"
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "reset",
+ "description": "Truncate River runtime tables in a disposable schema.",
+ "name": "reset",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "schema": {
+ "$ref": "#/$defs/schema_name"
+ }
+ },
+ "type": "object"
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "retry",
+ "description": "Retry a job outside a transaction.",
+ "name": "retry",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "deterministic_controls",
+ "description": "Calculate a deterministic retry delay.",
+ "name": "retry_delay",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "error_count": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "job_id": {
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "error_count",
+ "job_id"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "delay_ns": {
+ "minimum": 0,
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "delay_ns"
+ ]
+ }
+ },
+ {
+ "capability": "deterministic_controls",
+ "description": "Set the adapter's deterministic random seed.",
+ "name": "rng_seed",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "seed": {
+ "description": "Unsigned 64-bit seed. Implementations whose default retry policy has no seedable jitter ignore it.",
+ "maximum": 18446744073709551615,
+ "minimum": 0,
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "seed"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "extensions",
+ "description": "Read extension, resumable, and subscription observations from a running client.",
+ "name": "runtime_stats",
+ "params": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "cancelled_at_start": {
+ "description": "Attempts of the `cooperative_cancel` behavior whose cancellation was already requested when the worker started.",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "error_handler_calls": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "events": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "periodic_starts": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "resumable_first_runs": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "resumable_second_runs": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "stuck_jobs": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "trace": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "cancelled_at_start",
+ "error_handler_calls",
+ "events",
+ "periodic_starts",
+ "resumable_first_runs",
+ "resumable_second_runs",
+ "stuck_jobs",
+ "trace"
+ ]
+ }
+ },
+ {
+ "capability": "lifecycle",
+ "description": "Start a configurable worker client.",
+ "name": "start",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "cancelled_job_retention_ms": {
+ "description": "Retention for jobs finalized in this state before the job cleaner deletes them; `-1` keeps them forever.",
+ "minimum": -1,
+ "type": "integer"
+ },
+ "claim_barrier": {
+ "description": "Name of a barrier created with `barrier_create` that holds the client's first claim that returns jobs: once that claim commits, the client keeps its jobs without starting them until the barrier is released, while it keeps receiving notifications. Stopping the client releases it, and later claims don't wait. A name that isn't a current barrier is rejected with `invalid_params`. The harness uses it to deliver a cancellation between a claim and the start of the claimed job.",
+ "minLength": 1,
+ "type": "string"
+ },
+ "client_id": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "completed_job_retention_ms": {
+ "description": "Retention for jobs finalized in this state before the job cleaner deletes them; `-1` keeps them forever.",
+ "minimum": -1,
+ "type": "integer"
+ },
+ "discarded_job_retention_ms": {
+ "description": "Retention for jobs finalized in this state before the job cleaner deletes them; `-1` keeps them forever.",
+ "minimum": -1,
+ "type": "integer"
+ },
+ "elect_interval_ms": {
+ "description": "Optional tuning: leader election interval. Adapters that cannot configure it reject the request with `unsupported`; the harness sends it only to candidates whose descriptor lists it in `start_options`.",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "error_handler_cancel": {
+ "type": "boolean"
+ },
+ "fetch_only_known_kinds": {
+ "description": "Claim only jobs of the kinds the client registers, including kind aliases, like Go's `Config.FetchOnlyKnownKinds`, leaving jobs of other kinds available without using attempts.",
+ "type": "boolean"
+ },
+ "fetch_poll_interval_ms": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "instrumented": {
+ "type": "boolean"
+ },
+ "job_cleaner_interval_ms": {
+ "description": "Optional tuning: interval between job cleaner runs. Adapters that cannot configure it may ignore it.",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "job_stuck_threshold_ms": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "job_timeout_disabled": {
+ "description": "Disable the client-wide job timeout.",
+ "type": "boolean"
+ },
+ "job_timeout_ms": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "leader_election_disabled": {
+ "description": "Keep the client out of leader election, like Go's `Config.LeaderElectionDisabled`: it works jobs but never becomes leader or runs leader-owned maintenance. Combined with `periodic_run_on_start`, the start is rejected.",
+ "type": "boolean"
+ },
+ "max_workers": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "periodic_run_on_start": {
+ "type": "boolean"
+ },
+ "periodic_unique": {
+ "description": "With `periodic_run_on_start`, insert the run-on-start periodic job (ID `conformance-periodic`) with unique options `by_args` and `by_queue`, so a later leader of any implementation skips its own run-on-start insertion as a duplicate, and register after it a second, non-unique run-on-start periodic job (ID `conformance-periodic-marker`, message `periodic marker`), whose insertion shows the unique one's was attempted. Rejected without `periodic_run_on_start`.",
+ "type": "boolean"
+ },
+ "poll_only": {
+ "type": "boolean"
+ },
+ "queue": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "queue_cleaner_interval_ms": {
+ "description": "Optional tuning: interval between queue cleaner runs. Adapters that cannot configure it may ignore it.",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "reindexer_index_names": {
+ "description": "Indexes the leader reindexes, overriding the default set.",
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "reindexer_interval_ms": {
+ "description": "Interval between reindexer runs.",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "rescue_after_ms": {
+ "description": "Duration a running job may run before the leader's rescuer treats it as abandoned.",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "rescuer_interval_ms": {
+ "description": "Optional tuning: interval between rescuer runs. Handled like `elect_interval_ms`.",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "retry_delay_ms": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "scheduler_interval_ms": {
+ "description": "Optional tuning: interval between scheduler runs and the threshold below which retries and snoozes stay available. Handled like `elect_interval_ms`.",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "schema": {
+ "$ref": "#/$defs/schema_name"
+ },
+ "worker_kinds": {
+ "description": "Kinds the client registers the built-in worker under, replacing the default `[\"conformance_echo\"]`. `conformance_echo_peer` is the same worker under a second kind. `conformance_echo_renamed` is the same worker after a safe rename from `conformance_echo`, which it keeps as a kind alias, like Go's `JobArgsWithKindAliases`: it also works jobs of kind `conformance_echo`, so it can't be combined with `conformance_echo`. A conflicting registration is rejected with `invalid_params`.",
+ "items": {
+ "enum": [
+ "conformance_echo",
+ "conformance_echo_peer",
+ "conformance_echo_renamed"
+ ]
+ },
+ "minItems": 1,
+ "type": "array",
+ "uniqueItems": true
+ }
+ },
+ "type": "object",
+ "required": [
+ "client_id"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "lifecycle",
+ "description": "Stop a running worker client gracefully or immediately.",
+ "name": "stop",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "cancel": {
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Begin a named transaction.",
+ "name": "tx_begin",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Cancel one job in a transaction.",
+ "name": "tx_cancel",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Commit and consume a named transaction.",
+ "name": "tx_commit",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Delete one job in a transaction.",
+ "name": "tx_delete",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Delete jobs with filters in a transaction.",
+ "name": "tx_delete_many",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "all": {
+ "type": "boolean"
+ },
+ "ids": {
+ "items": {
+ "$ref": "#/$defs/job_id"
+ },
+ "type": "array"
+ },
+ "kinds": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "limit": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "queues": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "states": {
+ "items": {
+ "$ref": "#/$defs/job_state"
+ },
+ "type": "array"
+ },
+ "handle": {
+ "$ref": "#/$defs/handle"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/jobs_result"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Deliberately abort PostgreSQL transaction state.",
+ "name": "tx_fail",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Read one job in a transaction.",
+ "name": "tx_get",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Insert one job in a transaction.",
+ "name": "tx_insert",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "job": {
+ "additionalProperties": false,
+ "properties": {
+ "behavior": {
+ "description": "Built-in `conformance_echo` worker behavior; empty completes immediately.",
+ "enum": [
+ "",
+ "barrier_output",
+ "barrier_wait",
+ "cancel",
+ "cancel_error",
+ "cancel_panic",
+ "cooperative_cancel",
+ "discard",
+ "error",
+ "ignored_cancel",
+ "output",
+ "panic",
+ "resumable",
+ "resumable_cursor",
+ "resumable_duplicate",
+ "sleep",
+ "snooze_once",
+ "snooze_then_cancel",
+ "transactional_complete"
+ ]
+ },
+ "duration_ms": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "message": {
+ "type": "string"
+ },
+ "opts": {
+ "$ref": "#/$defs/insert_opts"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "job"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Insert a typed job batch in a caller-managed transaction and return ordered normalized results.",
+ "name": "tx_insert_many",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "jobs": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "behavior": {
+ "description": "Built-in `conformance_echo` worker behavior; empty completes immediately.",
+ "enum": [
+ "",
+ "barrier_output",
+ "barrier_wait",
+ "cancel",
+ "cancel_error",
+ "cancel_panic",
+ "cooperative_cancel",
+ "discard",
+ "error",
+ "ignored_cancel",
+ "output",
+ "panic",
+ "resumable",
+ "resumable_cursor",
+ "resumable_duplicate",
+ "sleep",
+ "snooze_once",
+ "snooze_then_cancel",
+ "transactional_complete"
+ ]
+ },
+ "duration_ms": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "message": {
+ "type": "string"
+ },
+ "opts": {
+ "$ref": "#/$defs/insert_opts"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "jobs"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "results": {
+ "items": {
+ "$ref": "#/$defs/insert_result"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "results"
+ ]
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "List jobs in a transaction.",
+ "name": "tx_list",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "after": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "direction": {
+ "enum": [
+ "asc",
+ "desc"
+ ]
+ },
+ "ids": {
+ "items": {
+ "$ref": "#/$defs/job_id"
+ },
+ "type": "array"
+ },
+ "kinds": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "limit": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "metadata": {
+ "type": "object"
+ },
+ "order_by": {
+ "enum": [
+ "finalized_at",
+ "id",
+ "scheduled_at",
+ "time"
+ ]
+ },
+ "priorities": {
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "queues": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "states": {
+ "items": {
+ "$ref": "#/$defs/job_state"
+ },
+ "type": "array"
+ },
+ "tags_all": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "tags_any": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "handle": {
+ "$ref": "#/$defs/handle"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/list_result"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Read one persisted queue in a transaction.",
+ "name": "tx_queue_get",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "name"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/queue"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "List persisted queues in a transaction.",
+ "name": "tx_queue_list",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "limit": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "queues": {
+ "items": {
+ "$ref": "#/$defs/queue"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "queues"
+ ]
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Pause a persisted queue in a transaction.",
+ "name": "tx_queue_pause",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "name"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Resume a persisted queue in a transaction.",
+ "name": "tx_queue_resume",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "name"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Update persisted queue metadata in a transaction. Without `metadata` the queue's metadata is unchanged.",
+ "name": "tx_queue_update",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "metadata": {
+ "type": "object"
+ },
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "name"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/queue"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Retry one job in a transaction.",
+ "name": "tx_retry",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "id": {
+ "$ref": "#/$defs/job_id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Roll back and consume a named transaction.",
+ "name": "tx_rollback",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+ }
+ },
+ {
+ "capability": "transactions",
+ "description": "Update one job in a transaction.",
+ "name": "tx_update",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "handle": {
+ "$ref": "#/$defs/handle"
+ },
+ "id": {
+ "$ref": "#/$defs/job_id"
+ },
+ "output": {
+ "description": "Any JSON value recorded as metadata `output`."
+ }
+ },
+ "type": "object",
+ "required": [
+ "handle",
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "unique_jobs",
+ "description": "Calculate a language-neutral unique-key fixture and state mask.",
+ "name": "unique_key",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "args": {
+ "description": "Encoded job arguments. All-args fixtures include non-object values, which must fail with expected_error."
+ },
+ "expected_error": {
+ "description": "Fixture expectation; adapters ignore it.",
+ "enum": [
+ "rejected"
+ ]
+ },
+ "expected_sha256": {
+ "description": "Fixture expectation; adapters ignore it.",
+ "type": "string"
+ },
+ "expected_state_mask": {
+ "description": "Fixture expectation; adapters ignore it.",
+ "type": "integer"
+ },
+ "kind": {
+ "enum": [
+ "conformance_all_args",
+ "conformance_dotted_selected_args",
+ "conformance_numeric_boundaries",
+ "conformance_selected_args",
+ "conformance_simple"
+ ]
+ },
+ "name": {
+ "description": "Fixture name; adapters ignore it.",
+ "type": "string"
+ },
+ "now": {
+ "$ref": "#/$defs/timestamp"
+ },
+ "options": {
+ "additionalProperties": false,
+ "properties": {
+ "by_args": {
+ "type": "boolean"
+ },
+ "by_period_nanos": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "by_queue": {
+ "type": "boolean"
+ },
+ "by_state": {
+ "items": {
+ "$ref": "#/$defs/job_state"
+ },
+ "type": "array"
+ },
+ "exclude_kind": {
+ "type": "boolean"
+ }
+ },
+ "type": "object",
+ "required": [
+ "by_args",
+ "by_period_nanos",
+ "by_queue",
+ "exclude_kind"
+ ]
+ },
+ "queue": {
+ "type": "string"
+ },
+ "scheduled_at": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "selected_unique_components": {
+ "description": "Decoded JSON field-name paths for selected argument fixtures.",
+ "items": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "type": "array"
+ },
+ "selected_unique_paths": {
+ "description": "Fixture documentation; adapters ignore it.",
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "args",
+ "kind",
+ "now",
+ "options",
+ "queue"
+ ]
+ },
+ "result": {
+ "additionalProperties": false,
+ "properties": {
+ "sha256": {
+ "pattern": "^[0-9a-f]{64}$",
+ "type": "string"
+ },
+ "state_mask": {
+ "maximum": 255,
+ "minimum": 0,
+ "type": "integer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "sha256",
+ "state_mask"
+ ]
+ }
+ },
+ {
+ "capability": "job_crud",
+ "description": "Update one job outside a transaction.",
+ "name": "update",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ },
+ "output": {
+ "description": "Any JSON value recorded as metadata `output`."
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "work",
+ "description": "Wait for a job to reach one of the requested states.",
+ "name": "wait",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/job_id"
+ },
+ "states": {
+ "items": {
+ "$ref": "#/$defs/job_state"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ },
+ {
+ "capability": "work",
+ "description": "Run one job with a short-lived worker client.",
+ "name": "work",
+ "params": {
+ "additionalProperties": false,
+ "properties": {
+ "client_id": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "id": {
+ "$ref": "#/$defs/job_id"
+ },
+ "schema": {
+ "$ref": "#/$defs/schema_name"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id"
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/job"
+ }
+ }
+ ],
+ "protocol_revision": 1
+}
diff --git a/conformance/adapter/profiles/insert-only.json b/conformance/adapter/profiles/insert-only.json
new file mode 100644
index 000000000..28b4a9c83
--- /dev/null
+++ b/conformance/adapter/profiles/insert-only.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "../../schema/adapter-profile.schema.json",
+ "backend": "postgres",
+ "capabilities": [
+ "insert",
+ "lifecycle",
+ "transactions",
+ "unique_jobs"
+ ],
+ "description": "Insert-only clients that enqueue jobs, alone or in batches and caller-managed transactions, with Go-compatible unique keys, for other implementations to work.",
+ "methods": [
+ "handshake",
+ "insert",
+ "insert_many",
+ "tx_begin",
+ "tx_commit",
+ "tx_insert",
+ "tx_insert_many",
+ "tx_rollback",
+ "unique_key"
+ ],
+ "name": "insert-only-v1",
+ "protocol_revision": 1
+}
diff --git a/conformance/adapter/profiles/postgres-full.json b/conformance/adapter/profiles/postgres-full.json
new file mode 100644
index 000000000..42ebb39a7
--- /dev/null
+++ b/conformance/adapter/profiles/postgres-full.json
@@ -0,0 +1,104 @@
+{
+ "$schema": "../../schema/adapter-profile.schema.json",
+ "backend": "postgres",
+ "capabilities": [
+ "barriers",
+ "cancel",
+ "custom_schema",
+ "deterministic_controls",
+ "extensions",
+ "fault_injection",
+ "get",
+ "insert",
+ "job_crud",
+ "leadership",
+ "lifecycle",
+ "maintenance",
+ "migrate",
+ "notifications",
+ "periodic_jobs",
+ "poll_only",
+ "queues",
+ "reset",
+ "resumable_jobs",
+ "retry",
+ "scheduler",
+ "subscriptions",
+ "transactions",
+ "unique_jobs",
+ "work"
+ ],
+ "description": "Complete PostgreSQL compatibility: every method in contract.json and every complete manifest capability.",
+ "methods": [
+ "barrier_create",
+ "barrier_release",
+ "benchmark_enqueue",
+ "cancel",
+ "clock_set",
+ "connection_count",
+ "cron_next",
+ "delete",
+ "delete_finalized",
+ "delete_many",
+ "fault_disconnect_application",
+ "fault_disconnect_listeners",
+ "fault_expire_leader",
+ "get",
+ "handshake",
+ "insert",
+ "insert_many",
+ "leader",
+ "list",
+ "listener_count",
+ "migrate",
+ "queue_add",
+ "queue_get",
+ "queue_list",
+ "queue_pause",
+ "queue_remove",
+ "queue_resume",
+ "queue_update",
+ "raw_finalize",
+ "raw_insert_exact_json",
+ "raw_insert_full_row",
+ "raw_insert_no_notify",
+ "raw_job_exact_json",
+ "raw_job_row",
+ "raw_job_timestamps",
+ "raw_notifications",
+ "raw_replace_json_text",
+ "raw_set_kind",
+ "request_resign",
+ "reset",
+ "retry",
+ "retry_delay",
+ "rng_seed",
+ "runtime_stats",
+ "start",
+ "stop",
+ "tx_begin",
+ "tx_cancel",
+ "tx_commit",
+ "tx_delete",
+ "tx_delete_many",
+ "tx_fail",
+ "tx_get",
+ "tx_insert",
+ "tx_insert_many",
+ "tx_list",
+ "tx_queue_get",
+ "tx_queue_list",
+ "tx_queue_pause",
+ "tx_queue_resume",
+ "tx_queue_update",
+ "tx_retry",
+ "tx_rollback",
+ "tx_update",
+ "unique_key",
+ "update",
+ "wait",
+ "work"
+ ],
+ "name": "postgres-full-v1",
+ "protocol_revision": 1
+}
diff --git a/conformance/adapter/profiles/sqlite-runtime.json b/conformance/adapter/profiles/sqlite-runtime.json
new file mode 100644
index 000000000..a5e7d59ff
--- /dev/null
+++ b/conformance/adapter/profiles/sqlite-runtime.json
@@ -0,0 +1,94 @@
+{
+ "$schema": "../../schema/adapter-profile.schema.json",
+ "backend": "sqlite",
+ "capabilities": [
+ "barriers",
+ "cancel",
+ "deterministic_controls",
+ "extensions",
+ "get",
+ "insert",
+ "job_crud",
+ "leadership",
+ "lifecycle",
+ "migrate",
+ "notifications",
+ "periodic_jobs",
+ "poll_only",
+ "queues",
+ "reset",
+ "resumable_jobs",
+ "retry",
+ "scheduler",
+ "subscriptions",
+ "transactions",
+ "unique_jobs",
+ "work"
+ ],
+ "description": "SQLite runtime compatibility extending portable-storage-v1 with workers, queues, notifications, leadership, scheduling, periodic work, and lifecycle behavior.",
+ "extends": "portable-storage-v1",
+ "methods": [
+ "barrier_create",
+ "barrier_release",
+ "cancel",
+ "clock_set",
+ "cron_next",
+ "delete",
+ "delete_finalized",
+ "delete_many",
+ "get",
+ "handshake",
+ "insert",
+ "insert_many",
+ "leader",
+ "list",
+ "migrate",
+ "queue_add",
+ "queue_get",
+ "queue_list",
+ "queue_pause",
+ "queue_remove",
+ "queue_resume",
+ "queue_update",
+ "raw_finalize",
+ "raw_insert_exact_json",
+ "raw_insert_no_notify",
+ "raw_job_exact_json",
+ "raw_job_row",
+ "raw_job_timestamps",
+ "raw_notifications",
+ "raw_replace_json_text",
+ "raw_set_kind",
+ "request_resign",
+ "reset",
+ "retry",
+ "retry_delay",
+ "rng_seed",
+ "runtime_stats",
+ "start",
+ "stop",
+ "tx_begin",
+ "tx_cancel",
+ "tx_commit",
+ "tx_delete",
+ "tx_delete_many",
+ "tx_get",
+ "tx_insert",
+ "tx_insert_many",
+ "tx_list",
+ "tx_queue_get",
+ "tx_queue_list",
+ "tx_queue_pause",
+ "tx_queue_resume",
+ "tx_queue_update",
+ "tx_retry",
+ "tx_rollback",
+ "tx_update",
+ "unique_key",
+ "update",
+ "wait",
+ "work"
+ ],
+ "name": "sqlite-runtime-v1",
+ "protocol_revision": 1
+}
diff --git a/conformance/adapter/profiles/sqlite.json b/conformance/adapter/profiles/sqlite.json
new file mode 100644
index 000000000..1923c5757
--- /dev/null
+++ b/conformance/adapter/profiles/sqlite.json
@@ -0,0 +1,55 @@
+{
+ "$schema": "../../schema/adapter-profile.schema.json",
+ "backend": "sqlite",
+ "capabilities": [
+ "cancel",
+ "deterministic_controls",
+ "get",
+ "insert",
+ "job_crud",
+ "lifecycle",
+ "migrate",
+ "reset",
+ "retry",
+ "transactions",
+ "unique_jobs"
+ ],
+ "description": "Backend-neutral job storage, insertion, and transaction compatibility on SQLite.",
+ "methods": [
+ "cancel",
+ "clock_set",
+ "cron_next",
+ "delete",
+ "delete_many",
+ "get",
+ "handshake",
+ "insert",
+ "insert_many",
+ "list",
+ "migrate",
+ "raw_insert_exact_json",
+ "raw_job_exact_json",
+ "raw_job_row",
+ "raw_job_timestamps",
+ "reset",
+ "retry",
+ "retry_delay",
+ "rng_seed",
+ "tx_begin",
+ "tx_cancel",
+ "tx_commit",
+ "tx_delete",
+ "tx_delete_many",
+ "tx_get",
+ "tx_insert",
+ "tx_insert_many",
+ "tx_list",
+ "tx_retry",
+ "tx_rollback",
+ "tx_update",
+ "unique_key",
+ "update"
+ ],
+ "name": "portable-storage-v1",
+ "protocol_revision": 1
+}
diff --git a/conformance/feature-inventory.json b/conformance/feature-inventory.json
new file mode 100644
index 000000000..c51d4486e
--- /dev/null
+++ b/conformance/feature-inventory.json
@@ -0,0 +1,3975 @@
+{
+ "$schema": "schema/feature-inventory.schema.json",
+ "items": [
+ {
+ "applicability": "not_applicable",
+ "area": "client",
+ "detail": "func() riverdriver.Driver[TTx]",
+ "id": "client.Driver",
+ "rationale": "Unstable Go accessor for the internal driver seam.",
+ "source": "client.go:river.Client.Driver"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func() string",
+ "id": "client.ID",
+ "rationale": "Accessor for the configured or generated client ID (see config.ID).",
+ "source": "client.go:river.Client.ID"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, river.JobArgs, *river.InsertOpts) (*rivertype.JobInsertResult, error)",
+ "id": "client.Insert",
+ "rationale": "Each implementation provides Insert in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "candidate_insert_reference_work",
+ "reference_insert_candidate_work"
+ ],
+ "source": "client.go:river.Client.Insert"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, []river.InsertManyParams) ([]*rivertype.JobInsertResult, error)",
+ "id": "client.InsertMany",
+ "rationale": "Each implementation provides InsertMany in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "typed_batch_insertion"
+ ],
+ "source": "client.go:river.Client.InsertMany"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "client",
+ "detail": "func(context.Context, []river.InsertManyParams) (int, error)",
+ "id": "client.InsertManyFast",
+ "rationale": "Ports don't offer fast insertion yet; batches use ordinary typed insertion.",
+ "source": "client.go:river.Client.InsertManyFast"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "client",
+ "detail": "func(context.Context, TTx, []river.InsertManyParams) (int, error)",
+ "id": "client.InsertManyFastTx",
+ "rationale": "Ports don't offer fast insertion yet; batches use ordinary typed insertion.",
+ "source": "client.go:river.Client.InsertManyFastTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, []river.InsertManyParams) ([]*rivertype.JobInsertResult, error)",
+ "id": "client.InsertManyTx",
+ "rationale": "Each implementation provides InsertMany inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_batch_insertion"
+ ],
+ "source": "client.go:river.Client.InsertManyTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, river.JobArgs, *river.InsertOpts) (*rivertype.JobInsertResult, error)",
+ "id": "client.InsertTx",
+ "rationale": "Each implementation provides Insert inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transaction_commit_visibility",
+ "transaction_rollback_visibility"
+ ],
+ "source": "client.go:river.Client.InsertTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, int64) (*rivertype.JobRow, error)",
+ "id": "client.JobCancel",
+ "rationale": "Each implementation provides JobCancel in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "cross_language_cancel_retry_race",
+ "differential_job_crud",
+ "remote_cancel_notification"
+ ],
+ "source": "client.go:river.Client.JobCancel"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, int64) (*rivertype.JobRow, error)",
+ "id": "client.JobCancelTx",
+ "rationale": "Each implementation provides JobCancel inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_cross_language_cancel"
+ ],
+ "source": "client.go:river.Client.JobCancelTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, int64) (*rivertype.JobRow, error)",
+ "id": "client.JobDelete",
+ "rationale": "Each implementation provides JobDelete in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "differential_job_crud"
+ ],
+ "source": "client.go:river.Client.JobDelete"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, *river.JobDeleteManyParams) (*river.JobDeleteManyResult, error)",
+ "id": "client.JobDeleteMany",
+ "rationale": "Each implementation provides JobDeleteMany in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "bulk_delete_safety"
+ ],
+ "source": "client.go:river.Client.JobDeleteMany"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, *river.JobDeleteManyParams) (*river.JobDeleteManyResult, error)",
+ "id": "client.JobDeleteManyTx",
+ "rationale": "Each implementation provides JobDeleteMany inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_crud_commit_rollback"
+ ],
+ "source": "client.go:river.Client.JobDeleteManyTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, int64) (*rivertype.JobRow, error)",
+ "id": "client.JobDeleteTx",
+ "rationale": "Each implementation provides JobDelete inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_crud_commit_rollback"
+ ],
+ "source": "client.go:river.Client.JobDeleteTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, int64) (*rivertype.JobRow, error)",
+ "id": "client.JobGet",
+ "rationale": "Each implementation provides JobGet in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "differential_job_crud"
+ ],
+ "source": "client.go:river.Client.JobGet"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, int64) (*rivertype.JobRow, error)",
+ "id": "client.JobGetTx",
+ "rationale": "Each implementation provides JobGet inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_crud_commit_rollback"
+ ],
+ "source": "client.go:river.Client.JobGetTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, *river.JobListParams) (*river.JobListResult, error)",
+ "id": "client.JobList",
+ "rationale": "Each implementation provides JobList in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "client.go:river.Client.JobList"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, *river.JobListParams) (*river.JobListResult, error)",
+ "id": "client.JobListTx",
+ "rationale": "Each implementation provides JobList inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_crud_commit_rollback"
+ ],
+ "source": "client.go:river.Client.JobListTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, int64) (*rivertype.JobRow, error)",
+ "id": "client.JobRetry",
+ "rationale": "Each implementation provides JobRetry in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "cross_language_cancel_retry_race",
+ "differential_job_crud",
+ "exhausted_job_retry",
+ "sqlite_runtime_exhausted_job_retry"
+ ],
+ "source": "client.go:river.Client.JobRetry"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, int64) (*rivertype.JobRow, error)",
+ "id": "client.JobRetryTx",
+ "rationale": "Each implementation provides JobRetry inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_crud_commit_rollback"
+ ],
+ "source": "client.go:river.Client.JobRetryTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, int64, *river.JobUpdateParams) (*rivertype.JobRow, error)",
+ "id": "client.JobUpdate",
+ "rationale": "Each implementation provides JobUpdate in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "differential_job_crud"
+ ],
+ "source": "client.go:river.Client.JobUpdate"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, int64, *river.JobUpdateParams) (*rivertype.JobRow, error)",
+ "id": "client.JobUpdateTx",
+ "rationale": "Each implementation provides JobUpdate inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_crud_commit_rollback"
+ ],
+ "source": "client.go:river.Client.JobUpdateTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func() *river.ClientNotifyBundle[TTx]",
+ "id": "client.Notify",
+ "rationale": "Go bundle for sending control notifications such as a leader resignation request; the adapter's request_resign method uses it.",
+ "scenarios": [
+ "mixed_request_resign_terms"
+ ],
+ "source": "client.go:river.Client.Notify"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func() *river.PeriodicJobBundle",
+ "id": "client.PeriodicJobs",
+ "rationale": "Language-native API to add or remove periodic jobs at runtime; the enqueue behavior itself is classified under config.PeriodicJobs.",
+ "source": "client.go:river.Client.PeriodicJobs"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "client",
+ "detail": "func() riverpilot.Pilot",
+ "id": "client.Pilot",
+ "rationale": "Unstable Go accessor for the extension seam.",
+ "source": "client.go:river.Client.Pilot"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, string) (*rivertype.Queue, error)",
+ "id": "client.QueueGet",
+ "rationale": "Each implementation provides QueueGet in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "differential_queue_crud"
+ ],
+ "source": "client.go:river.Client.QueueGet"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, string) (*rivertype.Queue, error)",
+ "id": "client.QueueGetTx",
+ "rationale": "Each implementation provides QueueGet inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_queue_operations"
+ ],
+ "source": "client.go:river.Client.QueueGetTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, *river.QueueListParams) (*river.QueueListResult, error)",
+ "id": "client.QueueList",
+ "rationale": "Each implementation provides QueueList in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "differential_queue_crud"
+ ],
+ "source": "client.go:river.Client.QueueList"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, *river.QueueListParams) (*river.QueueListResult, error)",
+ "id": "client.QueueListTx",
+ "rationale": "Each implementation provides QueueList inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_queue_operations"
+ ],
+ "source": "client.go:river.Client.QueueListTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, string, *river.QueuePauseOpts) error",
+ "id": "client.QueuePause",
+ "rationale": "Each implementation provides QueuePause in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "pause_resume_notification"
+ ],
+ "source": "client.go:river.Client.QueuePause"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, string, *river.QueuePauseOpts) error",
+ "id": "client.QueuePauseTx",
+ "rationale": "Each implementation provides QueuePause inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_queue_operations"
+ ],
+ "source": "client.go:river.Client.QueuePauseTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, string, *river.QueuePauseOpts) error",
+ "id": "client.QueueResume",
+ "rationale": "Each implementation provides QueueResume in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "pause_resume_notification"
+ ],
+ "source": "client.go:river.Client.QueueResume"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, string, *river.QueuePauseOpts) error",
+ "id": "client.QueueResumeTx",
+ "rationale": "Each implementation provides QueueResume inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_queue_operations"
+ ],
+ "source": "client.go:river.Client.QueueResumeTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, string, *river.QueueUpdateParams) (*rivertype.Queue, error)",
+ "id": "client.QueueUpdate",
+ "rationale": "Each implementation provides QueueUpdate in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "differential_queue_crud"
+ ],
+ "source": "client.go:river.Client.QueueUpdate"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context, TTx, string, *river.QueueUpdateParams) (*rivertype.Queue, error)",
+ "id": "client.QueueUpdateTx",
+ "rationale": "Each implementation provides QueueUpdate inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method.",
+ "scenarios": [
+ "transactional_queue_operations"
+ ],
+ "source": "client.go:river.Client.QueueUpdateTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func() *river.QueueBundle",
+ "id": "client.Queues",
+ "rationale": "Language-native API to add, reconfigure, and remove worked queues at runtime; the adapter's queue_add/queue_remove use it.",
+ "scenarios": [
+ "dynamic_queue_add_reconfigure_remove"
+ ],
+ "source": "client.go:river.Client.Queues"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func() string",
+ "id": "client.Schema",
+ "rationale": "Accessor for the configured schema (see config.Schema).",
+ "source": "client.go:river.Client.Schema"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context) error",
+ "id": "client.Start",
+ "rationale": "Language-native client start.",
+ "scenarios": [
+ "sqlite_runtime_lifecycle_shutdown"
+ ],
+ "source": "client.go:river.Client.Start"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context) error",
+ "id": "client.Stop",
+ "rationale": "Language-native graceful stop that lets running jobs finish.",
+ "scenarios": [
+ "sqlite_runtime_lifecycle_shutdown"
+ ],
+ "source": "client.go:river.Client.Stop"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(context.Context) error",
+ "id": "client.StopAndCancel",
+ "rationale": "Language-native hard stop that cancels running jobs; a job still ignoring cancellation after the stuck threshold is aborted and its attempt fails. The adapter's stop with cancel uses it.",
+ "scenarios": [
+ "ignored_cancellation_hard_abort"
+ ],
+ "source": "client.go:river.Client.StopAndCancel"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func() <-chan struct {}",
+ "id": "client.Stopped",
+ "rationale": "Go channel closed when the client has fully stopped; other languages signal completion in their own idiom.",
+ "source": "client.go:river.Client.Stopped"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "client",
+ "detail": "func(...river.EventKind) (<-chan *river.Event, func())",
+ "id": "client.Subscribe",
+ "rationale": "Language-native local event subscription; the adapter reports observed events via runtime_stats.",
+ "scenarios": [
+ "remote_queue_subscription_events",
+ "sqlite_runtime_extensions_resumable_subscriptions"
+ ],
+ "source": "client.go:river.Client.Subscribe"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "client",
+ "detail": "func(*river.SubscribeConfig) (<-chan *river.Event, func())",
+ "id": "client.SubscribeConfig",
+ "rationale": "Go-specific variant of Subscribe that overrides the channel buffer size.",
+ "source": "client.go:river.Client.SubscribeConfig"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "config",
+ "detail": "int32",
+ "id": "config.AdvisoryLockPrefix",
+ "rationale": "Copied into the periodic job enqueuer's configuration but not used to derive any lock key in this version; it has no persisted or cross-process effect to match.",
+ "source": "client.go:river.Config.AdvisoryLockPrefix"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.CancelledJobRetentionPeriod",
+ "rationale": "The job cleaner deletes cancelled rows after this period; deletion is visible to every implementation sharing the database.",
+ "scenarios": [
+ "maintenance_job_cleaner_retention"
+ ],
+ "source": "client.go:river.Config.CancelledJobRetentionPeriod"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.CompletedJobRetentionPeriod",
+ "rationale": "The job cleaner deletes completed rows after this period; deletion is visible to every implementation sharing the database.",
+ "scenarios": [
+ "maintenance_job_cleaner_retention"
+ ],
+ "source": "client.go:river.Config.CompletedJobRetentionPeriod"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.DiscardedJobRetentionPeriod",
+ "rationale": "The job cleaner deletes discarded rows after this period; deletion is visible to every implementation sharing the database.",
+ "scenarios": [
+ "maintenance_job_cleaner_retention"
+ ],
+ "source": "client.go:river.Config.DiscardedJobRetentionPeriod"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "river.ErrorHandler",
+ "id": "config.ErrorHandler",
+ "rationale": "Language-native error/panic callback. Its persisted effect (overriding the outcome, e.g. cancel) is exercised through the adapter's error_handler_cancel start option.",
+ "scenarios": [
+ "error_handler_cancel_override"
+ ],
+ "source": "client.go:river.Config.ErrorHandler"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.FetchCooldown",
+ "rationale": "Per-client minimum interval between fetches (a throughput throttle), which also suppresses a client's repeated insert notification for a queue within the interval on every backend. Implementations expose an equivalent client-level knob with the same default and minimum. Rows are unaffected; the reference adapter's 1 ms setting keeps notification scenarios deterministic.",
+ "source": "client.go:river.Config.FetchCooldown"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "bool",
+ "id": "config.FetchOnlyKnownKinds",
+ "rationale": "Restricts a client's claims to the kinds of its registered workers, including aliases, so clients that know different kinds can share a queue and jobs of other kinds stay available without using attempts.",
+ "scenarios": [
+ "heterogeneous_fleet_known_kinds",
+ "kind_alias_rename",
+ "sqlite_runtime_heterogeneous_fleet_known_kinds",
+ "sqlite_runtime_kind_alias_rename"
+ ],
+ "source": "client.go:river.Config.FetchOnlyKnownKinds"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.FetchPollInterval",
+ "rationale": "Per-process polling fallback interval. The adapter's fetch_poll_interval_ms option exercises both the polling fallback and notification-only wakeups with polling effectively disabled.",
+ "scenarios": [
+ "lost_notification_poll_recovery",
+ "notification_only_wakeups"
+ ],
+ "source": "client.go:river.Config.FetchPollInterval"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "[]rivertype.Hook",
+ "id": "config.Hooks",
+ "rationale": "Registration of global hooks in each language's idiom. Hook ordering semantics are exercised through plugin registration in extension_hook_middleware_order.",
+ "source": "client.go:river.Config.Hooks"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "string",
+ "id": "config.ID",
+ "rationale": "Persisted in attempted_by and used as leader_id; scenarios assert attempted_by client IDs across implementations.",
+ "scenarios": [
+ "process_kill_restart_and_rescue",
+ "sqlite_runtime_attempted_by_ordering"
+ ],
+ "source": "client.go:river.Config.ID"
+ },
+ {
+ "applicability": "internal",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.JobCleanerTimeout",
+ "rationale": "Timeout for individual job cleaner queries; bounds local work only and changes no persisted outcome.",
+ "source": "client.go:river.Config.JobCleanerTimeout"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "config",
+ "detail": "[]rivertype.JobInsertMiddleware",
+ "id": "config.JobInsertMiddleware",
+ "rationale": "Deprecated Go field superseded by Plugins. The insert-middleware concept is classified under extension.rivertype.JobInsertMiddleware.",
+ "source": "client.go:river.Config.JobInsertMiddleware"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "river.JobStuckHandler",
+ "id": "config.JobStuckHandler",
+ "rationale": "Language-native callback invoked when a timed-out job does not return; lets the client open a replacement worker slot.",
+ "scenarios": [
+ "stuck_job_detection"
+ ],
+ "source": "client.go:river.Config.JobStuckHandler"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.JobStuckThreshold",
+ "rationale": "In-process grace period after JobTimeout before a job is treated as stuck and its slot replaced. Observable only as extra concurrency, not in persisted rows.",
+ "scenarios": [
+ "stuck_job_detection"
+ ],
+ "source": "client.go:river.Config.JobStuckThreshold"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.JobTimeout",
+ "rationale": "Timed-out attempts are cancelled and recorded as errors with retry scheduling, which other implementations observe.",
+ "scenarios": [
+ "timeout_cancellation"
+ ],
+ "source": "client.go:river.Config.JobTimeout"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "bool",
+ "id": "config.LeaderElectionDisabled",
+ "rationale": "A client kept out of leader election never writes river_leader or runs leader-owned maintenance while it works jobs alongside eligible clients of any implementation, and rejects periodic jobs.",
+ "scenarios": [
+ "leader_election_disabled_both_directions",
+ "multi_engine_leader_election_disabled",
+ "sqlite_runtime_leader_election_disabled"
+ ],
+ "source": "client.go:river.Config.LeaderElectionDisabled"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "*slog.Logger",
+ "id": "config.Logger",
+ "rationale": "Each implementation uses its own logging facility.",
+ "source": "client.go:river.Config.Logger"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "int",
+ "id": "config.MaxAttempts",
+ "rationale": "Client-wide default for inserted rows' max_attempts. Its value (25) is persisted in every row inserted without an override, which any implementation may then work, so it must match. The per-insert value is classified as insert_opts.MaxAttempts.",
+ "scenarios": [
+ "candidate_insert_reference_work",
+ "reference_insert_candidate_work"
+ ],
+ "source": "client.go:river.Config.MaxAttempts"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "[]rivertype.Middleware",
+ "id": "config.Middleware",
+ "rationale": "Registration of global middleware in each language's idiom. Middleware ordering semantics are exercised through plugin registration in extension_hook_middleware_order.",
+ "source": "client.go:river.Config.Middleware"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "[]*river.PeriodicJob",
+ "id": "config.PeriodicJobs",
+ "rationale": "Only the elected leader enqueues periodic jobs, tagging them with reserved metadata; duplicate or missing enqueues are visible across implementations.",
+ "scenarios": [
+ "mixed_leader_death_failover_both_directions",
+ "periodic_due_job_available",
+ "periodic_run_on_start",
+ "sqlite_runtime_periodic_scheduler"
+ ],
+ "source": "client.go:river.Config.PeriodicJobs"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "[]rivertype.Plugin",
+ "id": "config.Plugins",
+ "rationale": "Language-native plugin registration; the adapter's instrumented option installs a plugin and the scenario checks hook and middleware ordering.",
+ "scenarios": [
+ "extension_hook_middleware_order"
+ ],
+ "source": "client.go:river.Config.Plugins"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "bool",
+ "id": "config.PollOnly",
+ "rationale": "Disables LISTEN in favor of polling. Implementations provide an equivalent notification-free mode, which clients also enter on their own on a PostgreSQL server without LISTEN/NOTIFY, like YugabyteDB by default.",
+ "scenarios": [
+ "poll_only_remote_cancellation",
+ "simulated_yugabyte_polling",
+ "sqlite_runtime_poll_only_recovery"
+ ],
+ "source": "client.go:river.Config.PollOnly"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "map[string]river.QueueConfig",
+ "id": "config.Queues",
+ "rationale": "Queues a client works are persisted as river_queue rows and determine which jobs it fetches.",
+ "scenarios": [
+ "differential_queue_crud",
+ "dynamic_queue_add_reconfigure_remove"
+ ],
+ "source": "client.go:river.Config.Queues"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "[]string",
+ "id": "config.ReindexerIndexNames",
+ "rationale": "Determines which River indexes the leader reindexes.",
+ "scenarios": [
+ "maintenance_reindexer_skips_artifacts"
+ ],
+ "source": "client.go:river.Config.ReindexerIndexNames"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "river.PeriodicSchedule",
+ "id": "config.ReindexerSchedule",
+ "rationale": "Determines when the leader reindexes River indexes (midnight UTC by default).",
+ "scenarios": [
+ "maintenance_reindexer_skips_artifacts"
+ ],
+ "source": "client.go:river.Config.ReindexerSchedule"
+ },
+ {
+ "applicability": "internal",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.ReindexerTimeout",
+ "rationale": "Per-reindex operation timeout; bounds local work only.",
+ "source": "client.go:river.Config.ReindexerTimeout"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.RescueStuckJobsAfter",
+ "rationale": "Running jobs older than this are rescued by the leader, incrementing river:rescue_count and retrying or discarding them; a leader discards jobs of kinds it has no worker for.",
+ "scenarios": [
+ "candidate_process_kill_reference_rescue",
+ "process_kill_restart_and_rescue",
+ "reference_process_kill_candidate_rescue",
+ "rescuer_unknown_kind_discard",
+ "sqlite_runtime_rescuer_unknown_kind_discard"
+ ],
+ "source": "client.go:river.Config.RescueStuckJobsAfter"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "river.ClientRetryPolicy",
+ "id": "config.RetryPolicy",
+ "rationale": "Determines scheduled_at for retryable jobs, which is persisted and observed by every implementation.",
+ "scenarios": [
+ "default_retry_policy_schedule",
+ "deterministic_retry_clock_rng"
+ ],
+ "source": "client.go:river.Config.RetryPolicy"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "config",
+ "detail": "string",
+ "id": "config.Schema",
+ "rationale": "Custom schemas qualify every table and notification topic.",
+ "scenarios": [
+ "custom_schema_candidate_migrate_reference_work",
+ "custom_schema_reference_migrate_candidate_work"
+ ],
+ "source": "client.go:river.Config.Schema"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "bool",
+ "id": "config.SkipJobKindValidation",
+ "rationale": "Deprecated escape hatch that skips kind-format validation at insert time; implementations may offer an equivalent legacy-kind option.",
+ "source": "client.go:river.Config.SkipJobKindValidation"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "bool",
+ "id": "config.SkipUnknownJobCheck",
+ "rationale": "Insert-time validation local to the inserting client: it only decides whether that client refuses kinds it has no worker for. The rows it lets through are ordinary jobs, and how a worker treats a kind it doesn't know is covered by mixed_unknown_kind_error.",
+ "scenarios": [
+ "mixed_unknown_kind_error"
+ ],
+ "source": "client.go:river.Config.SkipUnknownJobCheck"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "time.Duration",
+ "id": "config.SoftStopTimeout",
+ "rationale": "Local graceful-stop deadline before escalating to cancellation; each implementation offers an equivalent shutdown control. Only when the escalation happens is local; what it persists is a hard stop's outcome, which the cited scenario covers.",
+ "scenarios": [
+ "hard_shutdown_soft_stop_classification"
+ ],
+ "source": "client.go:river.Config.SoftStopTimeout"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "config",
+ "detail": "river.TestConfig",
+ "id": "config.Test",
+ "rationale": "Go test-environment settings (time generator, unique enforcement toggle). Conformance drives time through the adapter's clock_set instead.",
+ "source": "client.go:river.Config.Test"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "config",
+ "detail": "bool",
+ "id": "config.TestOnly",
+ "rationale": "Go test-suite switch that removes startup jitter; not part of any production behavior.",
+ "source": "client.go:river.Config.TestOnly"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "config",
+ "detail": "[]rivertype.WorkerMiddleware",
+ "id": "config.WorkerMiddleware",
+ "rationale": "Deprecated Go field superseded by Plugins. The worker-middleware concept is classified under extension.rivertype.WorkerMiddleware.",
+ "source": "client.go:river.Config.WorkerMiddleware"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "config",
+ "detail": "*river.Workers",
+ "id": "config.Workers",
+ "rationale": "Language-native worker registry mapping kinds to handlers.",
+ "source": "client.go:river.Config.Workers"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func() string",
+ "id": "driver.Driver.ArgPlaceholder",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.ArgPlaceholder"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func() string",
+ "id": "driver.Driver.DatabaseName",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.DatabaseName"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func() Executor",
+ "id": "driver.Driver.GetExecutor",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.GetExecutor"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(params *GetListenenerParams) Listener",
+ "id": "driver.Driver.GetListener",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.GetListener"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func() []string",
+ "id": "driver.Driver.GetMigrationDefaultLines",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.GetMigrationDefaultLines"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(line string) fs.FS",
+ "id": "driver.Driver.GetMigrationFS",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.GetMigrationFS"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func() []string",
+ "id": "driver.Driver.GetMigrationLines",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.GetMigrationLines"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(line string, version int) []string",
+ "id": "driver.Driver.GetMigrationTruncateTables",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.GetMigrationTruncateTables"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func() bool",
+ "id": "driver.Driver.PoolIsSet",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.PoolIsSet"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(dbPool any) error",
+ "id": "driver.Driver.PoolSet",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.PoolSet"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(column, namedArg string, values []string) (string, any, error)",
+ "id": "driver.Driver.SQLFragmentColumnContainsAll",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.SQLFragmentColumnContainsAll"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(column, namedArg string, values []string) (string, any, error)",
+ "id": "driver.Driver.SQLFragmentColumnContainsAny",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.SQLFragmentColumnContainsAny"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(column string, values any) (string, any, error)",
+ "id": "driver.Driver.SQLFragmentColumnIn",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.SQLFragmentColumnIn"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func() bool",
+ "id": "driver.Driver.SupportsListenNotify",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.SupportsListenNotify"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func() bool",
+ "id": "driver.Driver.SupportsListener",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.SupportsListener"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func() time.Duration",
+ "id": "driver.Driver.TimePrecision",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.TimePrecision"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(tx TTx) ExecutorTx",
+ "id": "driver.Driver.UnwrapExecutor",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.UnwrapExecutor"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(execTx ExecutorTx) TTx",
+ "id": "driver.Driver.UnwrapTx",
+ "rationale": "Go database-driver adapter plumbing; other implementations integrate their database libraries directly.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Driver.UnwrapTx"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context) (ExecutorTx, error)",
+ "id": "driver.Executor.Begin",
+ "rationale": "Go driver-seam primitive for raw statement execution or transactions.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.Begin"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *ColumnExistsParams) (bool, error)",
+ "id": "driver.Executor.ColumnExists",
+ "rationale": "Go driver-seam method for schema introspection used by the migrator.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.ColumnExists"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, sql string, args ...any) error",
+ "id": "driver.Executor.Exec",
+ "rationale": "Go driver-seam primitive for raw statement execution or transactions.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.Exec"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *IndexDropIfExistsParams) error",
+ "id": "driver.Executor.IndexDropIfExists",
+ "rationale": "Go driver-seam method for index introspection and maintenance used by the reindexer and tests.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.IndexDropIfExists"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *IndexExistsParams) (bool, error)",
+ "id": "driver.Executor.IndexExists",
+ "rationale": "Go driver-seam method for index introspection and maintenance used by the reindexer and tests.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.IndexExists"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *IndexReindexParams) error",
+ "id": "driver.Executor.IndexReindex",
+ "rationale": "Go driver-seam method for index introspection and maintenance used by the reindexer and tests.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.IndexReindex"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *IndexReindexArtifactsParams) ([]string, error)",
+ "id": "driver.Executor.IndexReindexArtifacts",
+ "rationale": "Go driver-seam method for index introspection and maintenance used by the reindexer and tests.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.IndexReindexArtifacts"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *IndexesExistParams) (map[string]bool, error)",
+ "id": "driver.Executor.IndexesExist",
+ "rationale": "Go driver-seam method for index introspection and maintenance used by the reindexer and tests.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.IndexesExist"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context) error",
+ "id": "driver.Executor.InitDriver",
+ "rationale": "Go driver-seam method that detects server capabilities, such as YugabyteDB lacking LISTEN/NOTIFY and xmax, before a client starts; simulated_yugabyte_polling covers their effects across implementations.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.InitDriver"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobCancelParams) (*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobCancel",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobCancel"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobCountByAllStatesParams) (map[rivertype.JobState]int, error)",
+ "id": "driver.Executor.JobCountByAllStates",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobCountByAllStates"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobCountByQueueAndStateParams) ([]*JobCountByQueueAndStateResult, error)",
+ "id": "driver.Executor.JobCountByQueueAndState",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobCountByQueueAndState"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobCountByStateParams) (int, error)",
+ "id": "driver.Executor.JobCountByState",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobCountByState"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobDeleteParams) (*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobDelete",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobDelete"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobDeleteBeforeParams) (int, error)",
+ "id": "driver.Executor.JobDeleteBefore",
+ "rationale": "Go driver-seam method for the job cleaner's deletion, also reused by extensions' own cleaner passes. The adapter's delete_finalized method runs it directly so queue inclusion and exclusion are checked before the batch limit on every engine.",
+ "scenarios": [
+ "job_cleaner_queue_filters",
+ "sqlite_runtime_job_cleaner_queue_filters"
+ ],
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobDeleteBefore"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobDeleteManyParams) ([]*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobDeleteMany",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobDeleteMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobGetAvailableParams) (*JobGetAvailableResult, error)",
+ "id": "driver.Executor.JobGetAvailable",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobGetAvailable"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobGetByIDParams) (*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobGetByID",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobGetByID"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobGetByIDManyParams) ([]*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobGetByIDMany",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobGetByIDMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobGetByKindManyParams) ([]*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobGetByKindMany",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobGetByKindMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobGetCancelRequestedParams) ([]int64, error)",
+ "id": "driver.Executor.JobGetCancelRequested",
+ "rationale": "Go driver-seam query through which clients without a notifier poll their running jobs for cancellation requests; the resulting cancellation is covered by poll_only_remote_cancellation.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobGetCancelRequested"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobGetStuckParams) ([]*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobGetStuck",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobGetStuck"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobInsertFastManyParams) ([]*JobInsertFastResult, error)",
+ "id": "driver.Executor.JobInsertFastMany",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobInsertFastMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobInsertFastManyParams) (int, error)",
+ "id": "driver.Executor.JobInsertFastManyNoReturning",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobInsertFastManyNoReturning"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobInsertFullParams) (*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobInsertFull",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobInsertFull"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, jobs *JobInsertFullManyParams) ([]*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobInsertFullMany",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobInsertFullMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobKindListParams) ([]string, error)",
+ "id": "driver.Executor.JobKindList",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobKindList"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobListParams) ([]*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobList",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobList"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobRescueManyParams) (*struct{}, error)",
+ "id": "driver.Executor.JobRescueMany",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobRescueMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobRetryParams) (*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobRetry",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobRetry"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobScheduleParams) ([]*JobScheduleResult, error)",
+ "id": "driver.Executor.JobSchedule",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobSchedule"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobSetStateIfRunningManyParams) ([]*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobSetStateIfRunningMany",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobSetStateIfRunningMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobUpdateParams) (*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobUpdate",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobUpdate"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *JobUpdateFullParams) (*rivertype.JobRow, error)",
+ "id": "driver.Executor.JobUpdateFull",
+ "rationale": "Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.JobUpdateFull"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *LeaderElectParams) (*Leader, error)",
+ "id": "driver.Executor.LeaderAttemptElect",
+ "rationale": "Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.LeaderAttemptElect"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *LeaderReelectParams) (*Leader, error)",
+ "id": "driver.Executor.LeaderAttemptReelect",
+ "rationale": "Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.LeaderAttemptReelect"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *LeaderDeleteExpiredParams) (int, error)",
+ "id": "driver.Executor.LeaderDeleteExpired",
+ "rationale": "Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.LeaderDeleteExpired"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *LeaderGetElectedLeaderParams) (*Leader, error)",
+ "id": "driver.Executor.LeaderGetElectedLeader",
+ "rationale": "Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.LeaderGetElectedLeader"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *LeaderInsertParams) (*Leader, error)",
+ "id": "driver.Executor.LeaderInsert",
+ "rationale": "Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.LeaderInsert"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *LeaderResignParams) (bool, error)",
+ "id": "driver.Executor.LeaderResign",
+ "rationale": "Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.LeaderResign"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *MigrationDeleteAssumingMainManyParams) ([]*Migration, error)",
+ "id": "driver.Executor.MigrationDeleteAssumingMainMany",
+ "rationale": "Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.MigrationDeleteAssumingMainMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *MigrationDeleteByLineAndVersionManyParams) ([]*Migration, error)",
+ "id": "driver.Executor.MigrationDeleteByLineAndVersionMany",
+ "rationale": "Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.MigrationDeleteByLineAndVersionMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *MigrationGetAllAssumingMainParams) ([]*Migration, error)",
+ "id": "driver.Executor.MigrationGetAllAssumingMain",
+ "rationale": "Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.MigrationGetAllAssumingMain"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *MigrationGetByLineParams) ([]*Migration, error)",
+ "id": "driver.Executor.MigrationGetByLine",
+ "rationale": "Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.MigrationGetByLine"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *MigrationInsertManyParams) ([]*Migration, error)",
+ "id": "driver.Executor.MigrationInsertMany",
+ "rationale": "Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.MigrationInsertMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *MigrationInsertManyAssumingMainParams) ([]*Migration, error)",
+ "id": "driver.Executor.MigrationInsertManyAssumingMain",
+ "rationale": "Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.MigrationInsertManyAssumingMain"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *NotificationDeleteBeforeParams) (int, error)",
+ "id": "driver.Executor.NotificationDeleteBefore",
+ "rationale": "Go driver-seam method for notification queries; notification behavior is covered by the notification items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.NotificationDeleteBefore"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *NotifyManyParams) error",
+ "id": "driver.Executor.NotifyMany",
+ "rationale": "Go driver-seam method for notification queries; notification behavior is covered by the notification items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.NotifyMany"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, key int64) (*struct{}, error)",
+ "id": "driver.Executor.PGAdvisoryXactLock",
+ "rationale": "Go driver-seam method for PostgreSQL advisory lock helper.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.PGAdvisoryXactLock"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context) error",
+ "id": "driver.Executor.Ping",
+ "rationale": "Go driver-seam connectivity check made when a client starts.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.Ping"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, sql string, args ...any) Row",
+ "id": "driver.Executor.QueryRow",
+ "rationale": "Go driver-seam primitive for raw statement execution or transactions.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.QueryRow"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *QueueCreateOrSetUpdatedAtParams) (*rivertype.Queue, error)",
+ "id": "driver.Executor.QueueCreateOrSetUpdatedAt",
+ "rationale": "Go driver-seam method for queue queries; their persisted effects are covered by the client queue items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.QueueCreateOrSetUpdatedAt"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *QueueDeleteExpiredParams) ([]string, error)",
+ "id": "driver.Executor.QueueDeleteExpired",
+ "rationale": "Go driver-seam method for queue queries; their persisted effects are covered by the client queue items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.QueueDeleteExpired"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *QueueGetParams) (*rivertype.Queue, error)",
+ "id": "driver.Executor.QueueGet",
+ "rationale": "Go driver-seam method for queue queries; their persisted effects are covered by the client queue items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.QueueGet"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *QueueListParams) ([]*rivertype.Queue, error)",
+ "id": "driver.Executor.QueueList",
+ "rationale": "Go driver-seam method for queue queries; their persisted effects are covered by the client queue items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.QueueList"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *QueueNameListParams) ([]string, error)",
+ "id": "driver.Executor.QueueNameList",
+ "rationale": "Go driver-seam method for queue queries; their persisted effects are covered by the client queue items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.QueueNameList"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *QueuePauseParams) error",
+ "id": "driver.Executor.QueuePause",
+ "rationale": "Go driver-seam method for queue queries; their persisted effects are covered by the client queue items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.QueuePause"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *QueueResumeParams) error",
+ "id": "driver.Executor.QueueResume",
+ "rationale": "Go driver-seam method for queue queries; their persisted effects are covered by the client queue items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.QueueResume"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *QueueUpdateParams) (*rivertype.Queue, error)",
+ "id": "driver.Executor.QueueUpdate",
+ "rationale": "Go driver-seam method for queue queries; their persisted effects are covered by the client queue items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.QueueUpdate"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *SchemaCreateParams) error",
+ "id": "driver.Executor.SchemaCreate",
+ "rationale": "Go driver-seam method for schema management used by maintenance and tests.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.SchemaCreate"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *SchemaDropParams) error",
+ "id": "driver.Executor.SchemaDrop",
+ "rationale": "Go driver-seam method for schema management used by maintenance and tests.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.SchemaDrop"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *SchemaGetExpiredParams) ([]string, error)",
+ "id": "driver.Executor.SchemaGetExpired",
+ "rationale": "Go driver-seam method for schema management used by maintenance and tests.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.SchemaGetExpired"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *TableExistsParams) (bool, error)",
+ "id": "driver.Executor.TableExists",
+ "rationale": "Go driver-seam method for table introspection and truncation used by the migrator and tests.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.TableExists"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, params *TableTruncateParams) error",
+ "id": "driver.Executor.TableTruncate",
+ "rationale": "Go driver-seam method for table introspection and truncation used by the migrator and tests.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Executor.TableTruncate"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context) error",
+ "id": "driver.ExecutorTx.Commit",
+ "rationale": "Go transaction wrapper in the driver seam; transaction semantics are covered by the client transaction items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.ExecutorTx.Commit"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "embeds Executor",
+ "id": "driver.ExecutorTx.Executor",
+ "rationale": "Go transaction wrapper in the driver seam; transaction semantics are covered by the client transaction items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.ExecutorTx"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context) error",
+ "id": "driver.ExecutorTx.Rollback",
+ "rationale": "Go transaction wrapper in the driver seam; transaction semantics are covered by the client transaction items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.ExecutorTx.Rollback"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context) error",
+ "id": "driver.Listener.Close",
+ "rationale": "Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Listener.Close"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context) error",
+ "id": "driver.Listener.Connect",
+ "rationale": "Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Listener.Connect"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, topic string) error",
+ "id": "driver.Listener.Listen",
+ "rationale": "Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Listener.Listen"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context) error",
+ "id": "driver.Listener.Ping",
+ "rationale": "Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Listener.Ping"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func() string",
+ "id": "driver.Listener.Schema",
+ "rationale": "Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Listener.Schema"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(sql string)",
+ "id": "driver.Listener.SetAfterConnectExec",
+ "rationale": "Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Listener.SetAfterConnectExec"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context, topic string) error",
+ "id": "driver.Listener.Unlisten",
+ "rationale": "Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Listener.Unlisten"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(ctx context.Context) (*Notification, error)",
+ "id": "driver.Listener.WaitForNotification",
+ "rationale": "Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Listener.WaitForNotification"
+ },
+ {
+ "applicability": "driver_specific",
+ "area": "driver",
+ "detail": "func(dest ...any) error",
+ "id": "driver.Row.Scan",
+ "rationale": "Go row-scanning wrapper in the driver seam.",
+ "source": "riverdriver/river_driver_interface.go:riverdriver.Row.Scan"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "event_kind",
+ "detail": "EventKindJobCancelled",
+ "id": "event_kind.job_cancelled",
+ "rationale": "Local subscription event in each implementation's idiom; not yet asserted by a shared scenario.",
+ "source": "event.go:river.EventKindJobCancelled"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "event_kind",
+ "detail": "EventKindJobCompleted",
+ "id": "event_kind.job_completed",
+ "rationale": "Local subscription event; the adapter reports observed events via runtime_stats.",
+ "scenarios": [
+ "sqlite_runtime_extensions_resumable_subscriptions"
+ ],
+ "source": "event.go:river.EventKindJobCompleted"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "event_kind",
+ "detail": "EventKindJobFailed",
+ "id": "event_kind.job_failed",
+ "rationale": "Local subscription event; the adapter reports observed events via runtime_stats.",
+ "scenarios": [
+ "sqlite_runtime_extensions_resumable_subscriptions"
+ ],
+ "source": "event.go:river.EventKindJobFailed"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "event_kind",
+ "detail": "EventKindJobInterrupted",
+ "id": "event_kind.job_interrupted",
+ "rationale": "Local subscription event in each implementation's idiom; not yet asserted by a shared scenario.",
+ "source": "event.go:river.EventKindJobInterrupted"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "event_kind",
+ "detail": "EventKindJobSnoozed",
+ "id": "event_kind.job_snoozed",
+ "rationale": "Local subscription event in each implementation's idiom; not yet asserted by a shared scenario.",
+ "source": "event.go:river.EventKindJobSnoozed"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "event_kind",
+ "detail": "EventKindQueuePaused",
+ "id": "event_kind.queue_paused",
+ "rationale": "Local subscription event raised when a pause control notification arrives, including from another implementation.",
+ "scenarios": [
+ "remote_queue_subscription_events",
+ "sqlite_runtime_remote_queue_subscription_events"
+ ],
+ "source": "event.go:river.EventKindQueuePaused"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "event_kind",
+ "detail": "EventKindQueueResumed",
+ "id": "event_kind.queue_resumed",
+ "rationale": "Local subscription event raised when a resume control notification arrives, including from another implementation.",
+ "scenarios": [
+ "remote_queue_subscription_events",
+ "sqlite_runtime_remote_queue_subscription_events"
+ ],
+ "source": "event.go:river.EventKindQueueResumed"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *riverdriver.JobCancelParams) (*rivertype.JobRow, error)",
+ "id": "extension.riverpilot.Pilot.JobCancel",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.JobCancel"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func() []string",
+ "id": "extension.riverpilot.Pilot.JobCleanerQueuesExcluded",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.JobCleanerQueuesExcluded"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, state ProducerState, params *riverdriver.JobGetAvailableParams) (*riverdriver.JobGetAvailableResult, error)",
+ "id": "extension.riverpilot.Pilot.JobGetAvailable",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.JobGetAvailable"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, execTx riverdriver.ExecutorTx, params *riverdriver.JobInsertFastManyParams) ([]*riverdriver.JobInsertFastResult, error)",
+ "id": "extension.riverpilot.Pilot.JobInsertMany",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.JobInsertMany"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *riverdriver.JobRetryParams) (*rivertype.JobRow, error)",
+ "id": "extension.riverpilot.Pilot.JobRetry",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.JobRetry"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *riverdriver.JobSetStateIfRunningManyParams) ([]*rivertype.JobRow, error)",
+ "id": "extension.riverpilot.Pilot.JobSetStateIfRunningMany",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.JobSetStateIfRunningMany"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(archetype *baseservice.Archetype, params *PilotInitParams)",
+ "id": "extension.riverpilot.Pilot.PilotInit",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.PilotInit"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "embeds PilotPeriodicJob",
+ "id": "extension.riverpilot.Pilot.PilotPeriodicJob",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *ProducerInitParams) (int64, ProducerState, error)",
+ "id": "extension.riverpilot.Pilot.ProducerInit",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.ProducerInit"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *riverdriver.ProducerKeepAliveParams) error",
+ "id": "extension.riverpilot.Pilot.ProducerKeepAlive",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.ProducerKeepAlive"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *ProducerShutdownParams) error",
+ "id": "extension.riverpilot.Pilot.ProducerShutdown",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.ProducerShutdown"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *QueueMetadataChangedParams) error",
+ "id": "extension.riverpilot.Pilot.QueueMetadataChanged",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.Pilot.QueueMetadataChanged"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *riverdriver.JobGetStuckParams) ([]*rivertype.JobRow, error)",
+ "id": "extension.riverpilot.PilotJobRescuer.JobGetStuck",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.PilotJobRescuer.JobGetStuck"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *riverdriver.JobRescueManyParams) (*struct{}, error)",
+ "id": "extension.riverpilot.PilotJobRescuer.JobRescueMany",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.PilotJobRescuer.JobRescueMany"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *PeriodicJobGetAllParams) ([]*PeriodicJob, error)",
+ "id": "extension.riverpilot.PilotPeriodicJob.PeriodicJobGetAll",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.PilotPeriodicJob.PeriodicJobGetAll"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *PeriodicJobKeepAliveAndReapParams) ([]*PeriodicJob, error)",
+ "id": "extension.riverpilot.PilotPeriodicJob.PeriodicJobKeepAliveAndReap",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.PilotPeriodicJob.PeriodicJobKeepAliveAndReap"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(ctx context.Context, exec riverdriver.Executor, params *PeriodicJobUpsertManyParams) ([]*PeriodicJob, error)",
+ "id": "extension.riverpilot.PilotPeriodicJob.PeriodicJobUpsertMany",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.PilotPeriodicJob.PeriodicJobUpsertMany"
+ },
+ {
+ "applicability": "internal",
+ "area": "extension",
+ "detail": "func(job *rivertype.JobRow)",
+ "id": "extension.riverpilot.ProducerState.JobFinish",
+ "rationale": "Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items.",
+ "source": "rivershared/riverpilot/pilot.go:riverpilot.ProducerState.JobFinish"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "func() bool",
+ "id": "extension.rivertype.Hook.IsHook",
+ "rationale": "Go marker method used to discriminate hook, middleware, and plugin values; other languages express this with their own type systems.",
+ "source": "rivertype/river_type.go:rivertype.Hook.IsHook"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "embeds Hook",
+ "id": "extension.rivertype.HookInsertBegin.Hook",
+ "rationale": "Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems.",
+ "source": "rivertype/river_type.go:rivertype.HookInsertBegin"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "extension",
+ "detail": "func(ctx context.Context, params *JobInsertParams) error",
+ "id": "extension.rivertype.HookInsertBegin.InsertBegin",
+ "rationale": "Insert-begin hook in each language's idiom; ordering is checked through the adapter's instrumented plugin.",
+ "scenarios": [
+ "extension_hook_middleware_order"
+ ],
+ "source": "rivertype/river_type.go:rivertype.HookInsertBegin.InsertBegin"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "embeds Hook",
+ "id": "extension.rivertype.HookMetricEmit.Hook",
+ "rationale": "Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems.",
+ "source": "rivertype/river_type.go:rivertype.HookMetricEmit"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "func(ctx context.Context, params *HookMetricEmitParams)",
+ "id": "extension.rivertype.HookMetricEmit.MetricEmit",
+ "rationale": "Go-specific metric hook; other implementations expose telemetry through their own instrumentation.",
+ "source": "rivertype/river_type.go:rivertype.HookMetricEmit.MetricEmit"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "embeds Hook",
+ "id": "extension.rivertype.HookPeriodicJobsStart.Hook",
+ "rationale": "Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems.",
+ "source": "rivertype/river_type.go:rivertype.HookPeriodicJobsStart"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "extension",
+ "detail": "func(ctx context.Context, params *HookPeriodicJobsStartParams) error",
+ "id": "extension.rivertype.HookPeriodicJobsStart.Start",
+ "rationale": "Periodic-jobs-start hook in each language's idiom; the adapter's instrumented plugin counts invocations.",
+ "scenarios": [
+ "periodic_run_on_start"
+ ],
+ "source": "rivertype/river_type.go:rivertype.HookPeriodicJobsStart.Start"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "embeds Hook",
+ "id": "extension.rivertype.HookWorkBegin.Hook",
+ "rationale": "Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems.",
+ "source": "rivertype/river_type.go:rivertype.HookWorkBegin"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "extension",
+ "detail": "func(ctx context.Context, job *JobRow) error",
+ "id": "extension.rivertype.HookWorkBegin.WorkBegin",
+ "rationale": "Work-begin hook in each language's idiom; ordering is checked through the adapter's instrumented plugin.",
+ "scenarios": [
+ "extension_hook_middleware_order"
+ ],
+ "source": "rivertype/river_type.go:rivertype.HookWorkBegin.WorkBegin"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "embeds Hook",
+ "id": "extension.rivertype.HookWorkEnd.Hook",
+ "rationale": "Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems.",
+ "source": "rivertype/river_type.go:rivertype.HookWorkEnd"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "extension",
+ "detail": "func(ctx context.Context, job *JobRow, err error) error",
+ "id": "extension.rivertype.HookWorkEnd.WorkEnd",
+ "rationale": "Work-end hook in each language's idiom; ordering is checked through the adapter's instrumented plugin.",
+ "scenarios": [
+ "extension_hook_middleware_order"
+ ],
+ "source": "rivertype/river_type.go:rivertype.HookWorkEnd.WorkEnd"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "extension",
+ "detail": "func(ctx context.Context, manyParams []*JobInsertParams, doInner func(context.Context) ([]*JobInsertResult, error)) ([]*JobInsertResult, error)",
+ "id": "extension.rivertype.JobInsertMiddleware.InsertMany",
+ "rationale": "Insert middleware in each language's idiom; ordering is checked through the adapter's instrumented plugin.",
+ "scenarios": [
+ "extension_hook_middleware_order"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobInsertMiddleware.InsertMany"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "embeds Middleware",
+ "id": "extension.rivertype.JobInsertMiddleware.Middleware",
+ "rationale": "Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertMiddleware"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "func() bool",
+ "id": "extension.rivertype.Middleware.IsMiddleware",
+ "rationale": "Go marker method used to discriminate hook, middleware, and plugin values; other languages express this with their own type systems.",
+ "source": "rivertype/river_type.go:rivertype.Middleware.IsMiddleware"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "func() bool",
+ "id": "extension.rivertype.Plugin.IsPlugin",
+ "rationale": "Go marker method used to discriminate hook, middleware, and plugin values; other languages express this with their own type systems.",
+ "source": "rivertype/river_type.go:rivertype.Plugin.IsPlugin"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "extension",
+ "detail": "embeds Middleware",
+ "id": "extension.rivertype.WorkerMiddleware.Middleware",
+ "rationale": "Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems.",
+ "source": "rivertype/river_type.go:rivertype.WorkerMiddleware"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "extension",
+ "detail": "func(ctx context.Context, job *JobRow, doInner func(context.Context) error) error",
+ "id": "extension.rivertype.WorkerMiddleware.Work",
+ "rationale": "Work middleware in each language's idiom; ordering is checked through the adapter's instrumented plugin.",
+ "scenarios": [
+ "extension_hook_middleware_order"
+ ],
+ "source": "rivertype/river_type.go:rivertype.WorkerMiddleware.Work"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[T JobArgs](workers *Workers, worker Worker[T])",
+ "id": "function.AddWorker",
+ "rationale": "Language-native worker registration; the kinds a client registers decide which jobs it can work, exercised whenever one implementation works another's jobs.",
+ "scenarios": [
+ "candidate_insert_reference_work",
+ "reference_insert_candidate_work",
+ "sqlite_runtime_cross_language_work"
+ ],
+ "source": "worker.go:river.AddWorker"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "function",
+ "detail": "func[T JobArgs](workers *Workers, jobArgs T, worker Worker[T])",
+ "id": "function.AddWorkerArgs",
+ "rationale": "Go test helper that registers a worker for an explicit args value; documented as internal-only, with no counterpart elsewhere.",
+ "source": "worker.go:river.AddWorkerArgs"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[T JobArgs](workers *Workers, worker Worker[T]) error",
+ "id": "function.AddWorkerSafely",
+ "rationale": "Language-native worker registration; the kinds a client registers decide which jobs it can work, exercised whenever one implementation works another's jobs.",
+ "scenarios": [
+ "candidate_insert_reference_work",
+ "reference_insert_candidate_work",
+ "sqlite_runtime_cross_language_work"
+ ],
+ "source": "worker.go:river.AddWorkerSafely"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[TTx any](ctx context.Context) *Client[TTx]",
+ "id": "function.ClientFromContext",
+ "rationale": "Language-native access to the working client from a worker's context; has no persisted effect of its own.",
+ "source": "client_context.go:river.ClientFromContext"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[TTx any](ctx context.Context) (*Client[TTx], error)",
+ "id": "function.ClientFromContextSafely",
+ "rationale": "Language-native access to the working client from a worker's context; has no persisted effect of its own.",
+ "source": "client_context.go:river.ClientFromContextSafely"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func(err error) error",
+ "id": "function.JobCancel",
+ "rationale": "Worker-side cancellation in each language's idiom; the persisted cancelled row and its error are exercised through the adapter's `cancel` worker behavior.",
+ "scenarios": [
+ "single_implementation_worker_outcomes"
+ ],
+ "source": "error.go:river.JobCancel"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[TDriver riverdriver.Driver[TTx], TTx any, TArgs JobArgs](ctx context.Context, tx TTx, job *Job[TArgs]) (*Job[TArgs], error)",
+ "id": "function.JobCompleteTx",
+ "rationale": "Transactional completion from a worker in each language's idiom; exercised through the adapter's `transactional_complete` worker behavior.",
+ "scenarios": [
+ "transactional_completion"
+ ],
+ "source": "job_complete_tx.go:river.JobCompleteTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func(job *rivertype.JobRow) *JobListCursor",
+ "id": "function.JobListCursorFromJob",
+ "rationale": "Builds a job list cursor from a row; the cursor's encoding is exchanged between implementations through the adapter's list method.",
+ "scenarios": [
+ "job_list_cursor_interchange",
+ "sqlite_runtime_job_list_cursor_interchange"
+ ],
+ "source": "job_list_params.go:river.JobListCursorFromJob"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func(duration time.Duration) error",
+ "id": "function.JobSnooze",
+ "rationale": "Worker-side snooze in each language's idiom; the persisted snooze transition is exercised through the adapter's `snooze_once` worker behavior.",
+ "scenarios": [
+ "snooze_once_metadata_transition",
+ "sqlite_runtime_job_rows"
+ ],
+ "source": "error.go:river.JobSnooze"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func(ctx context.Context, key string, value any) error",
+ "id": "function.MetadataSet",
+ "rationale": "Worker-side metadata updates merged into the row when the attempt finishes, in each language's idiom; exercised by the adapter's resumable cursor and transactional completion behaviors.",
+ "scenarios": [
+ "resumable_cross_engine_cursor",
+ "transactional_completion"
+ ],
+ "source": "metadata.go:river.MetadataSet"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func() PeriodicSchedule",
+ "id": "function.NeverSchedule",
+ "rationale": "Language-native periodic schedule that never fires; it inserts nothing, so it has no cross-language effect.",
+ "source": "periodic_job.go:river.NeverSchedule"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client[TTx], error)",
+ "id": "function.NewClient",
+ "rationale": "Language-native client construction.",
+ "source": "client.go:river.NewClient"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func() *JobDeleteManyParams",
+ "id": "function.NewJobDeleteManyParams",
+ "rationale": "Language-native constructor for bulk delete parameters; the filters are classified under job_delete_many_params.",
+ "source": "delete_many_params.go:river.NewJobDeleteManyParams"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func() *JobListParams",
+ "id": "function.NewJobListParams",
+ "rationale": "Language-native constructor for job list parameters; the filters are classified under job_list_params.",
+ "source": "job_list_params.go:river.NewJobListParams"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func(scheduleFunc PeriodicSchedule, constructorFunc PeriodicJobConstructor, opts *PeriodicJobOpts) *PeriodicJob",
+ "id": "function.NewPeriodicJob",
+ "rationale": "Language-native periodic job construction; the enqueue behavior is classified under config.PeriodicJobs.",
+ "scenarios": [
+ "periodic_run_on_start",
+ "periodic_unique_cross_engine"
+ ],
+ "source": "periodic_job.go:river.NewPeriodicJob"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func() *QueueListParams",
+ "id": "function.NewQueueListParams",
+ "rationale": "Language-native constructor for queue list parameters.",
+ "source": "queue_list_params.go:river.NewQueueListParams"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func() *Workers",
+ "id": "function.NewWorkers",
+ "rationale": "Language-native worker registration; the kinds a client registers decide which jobs it can work, exercised whenever one implementation works another's jobs.",
+ "scenarios": [
+ "candidate_insert_reference_work",
+ "reference_insert_candidate_work",
+ "sqlite_runtime_cross_language_work"
+ ],
+ "source": "worker.go:river.NewWorkers"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func(interval time.Duration) PeriodicSchedule",
+ "id": "function.PeriodicInterval",
+ "rationale": "Fixed-interval periodic schedule in each language's idiom; the adapter's run-on-start periodic job uses it.",
+ "scenarios": [
+ "periodic_run_on_start"
+ ],
+ "source": "periodic_job.go:river.PeriodicInterval"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func(ctx context.Context, output any) error",
+ "id": "function.RecordOutput",
+ "rationale": "Records job output from a worker in each language's idiom; the persisted `output` metadata is exercised through the adapter's `output` worker behavior.",
+ "scenarios": [
+ "single_implementation_worker_outcomes",
+ "sqlite_runtime_job_rows"
+ ],
+ "source": "recorded_output.go:river.RecordOutput"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func() []string",
+ "id": "function.ReindexerIndexNamesDefault",
+ "rationale": "Default index set of the leader's reindexer; scenarios pass an explicit set through the adapter's `reindexer_index_names`.",
+ "scenarios": [
+ "maintenance_reindexer_skips_artifacts"
+ ],
+ "source": "client.go:river.ReindexerIndexNamesDefault"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[TCursor any](ctx context.Context, cursor TCursor) error",
+ "id": "function.ResumableSetCursor",
+ "rationale": "Resumable step cursor in each language's idiom; the persisted cursor metadata is read across implementations.",
+ "scenarios": [
+ "resumable_cross_engine_cursor",
+ "resumable_retry",
+ "sqlite_runtime_resumable_cross_engine_cursor"
+ ],
+ "source": "resumable.go:river.ResumableSetCursor"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[TDriver riverdriver.Driver[TTx], TTx any, TArgs JobArgs, TCursor any](ctx context.Context, tx TTx, job *Job[TArgs], cursor TCursor) (*Job[TArgs], error)",
+ "id": "function.ResumableSetStepCursorTx",
+ "rationale": "Transactional resumable checkpoint in each language's idiom; it writes the same reserved metadata as the non-transactional path, which shared scenarios cover.",
+ "source": "resumable_step_tx.go:river.ResumableSetStepCursorTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[TDriver riverdriver.Driver[TTx], TTx any, TArgs JobArgs](ctx context.Context, tx TTx, job *Job[TArgs]) (*Job[TArgs], error)",
+ "id": "function.ResumableSetStepTx",
+ "rationale": "Transactional resumable checkpoint in each language's idiom; it writes the same reserved metadata as the non-transactional path, which shared scenarios cover.",
+ "source": "resumable_step_tx.go:river.ResumableSetStepTx"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func(ctx context.Context, name string, opts *StepOpts, stepFunc func(ctx context.Context) error)",
+ "id": "function.ResumableStep",
+ "rationale": "Resumable step in each language's idiom; the persisted step metadata is read across implementations.",
+ "scenarios": [
+ "resumable_cross_engine_cursor",
+ "resumable_retry",
+ "sqlite_runtime_resumable_cross_engine_cursor"
+ ],
+ "source": "resumable.go:river.ResumableStep"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[TCursor any](ctx context.Context, name string, opts *StepOpts, stepFunc func(ctx context.Context, cursor TCursor) error)",
+ "id": "function.ResumableStepCursor",
+ "rationale": "Resumable step with a cursor in each language's idiom; the persisted cursor metadata is read across implementations.",
+ "scenarios": [
+ "resumable_cross_engine_cursor",
+ "resumable_retry",
+ "sqlite_runtime_resumable_cross_engine_cursor"
+ ],
+ "source": "resumable.go:river.ResumableStepCursor"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "function",
+ "detail": "func[T JobArgs](f func(context.Context, *Job[T]) error) Worker[T]",
+ "id": "function.WorkFunc",
+ "rationale": "Language-native shorthand for a worker defined by a function.",
+ "source": "worker.go:river.WorkFunc"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "insert_opts",
+ "detail": "int",
+ "id": "insert_opts.MaxAttempts",
+ "rationale": "Persisted max_attempts decides between retry and discard.",
+ "scenarios": [
+ "exhausted_job_retry",
+ "mixed_unknown_kind_error",
+ "single_implementation_worker_outcomes",
+ "sqlite_runtime_exhausted_job_retry"
+ ],
+ "source": "insert_opts.go:river.InsertOpts.MaxAttempts"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "insert_opts",
+ "detail": "[]uint8",
+ "id": "insert_opts.Metadata",
+ "rationale": "Persisted job metadata.",
+ "scenarios": [
+ "differential_job_crud",
+ "job_row_round_trip_all_fields"
+ ],
+ "source": "insert_opts.go:river.InsertOpts.Metadata"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "insert_opts",
+ "detail": "bool",
+ "id": "insert_opts.Pending",
+ "rationale": "Inserts rows in the pending state.",
+ "scenarios": [
+ "typed_batch_insertion"
+ ],
+ "source": "insert_opts.go:river.InsertOpts.Pending"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "insert_opts",
+ "detail": "int",
+ "id": "insert_opts.Priority",
+ "rationale": "Persisted priority; claims take lower priorities first.",
+ "scenarios": [
+ "claim_order",
+ "differential_job_crud",
+ "sqlite_runtime_claim_order",
+ "typed_batch_insertion"
+ ],
+ "source": "insert_opts.go:river.InsertOpts.Priority"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "insert_opts",
+ "detail": "string",
+ "id": "insert_opts.Queue",
+ "rationale": "Persisted queue; determines which clients fetch the job.",
+ "scenarios": [
+ "cross_language_unique_conflict",
+ "ignored_cancellation_hard_abort"
+ ],
+ "source": "insert_opts.go:river.InsertOpts.Queue"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "insert_opts",
+ "detail": "time.Time",
+ "id": "insert_opts.ScheduledAt",
+ "rationale": "Persisted scheduled_at; an explicit time inserts the job scheduled, even when due, and claims order jobs of equal priority by it.",
+ "scenarios": [
+ "claim_order",
+ "clock_boundary_scheduling",
+ "differential_job_list_filters_and_cursors",
+ "sqlite_runtime_claim_order"
+ ],
+ "source": "insert_opts.go:river.InsertOpts.ScheduledAt"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "insert_opts",
+ "detail": "[]string",
+ "id": "insert_opts.Tags",
+ "rationale": "Persisted tags.",
+ "scenarios": [
+ "differential_job_crud",
+ "typed_batch_insertion"
+ ],
+ "source": "insert_opts.go:river.InsertOpts.Tags"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "insert_opts",
+ "detail": "river.UniqueOpts",
+ "id": "insert_opts.UniqueOpts",
+ "rationale": "Controls persisted unique_key and unique_states.",
+ "scenarios": [
+ "cross_language_unique_conflict",
+ "unique_hash_goldens"
+ ],
+ "source": "insert_opts.go:river.InsertOpts.UniqueOpts"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func(job *rivertype.JobRow) time.Time",
+ "id": "interface.ClientRetryPolicy.NextRetry",
+ "rationale": "Retry policy in each language's idiom; its persisted effect is classified under config.RetryPolicy.",
+ "scenarios": [
+ "default_retry_policy_schedule",
+ "deterministic_retry_clock_rng"
+ ],
+ "source": "retry_policy.go:river.ClientRetryPolicy.NextRetry"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func(ctx context.Context, job *rivertype.JobRow, err error) *ErrorHandlerResult",
+ "id": "interface.ErrorHandler.HandleError",
+ "rationale": "Error callback in each language's idiom; its persisted effect is exercised through the adapter's error_handler_cancel start option.",
+ "scenarios": [
+ "error_handler_cancel_override"
+ ],
+ "source": "error_handler.go:river.ErrorHandler.HandleError"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func(ctx context.Context, job *rivertype.JobRow, panicVal any, trace string) *ErrorHandlerResult",
+ "id": "interface.ErrorHandler.HandlePanic",
+ "rationale": "Panic callback in each language's idiom; implementations without panics map it to their own abnormal termination. The persisted panic attempt is checked separately.",
+ "scenarios": [
+ "panic_attempt_trace"
+ ],
+ "source": "error_handler.go:river.ErrorHandler.HandlePanic"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "interface",
+ "detail": "func() string",
+ "id": "interface.JobArgs.Kind",
+ "rationale": "The kind is persisted with every job and selects the worker in any implementation; a job of an unregistered kind fails the same way everywhere.",
+ "scenarios": [
+ "candidate_insert_reference_work",
+ "mixed_unknown_kind_error",
+ "reference_insert_candidate_work",
+ "sqlite_runtime_unknown_kind_error"
+ ],
+ "source": "job.go:river.JobArgs.Kind"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func() []rivertype.Hook",
+ "id": "interface.JobArgsWithHooks.Hooks",
+ "rationale": "Per-kind hooks in each language's idiom; hook ordering is checked through globally installed plugins in extension_hook_middleware_order.",
+ "source": "job.go:river.JobArgsWithHooks.Hooks"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func() InsertOpts",
+ "id": "interface.JobArgsWithInsertOpts.InsertOpts",
+ "rationale": "Per-kind insertion defaults in each language's idiom; the persisted options are classified under insert_opts.",
+ "source": "job.go:river.JobArgsWithInsertOpts.InsertOpts"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "interface",
+ "detail": "func() []string",
+ "id": "interface.JobArgsWithKindAliases.KindAliases",
+ "rationale": "Former kinds a worker also works during a safe rename, so jobs an older deployment inserted under the old kind aren't orphaned; the alias also counts as a known kind for FetchOnlyKnownKinds.",
+ "scenarios": [
+ "kind_alias_rename",
+ "sqlite_runtime_kind_alias_rename"
+ ],
+ "source": "job.go:river.JobArgsWithKindAliases.KindAliases"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func() []rivertype.Plugin",
+ "id": "interface.JobArgsWithPlugins.Plugins",
+ "rationale": "Per-kind plugins in each language's idiom; plugin hook and middleware ordering is checked through globally installed plugins in extension_hook_middleware_order.",
+ "source": "job.go:river.JobArgsWithPlugins.Plugins"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func(current time.Time) time.Time",
+ "id": "interface.PeriodicSchedule.Next",
+ "rationale": "Periodic schedule in each language's idiom; cron schedules are checked against Go-generated goldens.",
+ "scenarios": [
+ "cron_schedule_goldens"
+ ],
+ "source": "periodic_job.go:river.PeriodicSchedule.Next"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func(job *rivertype.JobRow) []rivertype.WorkerMiddleware",
+ "id": "interface.Worker.Middleware",
+ "rationale": "Per-worker middleware in each language's idiom; middleware ordering is checked through globally installed plugins.",
+ "scenarios": [
+ "extension_hook_middleware_order"
+ ],
+ "source": "worker.go:river.Worker.Middleware"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func(job *Job[T]) time.Time",
+ "id": "interface.Worker.NextRetry",
+ "rationale": "Per-worker retry override in each language's idiom; like config.RetryPolicy, its effect is the persisted scheduled_at of a retryable job, which shared scenarios check for the client-level policy.",
+ "source": "worker.go:river.Worker.NextRetry"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func(job *Job[T]) time.Duration",
+ "id": "interface.Worker.Timeout",
+ "rationale": "Per-worker timeout override in each language's idiom; the client-level timeout's cancellation and the rescuer's use of timeouts are checked by shared scenarios.",
+ "scenarios": [
+ "maintenance_rescuer_full_batch_of_unexpired_jobs",
+ "timeout_cancellation"
+ ],
+ "source": "worker.go:river.Worker.Timeout"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "interface",
+ "detail": "func(ctx context.Context, job *Job[T]) error",
+ "id": "interface.Worker.Work",
+ "rationale": "The work function in each language's idiom; its outcomes are persisted the same way in every implementation.",
+ "scenarios": [
+ "single_implementation_worker_outcomes",
+ "sqlite_runtime_job_rows"
+ ],
+ "source": "worker.go:river.Worker.Work"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_delete_many_params",
+ "detail": "func(int) *river.JobDeleteManyParams",
+ "id": "job_delete_many_params.First",
+ "rationale": "Portable bulk-delete filter; the adapter's delete_many method accepts it.",
+ "scenarios": [
+ "bulk_delete_safety"
+ ],
+ "source": "delete_many_params.go:river.JobDeleteManyParams.First"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_delete_many_params",
+ "detail": "func(...int64) *river.JobDeleteManyParams",
+ "id": "job_delete_many_params.IDs",
+ "rationale": "Portable bulk-delete filter; the adapter's delete_many method accepts it.",
+ "scenarios": [
+ "bulk_delete_safety"
+ ],
+ "source": "delete_many_params.go:river.JobDeleteManyParams.IDs"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_delete_many_params",
+ "detail": "func(...string) *river.JobDeleteManyParams",
+ "id": "job_delete_many_params.Kinds",
+ "rationale": "Portable bulk-delete filter; the adapter's delete_many method accepts it.",
+ "scenarios": [
+ "bulk_delete_safety"
+ ],
+ "source": "delete_many_params.go:river.JobDeleteManyParams.Kinds"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_delete_many_params",
+ "detail": "func(...int16) *river.JobDeleteManyParams",
+ "id": "job_delete_many_params.Priorities",
+ "rationale": "Portable bulk-delete filter by priority; not yet accepted by the adapter's delete_many method.",
+ "source": "delete_many_params.go:river.JobDeleteManyParams.Priorities"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_delete_many_params",
+ "detail": "func(...string) *river.JobDeleteManyParams",
+ "id": "job_delete_many_params.Queues",
+ "rationale": "Portable bulk-delete filter; the adapter's delete_many method accepts it.",
+ "scenarios": [
+ "bulk_delete_safety"
+ ],
+ "source": "delete_many_params.go:river.JobDeleteManyParams.Queues"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_delete_many_params",
+ "detail": "func(...rivertype.JobState) *river.JobDeleteManyParams",
+ "id": "job_delete_many_params.States",
+ "rationale": "Portable bulk-delete filter; the adapter's delete_many method accepts it.",
+ "scenarios": [
+ "bulk_delete_safety"
+ ],
+ "source": "delete_many_params.go:river.JobDeleteManyParams.States"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_delete_many_params",
+ "detail": "func() *river.JobDeleteManyParams",
+ "id": "job_delete_many_params.UnsafeAll",
+ "rationale": "Portable bulk-delete filter; the adapter's delete_many method accepts it.",
+ "scenarios": [
+ "bulk_delete_safety"
+ ],
+ "source": "delete_many_params.go:river.JobDeleteManyParams.UnsafeAll"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(*river.JobListCursor) *river.JobListParams",
+ "id": "job_list_params.After",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.After"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(int) *river.JobListParams",
+ "id": "job_list_params.First",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.First"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(...int64) *river.JobListParams",
+ "id": "job_list_params.IDs",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.IDs"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(...string) *river.JobListParams",
+ "id": "job_list_params.Kinds",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.Kinds"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(string) *river.JobListParams",
+ "id": "job_list_params.Metadata",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.Metadata"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(river.JobListOrderByField, river.SortOrder) *river.JobListParams",
+ "id": "job_list_params.OrderBy",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.OrderBy"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(...int16) *river.JobListParams",
+ "id": "job_list_params.Priorities",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.Priorities"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(...string) *river.JobListParams",
+ "id": "job_list_params.Queues",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.Queues"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(...rivertype.JobState) *river.JobListParams",
+ "id": "job_list_params.States",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.States"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(...string) *river.JobListParams",
+ "id": "job_list_params.TagsAll",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.TagsAll"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "job_list_params",
+ "detail": "func(...string) *river.JobListParams",
+ "id": "job_list_params.TagsAny",
+ "rationale": "Portable job list filter/ordering/cursor option; the adapter's list method accepts it.",
+ "scenarios": [
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "job_list_params.go:river.JobListParams.TagsAny"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "job_list_params",
+ "detail": "func(string, ...river.NamedArgs) *river.JobListParams",
+ "id": "job_list_params.Where",
+ "rationale": "Accepts a raw SQL predicate with Go named arguments; tied to the Go driver's SQL dialect and not part of the portable list contract.",
+ "source": "job_list_params.go:river.JobListParams.Where"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "job_state",
+ "detail": "JobStateAvailable",
+ "id": "job_state.available",
+ "rationale": "Persisted river_job.state value.",
+ "scenarios": [
+ "differential_job_crud",
+ "single_implementation_worker_outcomes"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobStateAvailable"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "job_state",
+ "detail": "JobStateCancelled",
+ "id": "job_state.cancelled",
+ "rationale": "Persisted river_job.state value.",
+ "scenarios": [
+ "differential_job_crud",
+ "remote_cancel_notification"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobStateCancelled"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "job_state",
+ "detail": "JobStateCompleted",
+ "id": "job_state.completed",
+ "rationale": "Persisted river_job.state value.",
+ "scenarios": [
+ "single_implementation_worker_outcomes"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobStateCompleted"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "job_state",
+ "detail": "JobStateDiscarded",
+ "id": "job_state.discarded",
+ "rationale": "Persisted river_job.state value.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "single_implementation_worker_outcomes"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobStateDiscarded"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "job_state",
+ "detail": "JobStatePending",
+ "id": "job_state.pending",
+ "rationale": "Persisted river_job.state value.",
+ "scenarios": [
+ "typed_batch_insertion"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobStatePending"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "job_state",
+ "detail": "JobStateRetryable",
+ "id": "job_state.retryable",
+ "rationale": "Persisted river_job.state value.",
+ "scenarios": [
+ "deterministic_retry_clock_rng",
+ "single_implementation_worker_outcomes"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobStateRetryable"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "job_state",
+ "detail": "JobStateRunning",
+ "id": "job_state.running",
+ "rationale": "Persisted river_job.state value.",
+ "scenarios": [
+ "single_implementation_worker_outcomes"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobStateRunning"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "job_state",
+ "detail": "JobStateScheduled",
+ "id": "job_state.scheduled",
+ "rationale": "Persisted river_job.state value.",
+ "scenarios": [
+ "clock_boundary_scheduling",
+ "differential_job_list_filters_and_cursors"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobStateScheduled"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "sql:jsonb_set",
+ "id": "metadata_key.cancel_attempted_at",
+ "rationale": "Written by cancellation of a running job; tells the rescuer not to rescue it.",
+ "scenarios": [
+ "reserved_metadata_cross_engine"
+ ],
+ "source": "riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobCancel, riverdriver/riversqlite/internal/dbsqlc/river_job.sql:JobCancel"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "go:const, go:metadata_updates_index",
+ "id": "metadata_key.output",
+ "rationale": "Recorded job output; the adapter's update output writes it and the other implementation reads it.",
+ "scenarios": [
+ "differential_job_crud",
+ "reserved_metadata_cross_engine"
+ ],
+ "source": "client.go:river.Client.jobUpdate, recorded_output.go:river.RecordOutput, rivertype/river_type.go:rivertype.MetadataKeyOutput"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "go:sjson.SetBytes",
+ "id": "metadata_key.periodic",
+ "rationale": "Marks jobs inserted by the periodic job enqueuer.",
+ "scenarios": [
+ "periodic_run_on_start"
+ ],
+ "source": "internal/maintenance/periodic_job_enqueuer.go:maintenance.PeriodicJobEnqueuer.insertParamsFromConstructor"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "go:const, go:gjson.GetBytes, go:json_tag, go:metadata_updates_index",
+ "id": "metadata_key.river:log",
+ "rationale": "Written only by the optional Go riverlog middleware; other implementations need not write it. Every implementation must carry it through snoozes, cancellations, and completions unchanged, like other metadata it doesn't own.",
+ "scenarios": [
+ "reserved_metadata_cross_engine"
+ ],
+ "source": "riverlog/river_log.go:riverlog.Middleware.Work, riverlog/river_log.go:riverlog.appendLogDataWithCap, riverlog/river_log.go:riverlog.metadataKey, riverlog/river_log.go:riverlog.metadataWithLog"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "go:const, go:sjson.SetBytes",
+ "id": "metadata_key.river:periodic_job_id",
+ "rationale": "Identifies the periodic job that enqueued a job.",
+ "scenarios": [
+ "periodic_run_on_start",
+ "sqlite_runtime_periodic_scheduler"
+ ],
+ "source": "internal/maintenance/periodic_job_enqueuer.go:maintenance.PeriodicJobEnqueuer.insertParamsFromConstructor, internal/rivercommon/river_common.go:rivercommon.MetadataKeyPeriodicJobID"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "go:const, sql:jsonb_build_object, sql:jsonb_set",
+ "id": "metadata_key.river:rescue_count",
+ "rationale": "Incremented by the rescuer each time a stuck job is rescued.",
+ "scenarios": [
+ "reserved_metadata_cross_engine"
+ ],
+ "source": "internal/rivercommon/river_common.go:rivercommon.MetadataKeyRescueCount, riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobRescueMany, riverdriver/riversqlite/internal/dbsqlc/river_job.sql:JobRescue"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "go:const, go:gjson.GetBytes, go:metadata_updates_index",
+ "id": "metadata_key.river:resumable_cursor",
+ "rationale": "Resumable job cursor state carried across attempts and engines.",
+ "scenarios": [
+ "resumable_cross_engine_cursor",
+ "resumable_validation"
+ ],
+ "source": "internal/rivercommon/river_common.go:rivercommon.MetadataKeyResumableCursor, internal/riverplugin/plugin.go:riverplugin.ResumableMiddleware.Work, resumable_step_tx.go:river.resumableSetStepTx"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "go:const, go:gjson.GetBytes, go:metadata_updates_index",
+ "id": "metadata_key.river:resumable_step",
+ "rationale": "Last completed resumable step carried across attempts and engines.",
+ "scenarios": [
+ "resumable_cross_engine_cursor",
+ "resumable_retry",
+ "resumable_validation"
+ ],
+ "source": "internal/rivercommon/river_common.go:rivercommon.MetadataKeyResumableStep, internal/riverplugin/plugin.go:riverplugin.ResumableMiddleware.Work, resumable_step_tx.go:river.resumableSetStepTx"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "go:const",
+ "id": "metadata_key.river:unique_nonce",
+ "rationale": "Unique insert nonce used to detect whether a unique insert was skipped as a duplicate where xmax is unavailable: always on SQLite, and on YugabyteDB.",
+ "scenarios": [
+ "simulated_yugabyte_polling",
+ "sqlite_insert_get_unique_cross_language"
+ ],
+ "source": "riverdriver/unique_insert.go:riverdriver.UniqueInsertMetadataKey"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "go:gjson.GetBytes, go:metadata_updates_index",
+ "id": "metadata_key.snoozes",
+ "rationale": "Snooze counter; snoozing increments it without consuming an attempt.",
+ "scenarios": [
+ "reserved_metadata_cross_engine",
+ "snooze_once_metadata_transition"
+ ],
+ "source": "internal/jobexecutor/job_executor.go:jobexecutor.JobExecutor.reportResult"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "metadata_key",
+ "detail": "sql:json_literal",
+ "id": "metadata_key.unique_key_conflict",
+ "rationale": "Marker the leader's scheduler writes, with the value `scheduler_discarded`, when it discards a due retryable or scheduled unique job whose key a live job holds or an earlier due job shares.",
+ "scenarios": [
+ "scheduler_unique_conflict_discard",
+ "sqlite_runtime_job_rows",
+ "sqlite_runtime_scheduler_unique_conflict_discard"
+ ],
+ "source": "riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobSchedule, riverdriver/riversqlite/internal/dbsqlc/river_job.sql:JobScheduleSetDiscarded"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "create_river_migration up:79def9ab1643 down:34c87dc594bf",
+ "id": "migration.postgres.001",
+ "rationale": "Main-line PostgreSQL schema version.",
+ "scenarios": [
+ "candidate_migrator_reference_runtime",
+ "historical_migration_down_up",
+ "reference_migrator_candidate_runtime"
+ ],
+ "source": "riverdriver/riverpgxv5/migration/main/001_create_river_migration.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "initial_schema up:8915c00d08ed down:8e7e73755b3e",
+ "id": "migration.postgres.002",
+ "rationale": "Main-line PostgreSQL schema version.",
+ "scenarios": [
+ "candidate_migrator_reference_runtime",
+ "historical_migration_down_up",
+ "reference_migrator_candidate_runtime"
+ ],
+ "source": "riverdriver/riverpgxv5/migration/main/002_initial_schema.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "river_job_tags_non_null up:dedb183bb302 down:bca44f6f0e92",
+ "id": "migration.postgres.003",
+ "rationale": "Main-line PostgreSQL schema version.",
+ "scenarios": [
+ "candidate_migrator_reference_runtime",
+ "historical_migration_down_up",
+ "reference_migrator_candidate_runtime"
+ ],
+ "source": "riverdriver/riverpgxv5/migration/main/003_river_job_tags_non_null.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "pending_and_more up:3f7418b0cf78 down:91b5ced7b9d7",
+ "id": "migration.postgres.004",
+ "rationale": "Main-line PostgreSQL schema version.",
+ "scenarios": [
+ "candidate_migrator_reference_runtime",
+ "historical_migration_down_up",
+ "reference_migrator_candidate_runtime"
+ ],
+ "source": "riverdriver/riverpgxv5/migration/main/004_pending_and_more.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "migration_unique_client up:b760f487152c down:de84dca49a5d",
+ "id": "migration.postgres.005",
+ "rationale": "Main-line PostgreSQL schema version.",
+ "scenarios": [
+ "candidate_migrator_reference_runtime",
+ "historical_migration_down_up",
+ "reference_migrator_candidate_runtime"
+ ],
+ "source": "riverdriver/riverpgxv5/migration/main/005_migration_unique_client.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "bulk_unique up:3b133f7ce466 down:726483f6e5aa",
+ "id": "migration.postgres.006",
+ "rationale": "Main-line PostgreSQL schema version.",
+ "scenarios": [
+ "candidate_migrator_reference_runtime",
+ "historical_migration_down_up",
+ "reference_migrator_candidate_runtime"
+ ],
+ "source": "riverdriver/riverpgxv5/migration/main/006_bulk_unique.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "notification_outbox_sqlite_jsonb_and_sql_cleanup up:47ec8031b88e down:9131aae23518",
+ "id": "migration.postgres.007",
+ "rationale": "Main-line PostgreSQL schema version.",
+ "scenarios": [
+ "candidate_migrator_reference_runtime",
+ "historical_migration_down_up",
+ "reference_migrator_candidate_runtime"
+ ],
+ "source": "riverdriver/riverpgxv5/migration/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "job_id_autoincrement up:0c3750a947d6 down:0c3750a947d6",
+ "id": "migration.postgres.008",
+ "rationale": "Main-line PostgreSQL schema version.",
+ "scenarios": [
+ "candidate_migrator_reference_runtime",
+ "historical_migration_down_up",
+ "reference_migrator_candidate_runtime"
+ ],
+ "source": "riverdriver/riverpgxv5/migration/main/008_job_id_autoincrement.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "create_river_migration up:d15597cb0bb8 down:34c87dc594bf",
+ "id": "migration.sqlite.001",
+ "rationale": "Main-line SQLite schema version.",
+ "scenarios": [
+ "sqlite_migration_cross_language"
+ ],
+ "source": "riverdriver/riversqlite/migration/main/001_create_river_migration.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "initial_schema up:58bc64db39fa down:900508ba08d0",
+ "id": "migration.sqlite.002",
+ "rationale": "Main-line SQLite schema version.",
+ "scenarios": [
+ "sqlite_migration_cross_language"
+ ],
+ "source": "riverdriver/riversqlite/migration/main/002_initial_schema.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "river_job_tags_non_null up:ae9961ea15b2 down:223eb849addf",
+ "id": "migration.sqlite.003",
+ "rationale": "Main-line SQLite schema version.",
+ "scenarios": [
+ "sqlite_migration_cross_language"
+ ],
+ "source": "riverdriver/riversqlite/migration/main/003_river_job_tags_non_null.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "pending_and_more up:8c11c8d2bf63 down:28065bbe82db",
+ "id": "migration.sqlite.004",
+ "rationale": "Main-line SQLite schema version.",
+ "scenarios": [
+ "sqlite_migration_cross_language"
+ ],
+ "source": "riverdriver/riversqlite/migration/main/004_pending_and_more.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "migration_unique_client up:67c32e81494b down:9960dc49a229",
+ "id": "migration.sqlite.005",
+ "rationale": "Main-line SQLite schema version.",
+ "scenarios": [
+ "sqlite_migration_cross_language"
+ ],
+ "source": "riverdriver/riversqlite/migration/main/005_migration_unique_client.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "bulk_unique up:96713f4832bc down:b9e778134d15",
+ "id": "migration.sqlite.006",
+ "rationale": "Main-line SQLite schema version.",
+ "scenarios": [
+ "sqlite_migration_cross_language"
+ ],
+ "source": "riverdriver/riversqlite/migration/main/006_bulk_unique.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "notification_outbox_sqlite_jsonb_and_sql_cleanup up:441a05e1d9aa down:55bffeb528b4",
+ "id": "migration.sqlite.007",
+ "rationale": "Main-line SQLite schema version.",
+ "scenarios": [
+ "sqlite_migration_cross_language"
+ ],
+ "source": "riverdriver/riversqlite/migration/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "migration",
+ "detail": "job_id_autoincrement up:049c9bf615f2 down:04871283fe5d",
+ "id": "migration.sqlite.008",
+ "rationale": "Main-line SQLite schema version.",
+ "scenarios": [
+ "sqlite_migration_cross_language"
+ ],
+ "source": "riverdriver/riversqlite/migration/main/008_job_id_autoincrement.{up,down}.sql"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "action controlAction; job_id int64 omitempty; metadata json.RawMessage omitempty; queue string",
+ "id": "notification_payload.control",
+ "rationale": "JSON shape of control notifications.",
+ "scenarios": [
+ "pause_resume_notification",
+ "remote_cancel_notification",
+ "remote_queue_subscription_events"
+ ],
+ "source": "producer.go:river.controlEventPayload"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "controlActionCancel",
+ "id": "notification_payload.control.action.cancel",
+ "rationale": "Cancels a running job on the client working it.",
+ "scenarios": [
+ "remote_cancel_notification",
+ "transactional_cross_language_cancel"
+ ],
+ "source": "producer.go:river.controlActionCancel"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "controlActionMetadataChanged",
+ "id": "notification_payload.control.action.metadata_changed",
+ "rationale": "Sent on the shared control channel by a queue metadata update from any implementation. River Go's producers react at once by passing the new metadata to their extension, so every implementation must send the same payload.",
+ "scenarios": [
+ "differential_queue_crud"
+ ],
+ "source": "producer.go:river.controlActionMetadataChanged"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "controlActionPause",
+ "id": "notification_payload.control.action.pause",
+ "rationale": "Pauses fetching for a queue on every client.",
+ "scenarios": [
+ "pause_resume_notification",
+ "remote_queue_subscription_events"
+ ],
+ "source": "producer.go:river.controlActionPause"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "controlActionResume",
+ "id": "notification_payload.control.action.resume",
+ "rationale": "Resumes fetching for a queue on every client.",
+ "scenarios": [
+ "pause_resume_notification",
+ "remote_queue_subscription_events"
+ ],
+ "source": "producer.go:river.controlActionResume"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "queue string",
+ "id": "notification_payload.insert",
+ "rationale": "JSON shape of insert wakeup notifications.",
+ "scenarios": [
+ "notification_only_wakeups",
+ "transactional_insert_notification_commit_only"
+ ],
+ "source": "producer.go:river.insertPayload"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "action DBNotificationKind; leader_id string",
+ "id": "notification_payload.leadership",
+ "rationale": "JSON shape of leadership notifications.",
+ "scenarios": [
+ "mixed_leader_failover_both_directions",
+ "mixed_request_resign_terms"
+ ],
+ "source": "internal/leadership/elector.go:leadership.DBNotification"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "DBNotificationKindRequestResign",
+ "id": "notification_payload.leadership.action.request_resign",
+ "rationale": "Asks the current leader to resign.",
+ "scenarios": [
+ "mixed_request_resign_terms"
+ ],
+ "source": "internal/leadership/elector.go:leadership.DBNotificationKindRequestResign"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "DBNotificationKindResigned",
+ "id": "notification_payload.leadership.action.resigned",
+ "rationale": "Announces a resignation so followers attempt election immediately.",
+ "scenarios": [
+ "mixed_leader_failover_both_directions",
+ "mixed_request_resign_terms"
+ ],
+ "source": "internal/leadership/elector.go:leadership.DBNotificationKindResigned"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "action=cancel; job_id; queue",
+ "id": "notification_payload.sql.job_cancel",
+ "rationale": "Cancel notification emitted by the cancel query itself.",
+ "scenarios": [
+ "remote_cancel_notification",
+ "transactional_cross_language_cancel"
+ ],
+ "source": "riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobCancel"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_payload",
+ "detail": "action=resigned; leader_id",
+ "id": "notification_payload.sql.leader_resign",
+ "rationale": "Resignation notification emitted by the resign query itself.",
+ "scenarios": [
+ "mixed_leader_failover_both_directions"
+ ],
+ "source": "riverdriver/riverpgxv5/internal/dbsqlc/river_leader.sql:LeaderResign"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_topic",
+ "detail": "NotificationTopicControl",
+ "id": "notification_topic.river_control",
+ "rationale": "Control channel for cancel, pause, resume, and metadata changes.",
+ "scenarios": [
+ "pause_resume_notification",
+ "remote_cancel_notification"
+ ],
+ "source": "internal/notifier/notifier.go:notifier.NotificationTopicControl"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_topic",
+ "detail": "NotificationTopicInsert",
+ "id": "notification_topic.river_insert",
+ "rationale": "Insert wakeup channel.",
+ "scenarios": [
+ "notification_only_wakeups",
+ "transactional_insert_notification_commit_only"
+ ],
+ "source": "internal/notifier/notifier.go:notifier.NotificationTopicInsert"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "notification_topic",
+ "detail": "NotificationTopicLeadership",
+ "id": "notification_topic.river_leadership",
+ "rationale": "Leadership resignation channel.",
+ "scenarios": [
+ "mixed_leader_failover_both_directions",
+ "mixed_request_resign_terms"
+ ],
+ "source": "internal/notifier/notifier.go:notifier.NotificationTopicLeadership"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "periodic_job_opts",
+ "detail": "string",
+ "id": "periodic_job_opts.ID",
+ "rationale": "Persisted as river:periodic_job_id metadata on enqueued periodic jobs.",
+ "scenarios": [
+ "periodic_run_on_start",
+ "sqlite_runtime_periodic_scheduler"
+ ],
+ "source": "periodic_job.go:river.PeriodicJobOpts.ID"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "periodic_job_opts",
+ "detail": "bool",
+ "id": "periodic_job_opts.RunOnStart",
+ "rationale": "Makes a newly elected leader enqueue the periodic job immediately.",
+ "scenarios": [
+ "periodic_run_on_start"
+ ],
+ "source": "periodic_job.go:river.PeriodicJobOpts.RunOnStart"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "queue_config",
+ "detail": "time.Duration",
+ "id": "queue_config.FetchCooldown",
+ "rationale": "Per-queue override of config.FetchCooldown for fetching only; local throughput throttle. Insert notifications always use the client-level cooldown.",
+ "source": "client.go:river.QueueConfig.FetchCooldown"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "queue_config",
+ "detail": "time.Duration",
+ "id": "queue_config.FetchPollInterval",
+ "rationale": "Per-queue override of config.FetchPollInterval; local polling fallback.",
+ "source": "client.go:river.QueueConfig.FetchPollInterval"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "queue_config",
+ "detail": "int",
+ "id": "queue_config.MaxWorkers",
+ "rationale": "Local per-queue concurrency limit; the adapter's queue_add max_workers reconfigures it.",
+ "scenarios": [
+ "dynamic_queue_add_reconfigure_remove"
+ ],
+ "source": "client.go:river.QueueConfig.MaxWorkers"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "queue_list_params",
+ "detail": "func(int) *river.QueueListParams",
+ "id": "queue_list_params.First",
+ "rationale": "Queue list limit; the adapter's queue_list method accepts it.",
+ "scenarios": [
+ "differential_queue_crud"
+ ],
+ "source": "queue_list_params.go:river.QueueListParams.First"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "time.Time",
+ "id": "rivertype_field.AttemptError.At",
+ "rationale": "Field of an element of `river_job.errors`, written by the implementation that finishes an attempt and read by every other.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "panic_attempt_trace",
+ "single_implementation_worker_outcomes",
+ "sqlite_runtime_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.AttemptError.At"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "int",
+ "id": "rivertype_field.AttemptError.Attempt",
+ "rationale": "Field of an element of `river_job.errors`, written by the implementation that finishes an attempt and read by every other.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "panic_attempt_trace",
+ "single_implementation_worker_outcomes",
+ "sqlite_runtime_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.AttemptError.Attempt"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.AttemptError.Error",
+ "rationale": "Field of an element of `river_job.errors`, written by the implementation that finishes an attempt and read by every other.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "panic_attempt_trace",
+ "single_implementation_worker_outcomes",
+ "sqlite_runtime_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.AttemptError.Error"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.AttemptError.Trace",
+ "rationale": "Field of an element of `river_job.errors`, written by the implementation that finishes an attempt and read by every other.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "panic_attempt_trace",
+ "single_implementation_worker_outcomes",
+ "sqlite_runtime_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.AttemptError.Trace"
+ },
+ {
+ "applicability": "internal",
+ "area": "rivertype_field",
+ "detail": "time.Time",
+ "id": "rivertype_field.DurablePeriodicJob.CreatedAt",
+ "rationale": "Record of a periodic job persisted through the unstable Go extension seam (riverpilot); River itself persists none, so it carries no cross-language contract.",
+ "source": "rivertype/river_type.go:rivertype.DurablePeriodicJob.CreatedAt"
+ },
+ {
+ "applicability": "internal",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.DurablePeriodicJob.ID",
+ "rationale": "Record of a periodic job persisted through the unstable Go extension seam (riverpilot); River itself persists none, so it carries no cross-language contract.",
+ "source": "rivertype/river_type.go:rivertype.DurablePeriodicJob.ID"
+ },
+ {
+ "applicability": "internal",
+ "area": "rivertype_field",
+ "detail": "time.Time",
+ "id": "rivertype_field.DurablePeriodicJob.NextRunAt",
+ "rationale": "Record of a periodic job persisted through the unstable Go extension seam (riverpilot); River itself persists none, so it carries no cross-language contract.",
+ "source": "rivertype/river_type.go:rivertype.DurablePeriodicJob.NextRunAt"
+ },
+ {
+ "applicability": "internal",
+ "area": "rivertype_field",
+ "detail": "time.Time",
+ "id": "rivertype_field.DurablePeriodicJob.UpdatedAt",
+ "rationale": "Record of a periodic job persisted through the unstable Go extension seam (riverpilot); River itself persists none, so it carries no cross-language contract.",
+ "source": "rivertype/river_type.go:rivertype.DurablePeriodicJob.UpdatedAt"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "rivertype_field",
+ "detail": "Metric",
+ "id": "rivertype_field.HookMetricEmitParams.Metric",
+ "rationale": "Parameter of the Go-specific metric hook; other implementations expose telemetry through their own instrumentation.",
+ "source": "rivertype/river_type.go:rivertype.HookMetricEmitParams.Metric"
+ },
+ {
+ "applicability": "internal",
+ "area": "rivertype_field",
+ "detail": "[]*DurablePeriodicJob",
+ "id": "rivertype_field.HookPeriodicJobsStartParams.DurableJobs",
+ "rationale": "Record of a periodic job persisted through the unstable Go extension seam (riverpilot); River itself persists none, so it carries no cross-language contract.",
+ "source": "rivertype/river_type.go:rivertype.HookPeriodicJobsStartParams.DurableJobs"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "rivertype_field",
+ "detail": "int",
+ "id": "rivertype_field.JobGetAvailableCountMetric.Count",
+ "rationale": "Parameter of the Go-specific metric hook; other implementations expose telemetry through their own instrumentation.",
+ "source": "rivertype/river_type.go:rivertype.JobGetAvailableCountMetric.Count"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.JobGetAvailableCountMetric.Queue",
+ "rationale": "Parameter of the Go-specific metric hook; other implementations expose telemetry through their own instrumentation.",
+ "source": "rivertype/river_type.go:rivertype.JobGetAvailableCountMetric.Queue"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "rivertype_field",
+ "detail": "time.Duration",
+ "id": "rivertype_field.JobGetAvailableDurationMetric.Duration",
+ "rationale": "Parameter of the Go-specific metric hook; other implementations expose telemetry through their own instrumentation.",
+ "source": "rivertype/river_type.go:rivertype.JobGetAvailableDurationMetric.Duration"
+ },
+ {
+ "applicability": "not_applicable",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.JobGetAvailableDurationMetric.Queue",
+ "rationale": "Parameter of the Go-specific metric hook; other implementations expose telemetry through their own instrumentation.",
+ "source": "rivertype/river_type.go:rivertype.JobGetAvailableDurationMetric.Queue"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "JobArgs",
+ "id": "rivertype_field.JobInsertParams.Args",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.Args"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "*time.Time",
+ "id": "rivertype_field.JobInsertParams.CreatedAt",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.CreatedAt"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "[]byte",
+ "id": "rivertype_field.JobInsertParams.EncodedArgs",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.EncodedArgs"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "*int64",
+ "id": "rivertype_field.JobInsertParams.ID",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.ID"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.JobInsertParams.Kind",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.Kind"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "int",
+ "id": "rivertype_field.JobInsertParams.MaxAttempts",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.MaxAttempts"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "[]byte",
+ "id": "rivertype_field.JobInsertParams.Metadata",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.Metadata"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "int",
+ "id": "rivertype_field.JobInsertParams.Priority",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.Priority"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.JobInsertParams.Queue",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.Queue"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "*time.Time",
+ "id": "rivertype_field.JobInsertParams.ScheduledAt",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.ScheduledAt"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "JobState",
+ "id": "rivertype_field.JobInsertParams.State",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.State"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "[]string",
+ "id": "rivertype_field.JobInsertParams.Tags",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.Tags"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "[]byte",
+ "id": "rivertype_field.JobInsertParams.UniqueKey",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.UniqueKey"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "byte",
+ "id": "rivertype_field.JobInsertParams.UniqueStates",
+ "rationale": "Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow.",
+ "source": "rivertype/river_type.go:rivertype.JobInsertParams.UniqueStates"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "*JobRow",
+ "id": "rivertype_field.JobInsertResult.Job",
+ "rationale": "Field of an insertion result in each language's idiom; the adapter's insert methods report it.",
+ "scenarios": [
+ "cross_language_unique_conflict",
+ "unique_skip_keeps_existing_kind"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobInsertResult.Job"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "bool",
+ "id": "rivertype_field.JobInsertResult.UniqueSkippedAsDuplicate",
+ "rationale": "Field of an insertion result in each language's idiom; the adapter's insert methods report it.",
+ "scenarios": [
+ "cross_language_unique_conflict",
+ "unique_skip_keeps_existing_kind"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobInsertResult.UniqueSkippedAsDuplicate"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "int",
+ "id": "rivertype_field.JobRow.Attempt",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "single_implementation_worker_outcomes",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.Attempt"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "*time.Time",
+ "id": "rivertype_field.JobRow.AttemptedAt",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.AttemptedAt"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "[]string",
+ "id": "rivertype_field.JobRow.AttemptedBy",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows",
+ "sqlite_runtime_attempted_by_ordering"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.AttemptedBy"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "time.Time",
+ "id": "rivertype_field.JobRow.CreatedAt",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.CreatedAt"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "[]byte",
+ "id": "rivertype_field.JobRow.EncodedArgs",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.EncodedArgs"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "[]AttemptError",
+ "id": "rivertype_field.JobRow.Errors",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "panic_attempt_trace",
+ "single_implementation_worker_outcomes",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.Errors"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "*time.Time",
+ "id": "rivertype_field.JobRow.FinalizedAt",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.FinalizedAt"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "int64",
+ "id": "rivertype_field.JobRow.ID",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows",
+ "sqlite_unsafe_int64_job_ids_rpc_list_cursors",
+ "unsafe_int64_job_ids_rpc_list_cursors"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.ID"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.JobRow.Kind",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.Kind"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "int",
+ "id": "rivertype_field.JobRow.MaxAttempts",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "exhausted_job_retry",
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows",
+ "sqlite_runtime_exhausted_job_retry"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.MaxAttempts"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "[]byte",
+ "id": "rivertype_field.JobRow.Metadata",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.Metadata"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "int",
+ "id": "rivertype_field.JobRow.Priority",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.Priority"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.JobRow.Queue",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.Queue"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "time.Time",
+ "id": "rivertype_field.JobRow.ScheduledAt",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.ScheduledAt"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "JobState",
+ "id": "rivertype_field.JobRow.State",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.State"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "[]string",
+ "id": "rivertype_field.JobRow.Tags",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.Tags"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "[]byte",
+ "id": "rivertype_field.JobRow.UniqueKey",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows",
+ "sqlite_unique_column_bytes",
+ "unique_column_bytes"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.UniqueKey"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "[]JobState",
+ "id": "rivertype_field.JobRow.UniqueStates",
+ "rationale": "Column of `river_job` that every implementation reads and writes.",
+ "scenarios": [
+ "job_row_round_trip_all_fields",
+ "sqlite_job_rows",
+ "sqlite_unique_column_bytes",
+ "unique_column_bytes"
+ ],
+ "source": "rivertype/river_type.go:rivertype.JobRow.UniqueStates"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "time.Duration",
+ "id": "rivertype_field.JobSnoozeError.Duration",
+ "rationale": "Snooze duration carried by the language's snooze error or outcome; the persisted transition is classified under function.JobSnooze.",
+ "scenarios": [
+ "snooze_once_metadata_transition"
+ ],
+ "source": "rivertype/execution_error.go:rivertype.JobSnoozeError.Duration"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "time.Time",
+ "id": "rivertype_field.Queue.CreatedAt",
+ "rationale": "Column of `river_queue` that every implementation reads and writes.",
+ "scenarios": [
+ "differential_queue_crud",
+ "sqlite_runtime_queue_crud_reconfigure_pause"
+ ],
+ "source": "rivertype/river_type.go:rivertype.Queue.CreatedAt"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "[]byte",
+ "id": "rivertype_field.Queue.Metadata",
+ "rationale": "Column of `river_queue` that every implementation reads and writes.",
+ "scenarios": [
+ "differential_queue_crud",
+ "sqlite_runtime_queue_crud_reconfigure_pause"
+ ],
+ "source": "rivertype/river_type.go:rivertype.Queue.Metadata"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.Queue.Name",
+ "rationale": "Column of `river_queue` that every implementation reads and writes.",
+ "scenarios": [
+ "differential_queue_crud",
+ "sqlite_runtime_queue_crud_reconfigure_pause"
+ ],
+ "source": "rivertype/river_type.go:rivertype.Queue.Name"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "*time.Time",
+ "id": "rivertype_field.Queue.PausedAt",
+ "rationale": "Column of `river_queue` that every implementation reads and writes.",
+ "scenarios": [
+ "differential_queue_crud",
+ "sqlite_runtime_queue_crud_reconfigure_pause"
+ ],
+ "source": "rivertype/river_type.go:rivertype.Queue.PausedAt"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "rivertype_field",
+ "detail": "time.Time",
+ "id": "rivertype_field.Queue.UpdatedAt",
+ "rationale": "Column of `river_queue` that every implementation reads and writes.",
+ "scenarios": [
+ "differential_queue_crud",
+ "sqlite_runtime_queue_crud_reconfigure_pause"
+ ],
+ "source": "rivertype/river_type.go:rivertype.Queue.UpdatedAt"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.UnknownJobKindError.Kind",
+ "rationale": "Kind carried by the language's unknown-kind error; the persisted failure is checked across implementations.",
+ "scenarios": [
+ "mixed_unknown_kind_error",
+ "sqlite_runtime_unknown_kind_error"
+ ],
+ "source": "rivertype/execution_error.go:rivertype.UnknownJobKindError.Kind"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "[]Hook",
+ "id": "rivertype_field.WorkerMetadata.JobArgHooks",
+ "rationale": "Description of a registered worker passed to Go plugins; other implementations describe registered workers in their own idiom.",
+ "source": "rivertype/river_type.go:rivertype.WorkerMetadata.JobArgHooks"
+ },
+ {
+ "applicability": "api_equivalent",
+ "area": "rivertype_field",
+ "detail": "string",
+ "id": "rivertype_field.WorkerMetadata.Kind",
+ "rationale": "Description of a registered worker passed to Go plugins; other implementations describe registered workers in their own idiom.",
+ "source": "rivertype/river_type.go:rivertype.WorkerMetadata.Kind"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "unique_opts",
+ "detail": "bool",
+ "id": "unique_opts.ByArgs",
+ "rationale": "Contributes to the persisted unique_key/unique_states that every implementation must compute identically.",
+ "scenarios": [
+ "cross_language_unique_conflict",
+ "unique_hash_goldens"
+ ],
+ "source": "insert_opts.go:river.UniqueOpts.ByArgs"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "unique_opts",
+ "detail": "time.Duration",
+ "id": "unique_opts.ByPeriod",
+ "rationale": "Contributes to the persisted unique_key/unique_states that every implementation must compute identically.",
+ "scenarios": [
+ "cross_language_unique_conflict",
+ "unique_hash_goldens"
+ ],
+ "source": "insert_opts.go:river.UniqueOpts.ByPeriod"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "unique_opts",
+ "detail": "bool",
+ "id": "unique_opts.ByQueue",
+ "rationale": "Contributes to the persisted unique_key/unique_states that every implementation must compute identically.",
+ "scenarios": [
+ "cross_language_unique_conflict",
+ "unique_hash_goldens"
+ ],
+ "source": "insert_opts.go:river.UniqueOpts.ByQueue"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "unique_opts",
+ "detail": "[]rivertype.JobState",
+ "id": "unique_opts.ByState",
+ "rationale": "Contributes to the persisted unique_key/unique_states that every implementation must compute identically.",
+ "scenarios": [
+ "cross_language_unique_conflict",
+ "unique_hash_goldens"
+ ],
+ "source": "insert_opts.go:river.UniqueOpts.ByState"
+ },
+ {
+ "applicability": "protocol_visible",
+ "area": "unique_opts",
+ "detail": "bool",
+ "id": "unique_opts.ExcludeKind",
+ "rationale": "Omits kind from the persisted unique_key hash input, so jobs of different kinds can share a unique key; a duplicate insertion of one kind must leave an existing job of another kind unchanged.",
+ "scenarios": [
+ "sqlite_runtime_unique_skip_keeps_existing_kind",
+ "unique_hash_goldens",
+ "unique_skip_keeps_existing_kind"
+ ],
+ "source": "insert_opts.go:river.UniqueOpts.ExcludeKind"
+ }
+ ],
+ "protocol_revision": 1
+}
diff --git a/conformance/feature-matrix.md b/conformance/feature-matrix.md
new file mode 100644
index 000000000..4c99ee595
--- /dev/null
+++ b/conformance/feature-matrix.md
@@ -0,0 +1,634 @@
+# Backend feature matrix
+
+
+
+This matrix is rendered from [`feature-inventory.json`](feature-inventory.json),
+which lists every Go-visible River feature the generator derives from the Go
+implementation: configuration and option fields, client and query-builder
+methods, job states, event kinds, reserved metadata keys, notification topics
+and payloads, driver and extension interfaces, and main-line migrations. Each
+item carries one applicability:
+
+- `protocol_visible`: affects persisted rows, SQL, notifications, timing, or
+ other behavior another implementation can observe. Lists at least one
+ executable scenario or records the gap that no shared scenario covers it
+ yet.
+- `api_equivalent`: a language API surface every implementation provides in its
+ own idiom. Scenarios are listed where an adapter operation exercises it.
+- `driver_specific`: a detail of Go's internal driver seam.
+- `internal`: Go-internal mechanics with no cross-language contract.
+- `not_applicable`: a Go-only concept other implementations need not provide.
+- `unclassified`: newly discovered and not yet reviewed.
+
+A row's status is its applicability plus the executable scenarios it lists;
+owner tests come from the registry in `harness/scenario_registry_test.go`. The
+matrix makes no broader completeness claim. `go run
+./internal/cmd/generatefeatureinventory -check` fails when a feature is added
+or removed without classification, when a protocol-visible item has neither
+a scenario nor a recorded gap, when a scenario is not declared and registered, or when this file is
+stale.
+
+## Scope decisions
+
+- PostgreSQL is the only backend with custom-schema, `SKIP LOCKED` competition,
+ backend fault-injection, process-kill rescue, performance, and soak
+ scenarios.
+- Fetching has no kind filter. Every client fetches any available job in the
+ queues it works; a job whose kind has no registered worker fails with a
+ retryable unknown-kind error (`mixed_unknown_kind_error`,
+ `sqlite_runtime_unknown_kind_error`).
+- Fast insertion (`InsertManyFast`) isn't part of the shared contract. Ports
+ don't offer it yet, so batches go through ordinary typed insertion.
+- SQLite `portable-storage-v1` covers main-line migrations; deterministic
+ retry and unique-key controls; typed insertion; job
+ get/list/update/cancel/retry/delete; cross-language cursor ordering;
+ millisecond timestamp storage; and transaction commit, rollback, batch
+ atomicity, and visibility. Every selected candidate is exercised in both
+ directions with Go against one WAL database.
+- SQLite `sqlite-runtime-v1` additionally covers work in both directions,
+ competing workers, queue CRUD, dynamic reconfiguration and pause/resume,
+ transactional and ordinary notification wakeups, cancellation, leadership
+ and failover, scheduler and periodic work, poll-only recovery, resumable
+ retries, hook and middleware ordering, local subscriptions, cross-client
+ pause/resume subscription delivery, and graceful lifecycle behavior.
+- SQLite custom schemas, PostgreSQL aborted-transaction behavior, `SKIP
+ LOCKED`, backend fault injection, rescue, cleaner and reindex maintenance,
+ performance, and soak are outside the SQLite profiles.
+- Subscriber lag counters, job and queue cleaners, and reindexing are claimed
+ only through scenarios listed on the corresponding items below; the version 1
+ process adapter does not expose lag observations.
+- Rust uses builders, typed async workers, cancellation tokens, and explicit
+ transaction connections rather than reproducing Go API shapes.
+- JavaScript uses `bigint`, Temporal instants, promises, `AbortSignal`, and
+ optional worker-thread execution rather than narrowing protocol values to
+ JavaScript numbers or reproducing Go goroutine APIs. Shared scenarios
+ exercise job IDs above `Number.MAX_SAFE_INTEGER`, including JSON-RPC
+ requests, responses, list filters, and cursors.
+- `riverqueue::__private` is a hidden extension module for crates released
+ in lockstep with `riverqueue`. It is not a stable API compatibility promise.
+- The Rust crates and JavaScript packages are unpublished preview packages
+ until the release process is complete.
+
+## Summary
+
+| Area | `protocol_visible` | `api_equivalent` | `driver_specific` | `internal` | `not_applicable` | `unclassified` | Total |
+|---|---:|---:|---:|---:|---:|---:|---:|
+| [`config`](#config) | 15 | 14 | 0 | 2 | 5 | 0 | 36 |
+| [`insert_opts`](#insert_opts) | 8 | 0 | 0 | 0 | 0 | 0 | 8 |
+| [`unique_opts`](#unique_opts) | 5 | 0 | 0 | 0 | 0 | 0 | 5 |
+| [`queue_config`](#queue_config) | 0 | 3 | 0 | 0 | 0 | 0 | 3 |
+| [`periodic_job_opts`](#periodic_job_opts) | 2 | 0 | 0 | 0 | 0 | 0 | 2 |
+| [`client`](#client) | 0 | 38 | 0 | 0 | 5 | 0 | 43 |
+| [`job_list_params`](#job_list_params) | 0 | 11 | 0 | 0 | 1 | 0 | 12 |
+| [`job_delete_many_params`](#job_delete_many_params) | 0 | 7 | 0 | 0 | 0 | 0 | 7 |
+| [`queue_list_params`](#queue_list_params) | 0 | 1 | 0 | 0 | 0 | 0 | 1 |
+| [`job_state`](#job_state) | 8 | 0 | 0 | 0 | 0 | 0 | 8 |
+| [`event_kind`](#event_kind) | 0 | 7 | 0 | 0 | 0 | 0 | 7 |
+| [`metadata_key`](#metadata_key) | 11 | 0 | 0 | 0 | 0 | 0 | 11 |
+| [`notification_topic`](#notification_topic) | 3 | 0 | 0 | 0 | 0 | 0 | 3 |
+| [`notification_payload`](#notification_payload) | 11 | 0 | 0 | 0 | 0 | 0 | 11 |
+| [`driver`](#driver) | 0 | 0 | 94 | 0 | 0 | 0 | 94 |
+| [`extension`](#extension) | 0 | 6 | 0 | 18 | 11 | 0 | 35 |
+| [`interface`](#interface) | 2 | 11 | 0 | 0 | 0 | 0 | 13 |
+| [`function`](#function) | 0 | 25 | 0 | 0 | 1 | 0 | 26 |
+| [`rivertype_field`](#rivertype_field) | 27 | 20 | 0 | 5 | 5 | 0 | 57 |
+| [`migration`](#migration) | 16 | 0 | 0 | 0 | 0 | 0 | 16 |
+
+## config
+
+Exported fields of `river.Config`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `config.AdvisoryLockPrefix` | not_applicable | | Copied into the periodic job enqueuer's configuration but not used to derive any lock key in this version; it has no persisted or cross-process effect to match. |
+| `config.CancelledJobRetentionPeriod` | protocol_visible | `maintenance_job_cleaner_retention` (TestMaintenanceConformance) | The job cleaner deletes cancelled rows after this period; deletion is visible to every implementation sharing the database. |
+| `config.CompletedJobRetentionPeriod` | protocol_visible | `maintenance_job_cleaner_retention` (TestMaintenanceConformance) | The job cleaner deletes completed rows after this period; deletion is visible to every implementation sharing the database. |
+| `config.DiscardedJobRetentionPeriod` | protocol_visible | `maintenance_job_cleaner_retention` (TestMaintenanceConformance) | The job cleaner deletes discarded rows after this period; deletion is visible to every implementation sharing the database. |
+| `config.ErrorHandler` | api_equivalent | `error_handler_cancel_override` (TestMixedConformance) | Language-native error/panic callback. Its persisted effect (overriding the outcome, e.g. cancel) is exercised through the adapter's error_handler_cancel start option. |
+| `config.FetchCooldown` | api_equivalent | | Per-client minimum interval between fetches (a throughput throttle), which also suppresses a client's repeated insert notification for a queue within the interval on every backend. Implementations expose an equivalent client-level knob with the same default and minimum. Rows are unaffected; the reference adapter's 1 ms setting keeps notification scenarios deterministic. |
+| `config.FetchOnlyKnownKinds` | protocol_visible | `heterogeneous_fleet_known_kinds` (TestMixedConformance)
`kind_alias_rename` (TestMixedConformance)
`sqlite_runtime_heterogeneous_fleet_known_kinds` (TestMixedSQLiteRuntimeConformance)
`sqlite_runtime_kind_alias_rename` (TestMixedSQLiteRuntimeConformance) | Restricts a client's claims to the kinds of its registered workers, including aliases, so clients that know different kinds can share a queue and jobs of other kinds stay available without using attempts. |
+| `config.FetchPollInterval` | api_equivalent | `lost_notification_poll_recovery` (TestMixedConformance)
`notification_only_wakeups` (TestMixedConformance) | Per-process polling fallback interval. The adapter's fetch_poll_interval_ms option exercises both the polling fallback and notification-only wakeups with polling effectively disabled. |
+| `config.Hooks` | api_equivalent | | Registration of global hooks in each language's idiom. Hook ordering semantics are exercised through plugin registration in extension_hook_middleware_order. |
+| `config.ID` | protocol_visible | `process_kill_restart_and_rescue` (TestMixedConformance)
`sqlite_runtime_attempted_by_ordering` (TestMixedSQLiteRuntimeConformance) | Persisted in attempted_by and used as leader_id; scenarios assert attempted_by client IDs across implementations. |
+| `config.JobCleanerTimeout` | internal | | Timeout for individual job cleaner queries; bounds local work only and changes no persisted outcome. |
+| `config.JobInsertMiddleware` | not_applicable | | Deprecated Go field superseded by Plugins. The insert-middleware concept is classified under extension.rivertype.JobInsertMiddleware. |
+| `config.JobStuckHandler` | api_equivalent | `stuck_job_detection` (TestMixedConformance) | Language-native callback invoked when a timed-out job does not return; lets the client open a replacement worker slot. |
+| `config.JobStuckThreshold` | api_equivalent | `stuck_job_detection` (TestMixedConformance) | In-process grace period after JobTimeout before a job is treated as stuck and its slot replaced. Observable only as extra concurrency, not in persisted rows. |
+| `config.JobTimeout` | protocol_visible | `timeout_cancellation` (TestMixedConformance) | Timed-out attempts are cancelled and recorded as errors with retry scheduling, which other implementations observe. |
+| `config.LeaderElectionDisabled` | protocol_visible | `leader_election_disabled_both_directions` (TestMixedConformance)
`multi_engine_leader_election_disabled` (TestMultiEngineConformance)
`sqlite_runtime_leader_election_disabled` (TestMixedSQLiteRuntimeConformance) | A client kept out of leader election never writes river_leader or runs leader-owned maintenance while it works jobs alongside eligible clients of any implementation, and rejects periodic jobs. |
+| `config.Logger` | api_equivalent | | Each implementation uses its own logging facility. |
+| `config.MaxAttempts` | protocol_visible | `candidate_insert_reference_work` (TestMixedConformance)
`reference_insert_candidate_work` (TestMixedConformance) | Client-wide default for inserted rows' max_attempts. Its value (25) is persisted in every row inserted without an override, which any implementation may then work, so it must match. The per-insert value is classified as insert_opts.MaxAttempts. |
+| `config.Middleware` | api_equivalent | | Registration of global middleware in each language's idiom. Middleware ordering semantics are exercised through plugin registration in extension_hook_middleware_order. |
+| `config.PeriodicJobs` | protocol_visible | `mixed_leader_death_failover_both_directions` (TestMixedConformance)
`periodic_due_job_available` (TestMaintenanceConformance)
`periodic_run_on_start` (TestMixedConformance)
`sqlite_runtime_periodic_scheduler` (TestMixedSQLiteRuntimeConformance) | Only the elected leader enqueues periodic jobs, tagging them with reserved metadata; duplicate or missing enqueues are visible across implementations. |
+| `config.Plugins` | api_equivalent | `extension_hook_middleware_order` (TestMixedConformance) | Language-native plugin registration; the adapter's instrumented option installs a plugin and the scenario checks hook and middleware ordering. |
+| `config.PollOnly` | api_equivalent | `poll_only_remote_cancellation` (TestMixedConformance)
`simulated_yugabyte_polling` (TestMixedConformance)
`sqlite_runtime_poll_only_recovery` (TestMixedSQLiteRuntimeConformance) | Disables LISTEN in favor of polling. Implementations provide an equivalent notification-free mode, which clients also enter on their own on a PostgreSQL server without LISTEN/NOTIFY, like YugabyteDB by default. |
+| `config.Queues` | protocol_visible | `differential_queue_crud` (TestMixedConformance)
`dynamic_queue_add_reconfigure_remove` (TestMixedConformance) | Queues a client works are persisted as river_queue rows and determine which jobs it fetches. |
+| `config.ReindexerIndexNames` | protocol_visible | `maintenance_reindexer_skips_artifacts` (TestMaintenanceConformance) | Determines which River indexes the leader reindexes. |
+| `config.ReindexerSchedule` | protocol_visible | `maintenance_reindexer_skips_artifacts` (TestMaintenanceConformance) | Determines when the leader reindexes River indexes (midnight UTC by default). |
+| `config.ReindexerTimeout` | internal | | Per-reindex operation timeout; bounds local work only. |
+| `config.RescueStuckJobsAfter` | protocol_visible | `candidate_process_kill_reference_rescue` (TestMixedConformance)
`process_kill_restart_and_rescue` (TestMixedConformance)
`reference_process_kill_candidate_rescue` (TestMixedConformance)
`rescuer_unknown_kind_discard` (TestMixedConformance)
`sqlite_runtime_rescuer_unknown_kind_discard` (TestMixedSQLiteRuntimeConformance) | Running jobs older than this are rescued by the leader, incrementing river:rescue_count and retrying or discarding them; a leader discards jobs of kinds it has no worker for. |
+| `config.RetryPolicy` | protocol_visible | `default_retry_policy_schedule` (TestMixedConformance)
`deterministic_retry_clock_rng` (TestMixedConformance) | Determines scheduled_at for retryable jobs, which is persisted and observed by every implementation. |
+| `config.Schema` | protocol_visible | `custom_schema_candidate_migrate_reference_work` (TestMixedConformance)
`custom_schema_reference_migrate_candidate_work` (TestMixedConformance) | Custom schemas qualify every table and notification topic. |
+| `config.SkipJobKindValidation` | api_equivalent | | Deprecated escape hatch that skips kind-format validation at insert time; implementations may offer an equivalent legacy-kind option. |
+| `config.SkipUnknownJobCheck` | api_equivalent | `mixed_unknown_kind_error` (TestMixedConformance) | Insert-time validation local to the inserting client: it only decides whether that client refuses kinds it has no worker for. The rows it lets through are ordinary jobs, and how a worker treats a kind it doesn't know is covered by mixed_unknown_kind_error. |
+| `config.SoftStopTimeout` | api_equivalent | `hard_shutdown_soft_stop_classification` (TestResilienceConformance) | Local graceful-stop deadline before escalating to cancellation; each implementation offers an equivalent shutdown control. Only when the escalation happens is local; what it persists is a hard stop's outcome, which the cited scenario covers. |
+| `config.Test` | not_applicable | | Go test-environment settings (time generator, unique enforcement toggle). Conformance drives time through the adapter's clock_set instead. |
+| `config.TestOnly` | not_applicable | | Go test-suite switch that removes startup jitter; not part of any production behavior. |
+| `config.WorkerMiddleware` | not_applicable | | Deprecated Go field superseded by Plugins. The worker-middleware concept is classified under extension.rivertype.WorkerMiddleware. |
+| `config.Workers` | api_equivalent | | Language-native worker registry mapping kinds to handlers. |
+
+## insert_opts
+
+Exported fields of `river.InsertOpts`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `insert_opts.MaxAttempts` | protocol_visible | `exhausted_job_retry` (TestMixedConformance)
`mixed_unknown_kind_error` (TestMixedConformance)
`single_implementation_worker_outcomes` (TestMixedConformance)
`sqlite_runtime_exhausted_job_retry` (TestMixedSQLiteRuntimeConformance) | Persisted max_attempts decides between retry and discard. |
+| `insert_opts.Metadata` | protocol_visible | `differential_job_crud` (TestMixedConformance)
`job_row_round_trip_all_fields` (TestMixedConformance) | Persisted job metadata. |
+| `insert_opts.Pending` | protocol_visible | `typed_batch_insertion` (TestMixedConformance) | Inserts rows in the pending state. |
+| `insert_opts.Priority` | protocol_visible | `claim_order` (TestMixedConformance)
`differential_job_crud` (TestMixedConformance)
`sqlite_runtime_claim_order` (TestMixedSQLiteRuntimeConformance)
`typed_batch_insertion` (TestMixedConformance) | Persisted priority; claims take lower priorities first. |
+| `insert_opts.Queue` | protocol_visible | `cross_language_unique_conflict` (TestMixedConformance)
`ignored_cancellation_hard_abort` (TestMixedConformance) | Persisted queue; determines which clients fetch the job. |
+| `insert_opts.ScheduledAt` | protocol_visible | `claim_order` (TestMixedConformance)
`clock_boundary_scheduling` (TestMixedConformance)
`differential_job_list_filters_and_cursors` (TestMixedConformance)
`sqlite_runtime_claim_order` (TestMixedSQLiteRuntimeConformance) | Persisted scheduled_at; an explicit time inserts the job scheduled, even when due, and claims order jobs of equal priority by it. |
+| `insert_opts.Tags` | protocol_visible | `differential_job_crud` (TestMixedConformance)
`typed_batch_insertion` (TestMixedConformance) | Persisted tags. |
+| `insert_opts.UniqueOpts` | protocol_visible | `cross_language_unique_conflict` (TestMixedConformance)
`unique_hash_goldens` (TestMixedConformance) | Controls persisted unique_key and unique_states. |
+
+## unique_opts
+
+Exported fields of `river.UniqueOpts`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `unique_opts.ByArgs` | protocol_visible | `cross_language_unique_conflict` (TestMixedConformance)
`unique_hash_goldens` (TestMixedConformance) | Contributes to the persisted unique_key/unique_states that every implementation must compute identically. |
+| `unique_opts.ByPeriod` | protocol_visible | `cross_language_unique_conflict` (TestMixedConformance)
`unique_hash_goldens` (TestMixedConformance) | Contributes to the persisted unique_key/unique_states that every implementation must compute identically. |
+| `unique_opts.ByQueue` | protocol_visible | `cross_language_unique_conflict` (TestMixedConformance)
`unique_hash_goldens` (TestMixedConformance) | Contributes to the persisted unique_key/unique_states that every implementation must compute identically. |
+| `unique_opts.ByState` | protocol_visible | `cross_language_unique_conflict` (TestMixedConformance)
`unique_hash_goldens` (TestMixedConformance) | Contributes to the persisted unique_key/unique_states that every implementation must compute identically. |
+| `unique_opts.ExcludeKind` | protocol_visible | `sqlite_runtime_unique_skip_keeps_existing_kind` (TestMixedSQLiteRuntimeConformance)
`unique_hash_goldens` (TestMixedConformance)
`unique_skip_keeps_existing_kind` (TestMixedConformance) | Omits kind from the persisted unique_key hash input, so jobs of different kinds can share a unique key; a duplicate insertion of one kind must leave an existing job of another kind unchanged. |
+
+## queue_config
+
+Exported fields of `river.QueueConfig`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `queue_config.FetchCooldown` | api_equivalent | | Per-queue override of config.FetchCooldown for fetching only; local throughput throttle. Insert notifications always use the client-level cooldown. |
+| `queue_config.FetchPollInterval` | api_equivalent | | Per-queue override of config.FetchPollInterval; local polling fallback. |
+| `queue_config.MaxWorkers` | api_equivalent | `dynamic_queue_add_reconfigure_remove` (TestMixedConformance) | Local per-queue concurrency limit; the adapter's queue_add max_workers reconfigures it. |
+
+## periodic_job_opts
+
+Exported fields of `river.PeriodicJobOpts`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `periodic_job_opts.ID` | protocol_visible | `periodic_run_on_start` (TestMixedConformance)
`sqlite_runtime_periodic_scheduler` (TestMixedSQLiteRuntimeConformance) | Persisted as river:periodic_job_id metadata on enqueued periodic jobs. |
+| `periodic_job_opts.RunOnStart` | protocol_visible | `periodic_run_on_start` (TestMixedConformance) | Makes a newly elected leader enqueue the periodic job immediately. |
+
+## client
+
+Exported methods of `*river.Client[TTx]`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `client.Driver` | not_applicable | | Unstable Go accessor for the internal driver seam. |
+| `client.ID` | api_equivalent | | Accessor for the configured or generated client ID (see config.ID). |
+| `client.Insert` | api_equivalent | `candidate_insert_reference_work` (TestMixedConformance)
`reference_insert_candidate_work` (TestMixedConformance) | Each implementation provides Insert in its own idiom; exercised through the corresponding adapter method. |
+| `client.InsertMany` | api_equivalent | `typed_batch_insertion` (TestMixedConformance) | Each implementation provides InsertMany in its own idiom; exercised through the corresponding adapter method. |
+| `client.InsertManyFast` | not_applicable | | Ports don't offer fast insertion yet; batches use ordinary typed insertion. |
+| `client.InsertManyFastTx` | not_applicable | | Ports don't offer fast insertion yet; batches use ordinary typed insertion. |
+| `client.InsertManyTx` | api_equivalent | `transactional_batch_insertion` (TestMixedConformance) | Each implementation provides InsertMany inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.InsertTx` | api_equivalent | `transaction_commit_visibility` (TestMixedConformance)
`transaction_rollback_visibility` (TestMixedConformance) | Each implementation provides Insert inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobCancel` | api_equivalent | `cross_language_cancel_retry_race` (TestMixedConformance)
`differential_job_crud` (TestMixedConformance)
`remote_cancel_notification` (TestMixedConformance) | Each implementation provides JobCancel in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobCancelTx` | api_equivalent | `transactional_cross_language_cancel` (TestMixedConformance) | Each implementation provides JobCancel inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobDelete` | api_equivalent | `differential_job_crud` (TestMixedConformance) | Each implementation provides JobDelete in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobDeleteMany` | api_equivalent | `bulk_delete_safety` (TestMixedConformance) | Each implementation provides JobDeleteMany in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobDeleteManyTx` | api_equivalent | `transactional_crud_commit_rollback` (TestMixedConformance) | Each implementation provides JobDeleteMany inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobDeleteTx` | api_equivalent | `transactional_crud_commit_rollback` (TestMixedConformance) | Each implementation provides JobDelete inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobGet` | api_equivalent | `differential_job_crud` (TestMixedConformance) | Each implementation provides JobGet in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobGetTx` | api_equivalent | `transactional_crud_commit_rollback` (TestMixedConformance) | Each implementation provides JobGet inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobList` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Each implementation provides JobList in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobListTx` | api_equivalent | `transactional_crud_commit_rollback` (TestMixedConformance) | Each implementation provides JobList inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobRetry` | api_equivalent | `cross_language_cancel_retry_race` (TestMixedConformance)
`differential_job_crud` (TestMixedConformance)
`exhausted_job_retry` (TestMixedConformance)
`sqlite_runtime_exhausted_job_retry` (TestMixedSQLiteRuntimeConformance) | Each implementation provides JobRetry in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobRetryTx` | api_equivalent | `transactional_crud_commit_rollback` (TestMixedConformance) | Each implementation provides JobRetry inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobUpdate` | api_equivalent | `differential_job_crud` (TestMixedConformance) | Each implementation provides JobUpdate in its own idiom; exercised through the corresponding adapter method. |
+| `client.JobUpdateTx` | api_equivalent | `transactional_crud_commit_rollback` (TestMixedConformance) | Each implementation provides JobUpdate inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.Notify` | api_equivalent | `mixed_request_resign_terms` (TestMixedConformance) | Go bundle for sending control notifications such as a leader resignation request; the adapter's request_resign method uses it. |
+| `client.PeriodicJobs` | api_equivalent | | Language-native API to add or remove periodic jobs at runtime; the enqueue behavior itself is classified under config.PeriodicJobs. |
+| `client.Pilot` | not_applicable | | Unstable Go accessor for the extension seam. |
+| `client.QueueGet` | api_equivalent | `differential_queue_crud` (TestMixedConformance) | Each implementation provides QueueGet in its own idiom; exercised through the corresponding adapter method. |
+| `client.QueueGetTx` | api_equivalent | `transactional_queue_operations` (TestMixedConformance) | Each implementation provides QueueGet inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.QueueList` | api_equivalent | `differential_queue_crud` (TestMixedConformance) | Each implementation provides QueueList in its own idiom; exercised through the corresponding adapter method. |
+| `client.QueueListTx` | api_equivalent | `transactional_queue_operations` (TestMixedConformance) | Each implementation provides QueueList inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.QueuePause` | api_equivalent | `pause_resume_notification` (TestMixedConformance) | Each implementation provides QueuePause in its own idiom; exercised through the corresponding adapter method. |
+| `client.QueuePauseTx` | api_equivalent | `transactional_queue_operations` (TestMixedConformance) | Each implementation provides QueuePause inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.QueueResume` | api_equivalent | `pause_resume_notification` (TestMixedConformance) | Each implementation provides QueueResume in its own idiom; exercised through the corresponding adapter method. |
+| `client.QueueResumeTx` | api_equivalent | `transactional_queue_operations` (TestMixedConformance) | Each implementation provides QueueResume inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.QueueUpdate` | api_equivalent | `differential_queue_crud` (TestMixedConformance) | Each implementation provides QueueUpdate in its own idiom; exercised through the corresponding adapter method. |
+| `client.QueueUpdateTx` | api_equivalent | `transactional_queue_operations` (TestMixedConformance) | Each implementation provides QueueUpdate inside a caller-managed transaction in its own idiom; exercised through the corresponding adapter method. |
+| `client.Queues` | api_equivalent | `dynamic_queue_add_reconfigure_remove` (TestMixedConformance) | Language-native API to add, reconfigure, and remove worked queues at runtime; the adapter's queue_add/queue_remove use it. |
+| `client.Schema` | api_equivalent | | Accessor for the configured schema (see config.Schema). |
+| `client.Start` | api_equivalent | `sqlite_runtime_lifecycle_shutdown` (TestMixedSQLiteRuntimeConformance) | Language-native client start. |
+| `client.Stop` | api_equivalent | `sqlite_runtime_lifecycle_shutdown` (TestMixedSQLiteRuntimeConformance) | Language-native graceful stop that lets running jobs finish. |
+| `client.StopAndCancel` | api_equivalent | `ignored_cancellation_hard_abort` (TestMixedConformance) | Language-native hard stop that cancels running jobs; a job still ignoring cancellation after the stuck threshold is aborted and its attempt fails. The adapter's stop with cancel uses it. |
+| `client.Stopped` | api_equivalent | | Go channel closed when the client has fully stopped; other languages signal completion in their own idiom. |
+| `client.Subscribe` | api_equivalent | `remote_queue_subscription_events` (TestMixedConformance)
`sqlite_runtime_extensions_resumable_subscriptions` (TestMixedSQLiteRuntimeConformance) | Language-native local event subscription; the adapter reports observed events via runtime_stats. |
+| `client.SubscribeConfig` | not_applicable | | Go-specific variant of Subscribe that overrides the channel buffer size. |
+
+## job_list_params
+
+Exported builder methods of `*river.JobListParams`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `job_list_params.After` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.First` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.IDs` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.Kinds` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.Metadata` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.OrderBy` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.Priorities` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.Queues` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.States` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.TagsAll` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.TagsAny` | api_equivalent | `differential_job_list_filters_and_cursors` (TestMixedConformance) | Portable job list filter/ordering/cursor option; the adapter's list method accepts it. |
+| `job_list_params.Where` | not_applicable | | Accepts a raw SQL predicate with Go named arguments; tied to the Go driver's SQL dialect and not part of the portable list contract. |
+
+## job_delete_many_params
+
+Exported builder methods of `*river.JobDeleteManyParams`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `job_delete_many_params.First` | api_equivalent | `bulk_delete_safety` (TestMixedConformance) | Portable bulk-delete filter; the adapter's delete_many method accepts it. |
+| `job_delete_many_params.IDs` | api_equivalent | `bulk_delete_safety` (TestMixedConformance) | Portable bulk-delete filter; the adapter's delete_many method accepts it. |
+| `job_delete_many_params.Kinds` | api_equivalent | `bulk_delete_safety` (TestMixedConformance) | Portable bulk-delete filter; the adapter's delete_many method accepts it. |
+| `job_delete_many_params.Priorities` | api_equivalent | | Portable bulk-delete filter by priority; not yet accepted by the adapter's delete_many method. |
+| `job_delete_many_params.Queues` | api_equivalent | `bulk_delete_safety` (TestMixedConformance) | Portable bulk-delete filter; the adapter's delete_many method accepts it. |
+| `job_delete_many_params.States` | api_equivalent | `bulk_delete_safety` (TestMixedConformance) | Portable bulk-delete filter; the adapter's delete_many method accepts it. |
+| `job_delete_many_params.UnsafeAll` | api_equivalent | `bulk_delete_safety` (TestMixedConformance) | Portable bulk-delete filter; the adapter's delete_many method accepts it. |
+
+## queue_list_params
+
+Exported builder methods of `*river.QueueListParams`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `queue_list_params.First` | api_equivalent | `differential_queue_crud` (TestMixedConformance) | Queue list limit; the adapter's queue_list method accepts it. |
+
+## job_state
+
+Values of `rivertype.JobStates()`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `job_state.available` | protocol_visible | `differential_job_crud` (TestMixedConformance)
`single_implementation_worker_outcomes` (TestMixedConformance) | Persisted river_job.state value. |
+| `job_state.cancelled` | protocol_visible | `differential_job_crud` (TestMixedConformance)
`remote_cancel_notification` (TestMixedConformance) | Persisted river_job.state value. |
+| `job_state.completed` | protocol_visible | `single_implementation_worker_outcomes` (TestMixedConformance) | Persisted river_job.state value. |
+| `job_state.discarded` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`single_implementation_worker_outcomes` (TestMixedConformance) | Persisted river_job.state value. |
+| `job_state.pending` | protocol_visible | `typed_batch_insertion` (TestMixedConformance) | Persisted river_job.state value. |
+| `job_state.retryable` | protocol_visible | `deterministic_retry_clock_rng` (TestMixedConformance)
`single_implementation_worker_outcomes` (TestMixedConformance) | Persisted river_job.state value. |
+| `job_state.running` | protocol_visible | `single_implementation_worker_outcomes` (TestMixedConformance) | Persisted river_job.state value. |
+| `job_state.scheduled` | protocol_visible | `clock_boundary_scheduling` (TestMixedConformance)
`differential_job_list_filters_and_cursors` (TestMixedConformance) | Persisted river_job.state value. |
+
+## event_kind
+
+Exported `river.EventKind*` constants.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `event_kind.job_cancelled` | api_equivalent | | Local subscription event in each implementation's idiom; not yet asserted by a shared scenario. |
+| `event_kind.job_completed` | api_equivalent | `sqlite_runtime_extensions_resumable_subscriptions` (TestMixedSQLiteRuntimeConformance) | Local subscription event; the adapter reports observed events via runtime_stats. |
+| `event_kind.job_failed` | api_equivalent | `sqlite_runtime_extensions_resumable_subscriptions` (TestMixedSQLiteRuntimeConformance) | Local subscription event; the adapter reports observed events via runtime_stats. |
+| `event_kind.job_interrupted` | api_equivalent | | Local subscription event in each implementation's idiom; not yet asserted by a shared scenario. |
+| `event_kind.job_snoozed` | api_equivalent | | Local subscription event in each implementation's idiom; not yet asserted by a shared scenario. |
+| `event_kind.queue_paused` | api_equivalent | `remote_queue_subscription_events` (TestMixedConformance)
`sqlite_runtime_remote_queue_subscription_events` (TestMixedSQLiteRuntimeConformance) | Local subscription event raised when a pause control notification arrives, including from another implementation. |
+| `event_kind.queue_resumed` | api_equivalent | `remote_queue_subscription_events` (TestMixedConformance)
`sqlite_runtime_remote_queue_subscription_events` (TestMixedSQLiteRuntimeConformance) | Local subscription event raised when a resume control notification arrives, including from another implementation. |
+
+## metadata_key
+
+Reserved job metadata keys written or read by River, from Go constants, Go metadata helpers, and driver SQL.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `metadata_key.cancel_attempted_at` | protocol_visible | `reserved_metadata_cross_engine` (TestMixedConformance) | Written by cancellation of a running job; tells the rescuer not to rescue it. |
+| `metadata_key.output` | protocol_visible | `differential_job_crud` (TestMixedConformance)
`reserved_metadata_cross_engine` (TestMixedConformance) | Recorded job output; the adapter's update output writes it and the other implementation reads it. |
+| `metadata_key.periodic` | protocol_visible | `periodic_run_on_start` (TestMixedConformance) | Marks jobs inserted by the periodic job enqueuer. |
+| `metadata_key.river:log` | protocol_visible | `reserved_metadata_cross_engine` (TestMixedConformance) | Written only by the optional Go riverlog middleware; other implementations need not write it. Every implementation must carry it through snoozes, cancellations, and completions unchanged, like other metadata it doesn't own. |
+| `metadata_key.river:periodic_job_id` | protocol_visible | `periodic_run_on_start` (TestMixedConformance)
`sqlite_runtime_periodic_scheduler` (TestMixedSQLiteRuntimeConformance) | Identifies the periodic job that enqueued a job. |
+| `metadata_key.river:rescue_count` | protocol_visible | `reserved_metadata_cross_engine` (TestMixedConformance) | Incremented by the rescuer each time a stuck job is rescued. |
+| `metadata_key.river:resumable_cursor` | protocol_visible | `resumable_cross_engine_cursor` (TestMixedConformance)
`resumable_validation` (TestMixedConformance) | Resumable job cursor state carried across attempts and engines. |
+| `metadata_key.river:resumable_step` | protocol_visible | `resumable_cross_engine_cursor` (TestMixedConformance)
`resumable_retry` (TestMixedConformance)
`resumable_validation` (TestMixedConformance) | Last completed resumable step carried across attempts and engines. |
+| `metadata_key.river:unique_nonce` | protocol_visible | `simulated_yugabyte_polling` (TestMixedConformance)
`sqlite_insert_get_unique_cross_language` (TestMixedSQLiteConformance) | Unique insert nonce used to detect whether a unique insert was skipped as a duplicate where xmax is unavailable: always on SQLite, and on YugabyteDB. |
+| `metadata_key.snoozes` | protocol_visible | `reserved_metadata_cross_engine` (TestMixedConformance)
`snooze_once_metadata_transition` (TestMixedConformance) | Snooze counter; snoozing increments it without consuming an attempt. |
+| `metadata_key.unique_key_conflict` | protocol_visible | `scheduler_unique_conflict_discard` (TestMixedConformance)
`sqlite_runtime_job_rows` (TestMixedSQLiteRuntimeConformance)
`sqlite_runtime_scheduler_unique_conflict_discard` (TestMixedSQLiteRuntimeConformance) | Marker the leader's scheduler writes, with the value `scheduler_discarded`, when it discards a due retryable or scheduled unique job whose key a live job holds or an earlier due job shares. |
+
+## notification_topic
+
+Notification topics declared by `internal/notifier`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `notification_topic.river_control` | protocol_visible | `pause_resume_notification` (TestMixedConformance)
`remote_cancel_notification` (TestMixedConformance) | Control channel for cancel, pause, resume, and metadata changes. |
+| `notification_topic.river_insert` | protocol_visible | `notification_only_wakeups` (TestMixedConformance)
`transactional_insert_notification_commit_only` (TestMixedConformance) | Insert wakeup channel. |
+| `notification_topic.river_leadership` | protocol_visible | `mixed_leader_failover_both_directions` (TestMixedConformance)
`mixed_request_resign_terms` (TestMixedConformance) | Leadership resignation channel. |
+
+## notification_payload
+
+Notification payload shapes and action values, from Go payload structs and `pg_notify` SQL.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `notification_payload.control` | protocol_visible | `pause_resume_notification` (TestMixedConformance)
`remote_cancel_notification` (TestMixedConformance)
`remote_queue_subscription_events` (TestMixedConformance) | JSON shape of control notifications. |
+| `notification_payload.control.action.cancel` | protocol_visible | `remote_cancel_notification` (TestMixedConformance)
`transactional_cross_language_cancel` (TestMixedConformance) | Cancels a running job on the client working it. |
+| `notification_payload.control.action.metadata_changed` | protocol_visible | `differential_queue_crud` (TestMixedConformance) | Sent on the shared control channel by a queue metadata update from any implementation. River Go's producers react at once by passing the new metadata to their extension, so every implementation must send the same payload. |
+| `notification_payload.control.action.pause` | protocol_visible | `pause_resume_notification` (TestMixedConformance)
`remote_queue_subscription_events` (TestMixedConformance) | Pauses fetching for a queue on every client. |
+| `notification_payload.control.action.resume` | protocol_visible | `pause_resume_notification` (TestMixedConformance)
`remote_queue_subscription_events` (TestMixedConformance) | Resumes fetching for a queue on every client. |
+| `notification_payload.insert` | protocol_visible | `notification_only_wakeups` (TestMixedConformance)
`transactional_insert_notification_commit_only` (TestMixedConformance) | JSON shape of insert wakeup notifications. |
+| `notification_payload.leadership` | protocol_visible | `mixed_leader_failover_both_directions` (TestMixedConformance)
`mixed_request_resign_terms` (TestMixedConformance) | JSON shape of leadership notifications. |
+| `notification_payload.leadership.action.request_resign` | protocol_visible | `mixed_request_resign_terms` (TestMixedConformance) | Asks the current leader to resign. |
+| `notification_payload.leadership.action.resigned` | protocol_visible | `mixed_leader_failover_both_directions` (TestMixedConformance)
`mixed_request_resign_terms` (TestMixedConformance) | Announces a resignation so followers attempt election immediately. |
+| `notification_payload.sql.job_cancel` | protocol_visible | `remote_cancel_notification` (TestMixedConformance)
`transactional_cross_language_cancel` (TestMixedConformance) | Cancel notification emitted by the cancel query itself. |
+| `notification_payload.sql.leader_resign` | protocol_visible | `mixed_leader_failover_both_directions` (TestMixedConformance) | Resignation notification emitted by the resign query itself. |
+
+## driver
+
+Methods of the exported `riverdriver` interfaces.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `driver.Driver.ArgPlaceholder` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.DatabaseName` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.GetExecutor` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.GetListener` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.GetMigrationDefaultLines` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.GetMigrationFS` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.GetMigrationLines` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.GetMigrationTruncateTables` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.PoolIsSet` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.PoolSet` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.SQLFragmentColumnContainsAll` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.SQLFragmentColumnContainsAny` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.SQLFragmentColumnIn` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.SupportsListenNotify` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.SupportsListener` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.TimePrecision` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.UnwrapExecutor` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Driver.UnwrapTx` | driver_specific | | Go database-driver adapter plumbing; other implementations integrate their database libraries directly. |
+| `driver.Executor.Begin` | driver_specific | | Go driver-seam primitive for raw statement execution or transactions. |
+| `driver.Executor.ColumnExists` | driver_specific | | Go driver-seam method for schema introspection used by the migrator. |
+| `driver.Executor.Exec` | driver_specific | | Go driver-seam primitive for raw statement execution or transactions. |
+| `driver.Executor.IndexDropIfExists` | driver_specific | | Go driver-seam method for index introspection and maintenance used by the reindexer and tests. |
+| `driver.Executor.IndexExists` | driver_specific | | Go driver-seam method for index introspection and maintenance used by the reindexer and tests. |
+| `driver.Executor.IndexReindex` | driver_specific | | Go driver-seam method for index introspection and maintenance used by the reindexer and tests. |
+| `driver.Executor.IndexReindexArtifacts` | driver_specific | | Go driver-seam method for index introspection and maintenance used by the reindexer and tests. |
+| `driver.Executor.IndexesExist` | driver_specific | | Go driver-seam method for index introspection and maintenance used by the reindexer and tests. |
+| `driver.Executor.InitDriver` | driver_specific | | Go driver-seam method that detects server capabilities, such as YugabyteDB lacking LISTEN/NOTIFY and xmax, before a client starts; simulated_yugabyte_polling covers their effects across implementations. |
+| `driver.Executor.JobCancel` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobCountByAllStates` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobCountByQueueAndState` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobCountByState` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobDelete` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobDeleteBefore` | driver_specific | `job_cleaner_queue_filters` (TestMixedConformance)
`sqlite_runtime_job_cleaner_queue_filters` (TestMixedSQLiteRuntimeConformance) | Go driver-seam method for the job cleaner's deletion, also reused by extensions' own cleaner passes. The adapter's delete_finalized method runs it directly so queue inclusion and exclusion are checked before the batch limit on every engine. |
+| `driver.Executor.JobDeleteMany` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobGetAvailable` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobGetByID` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobGetByIDMany` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobGetByKindMany` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobGetCancelRequested` | driver_specific | | Go driver-seam query through which clients without a notifier poll their running jobs for cancellation requests; the resulting cancellation is covered by poll_only_remote_cancellation. |
+| `driver.Executor.JobGetStuck` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobInsertFastMany` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobInsertFastManyNoReturning` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobInsertFull` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobInsertFullMany` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobKindList` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobList` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobRescueMany` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobRetry` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobSchedule` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobSetStateIfRunningMany` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobUpdate` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.JobUpdateFull` | driver_specific | | Go driver-seam method for job queries; their persisted effects are covered by the client, job_state, and metadata_key items. |
+| `driver.Executor.LeaderAttemptElect` | driver_specific | | Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios. |
+| `driver.Executor.LeaderAttemptReelect` | driver_specific | | Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios. |
+| `driver.Executor.LeaderDeleteExpired` | driver_specific | | Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios. |
+| `driver.Executor.LeaderGetElectedLeader` | driver_specific | | Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios. |
+| `driver.Executor.LeaderInsert` | driver_specific | | Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios. |
+| `driver.Executor.LeaderResign` | driver_specific | | Go driver-seam method for leader election queries; leadership behavior is covered by the leadership notification items and failover scenarios. |
+| `driver.Executor.MigrationDeleteAssumingMainMany` | driver_specific | | Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items. |
+| `driver.Executor.MigrationDeleteByLineAndVersionMany` | driver_specific | | Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items. |
+| `driver.Executor.MigrationGetAllAssumingMain` | driver_specific | | Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items. |
+| `driver.Executor.MigrationGetByLine` | driver_specific | | Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items. |
+| `driver.Executor.MigrationInsertMany` | driver_specific | | Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items. |
+| `driver.Executor.MigrationInsertManyAssumingMain` | driver_specific | | Go driver-seam method for migration bookkeeping queries; schema versions are covered by the migration items. |
+| `driver.Executor.NotificationDeleteBefore` | driver_specific | | Go driver-seam method for notification queries; notification behavior is covered by the notification items. |
+| `driver.Executor.NotifyMany` | driver_specific | | Go driver-seam method for notification queries; notification behavior is covered by the notification items. |
+| `driver.Executor.PGAdvisoryXactLock` | driver_specific | | Go driver-seam method for PostgreSQL advisory lock helper. |
+| `driver.Executor.Ping` | driver_specific | | Go driver-seam connectivity check made when a client starts. |
+| `driver.Executor.QueryRow` | driver_specific | | Go driver-seam primitive for raw statement execution or transactions. |
+| `driver.Executor.QueueCreateOrSetUpdatedAt` | driver_specific | | Go driver-seam method for queue queries; their persisted effects are covered by the client queue items. |
+| `driver.Executor.QueueDeleteExpired` | driver_specific | | Go driver-seam method for queue queries; their persisted effects are covered by the client queue items. |
+| `driver.Executor.QueueGet` | driver_specific | | Go driver-seam method for queue queries; their persisted effects are covered by the client queue items. |
+| `driver.Executor.QueueList` | driver_specific | | Go driver-seam method for queue queries; their persisted effects are covered by the client queue items. |
+| `driver.Executor.QueueNameList` | driver_specific | | Go driver-seam method for queue queries; their persisted effects are covered by the client queue items. |
+| `driver.Executor.QueuePause` | driver_specific | | Go driver-seam method for queue queries; their persisted effects are covered by the client queue items. |
+| `driver.Executor.QueueResume` | driver_specific | | Go driver-seam method for queue queries; their persisted effects are covered by the client queue items. |
+| `driver.Executor.QueueUpdate` | driver_specific | | Go driver-seam method for queue queries; their persisted effects are covered by the client queue items. |
+| `driver.Executor.SchemaCreate` | driver_specific | | Go driver-seam method for schema management used by maintenance and tests. |
+| `driver.Executor.SchemaDrop` | driver_specific | | Go driver-seam method for schema management used by maintenance and tests. |
+| `driver.Executor.SchemaGetExpired` | driver_specific | | Go driver-seam method for schema management used by maintenance and tests. |
+| `driver.Executor.TableExists` | driver_specific | | Go driver-seam method for table introspection and truncation used by the migrator and tests. |
+| `driver.Executor.TableTruncate` | driver_specific | | Go driver-seam method for table introspection and truncation used by the migrator and tests. |
+| `driver.ExecutorTx.Commit` | driver_specific | | Go transaction wrapper in the driver seam; transaction semantics are covered by the client transaction items. |
+| `driver.ExecutorTx.Executor` | driver_specific | | Go transaction wrapper in the driver seam; transaction semantics are covered by the client transaction items. |
+| `driver.ExecutorTx.Rollback` | driver_specific | | Go transaction wrapper in the driver seam; transaction semantics are covered by the client transaction items. |
+| `driver.Listener.Close` | driver_specific | | Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items. |
+| `driver.Listener.Connect` | driver_specific | | Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items. |
+| `driver.Listener.Listen` | driver_specific | | Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items. |
+| `driver.Listener.Ping` | driver_specific | | Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items. |
+| `driver.Listener.Schema` | driver_specific | | Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items. |
+| `driver.Listener.SetAfterConnectExec` | driver_specific | | Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items. |
+| `driver.Listener.Unlisten` | driver_specific | | Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items. |
+| `driver.Listener.WaitForNotification` | driver_specific | | Go LISTEN connection wrapper in the driver seam; notification behavior is covered by the notification_topic and notification_payload items. |
+| `driver.Row.Scan` | driver_specific | | Go row-scanning wrapper in the driver seam. |
+
+## extension
+
+Methods of the extension interfaces in `rivershared/riverpilot` and the hook, middleware, and plugin interfaces in `rivertype`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `extension.riverpilot.Pilot.JobCancel` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.JobCleanerQueuesExcluded` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.JobGetAvailable` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.JobInsertMany` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.JobRetry` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.JobSetStateIfRunningMany` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.PilotInit` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.PilotPeriodicJob` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.ProducerInit` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.ProducerKeepAlive` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.ProducerShutdown` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.Pilot.QueueMetadataChanged` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.PilotJobRescuer.JobGetStuck` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.PilotJobRescuer.JobRescueMany` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.PilotPeriodicJob.PeriodicJobGetAll` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.PilotPeriodicJob.PeriodicJobKeepAliveAndReap` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.PilotPeriodicJob.PeriodicJobUpsertMany` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.riverpilot.ProducerState.JobFinish` | internal | | Part of the unstable Go extension seam (riverpilot) used to substitute storage operations. Implementations may have their own seam; it carries no cross-language contract, and the default behavior's effects are covered by the driver and protocol items. |
+| `extension.rivertype.Hook.IsHook` | not_applicable | | Go marker method used to discriminate hook, middleware, and plugin values; other languages express this with their own type systems. |
+| `extension.rivertype.HookInsertBegin.Hook` | not_applicable | | Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems. |
+| `extension.rivertype.HookInsertBegin.InsertBegin` | api_equivalent | `extension_hook_middleware_order` (TestMixedConformance) | Insert-begin hook in each language's idiom; ordering is checked through the adapter's instrumented plugin. |
+| `extension.rivertype.HookMetricEmit.Hook` | not_applicable | | Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems. |
+| `extension.rivertype.HookMetricEmit.MetricEmit` | not_applicable | | Go-specific metric hook; other implementations expose telemetry through their own instrumentation. |
+| `extension.rivertype.HookPeriodicJobsStart.Hook` | not_applicable | | Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems. |
+| `extension.rivertype.HookPeriodicJobsStart.Start` | api_equivalent | `periodic_run_on_start` (TestMixedConformance) | Periodic-jobs-start hook in each language's idiom; the adapter's instrumented plugin counts invocations. |
+| `extension.rivertype.HookWorkBegin.Hook` | not_applicable | | Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems. |
+| `extension.rivertype.HookWorkBegin.WorkBegin` | api_equivalent | `extension_hook_middleware_order` (TestMixedConformance) | Work-begin hook in each language's idiom; ordering is checked through the adapter's instrumented plugin. |
+| `extension.rivertype.HookWorkEnd.Hook` | not_applicable | | Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems. |
+| `extension.rivertype.HookWorkEnd.WorkEnd` | api_equivalent | `extension_hook_middleware_order` (TestMixedConformance) | Work-end hook in each language's idiom; ordering is checked through the adapter's instrumented plugin. |
+| `extension.rivertype.JobInsertMiddleware.InsertMany` | api_equivalent | `extension_hook_middleware_order` (TestMixedConformance) | Insert middleware in each language's idiom; ordering is checked through the adapter's instrumented plugin. |
+| `extension.rivertype.JobInsertMiddleware.Middleware` | not_applicable | | Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems. |
+| `extension.rivertype.Middleware.IsMiddleware` | not_applicable | | Go marker method used to discriminate hook, middleware, and plugin values; other languages express this with their own type systems. |
+| `extension.rivertype.Plugin.IsPlugin` | not_applicable | | Go marker method used to discriminate hook, middleware, and plugin values; other languages express this with their own type systems. |
+| `extension.rivertype.WorkerMiddleware.Middleware` | not_applicable | | Go interface composition marking the value as a hook or middleware; other languages express this with their own type systems. |
+| `extension.rivertype.WorkerMiddleware.Work` | api_equivalent | `extension_hook_middleware_order` (TestMixedConformance) | Work middleware in each language's idiom; ordering is checked through the adapter's instrumented plugin. |
+
+## interface
+
+Methods of the exported interfaces in the `river` package, such as the optional interfaces job args and workers implement.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `interface.ClientRetryPolicy.NextRetry` | api_equivalent | `default_retry_policy_schedule` (TestMixedConformance)
`deterministic_retry_clock_rng` (TestMixedConformance) | Retry policy in each language's idiom; its persisted effect is classified under config.RetryPolicy. |
+| `interface.ErrorHandler.HandleError` | api_equivalent | `error_handler_cancel_override` (TestMixedConformance) | Error callback in each language's idiom; its persisted effect is exercised through the adapter's error_handler_cancel start option. |
+| `interface.ErrorHandler.HandlePanic` | api_equivalent | `panic_attempt_trace` (TestMixedConformance) | Panic callback in each language's idiom; implementations without panics map it to their own abnormal termination. The persisted panic attempt is checked separately. |
+| `interface.JobArgs.Kind` | protocol_visible | `candidate_insert_reference_work` (TestMixedConformance)
`mixed_unknown_kind_error` (TestMixedConformance)
`reference_insert_candidate_work` (TestMixedConformance)
`sqlite_runtime_unknown_kind_error` (TestMixedSQLiteRuntimeConformance) | The kind is persisted with every job and selects the worker in any implementation; a job of an unregistered kind fails the same way everywhere. |
+| `interface.JobArgsWithHooks.Hooks` | api_equivalent | | Per-kind hooks in each language's idiom; hook ordering is checked through globally installed plugins in extension_hook_middleware_order. |
+| `interface.JobArgsWithInsertOpts.InsertOpts` | api_equivalent | | Per-kind insertion defaults in each language's idiom; the persisted options are classified under insert_opts. |
+| `interface.JobArgsWithKindAliases.KindAliases` | protocol_visible | `kind_alias_rename` (TestMixedConformance)
`sqlite_runtime_kind_alias_rename` (TestMixedSQLiteRuntimeConformance) | Former kinds a worker also works during a safe rename, so jobs an older deployment inserted under the old kind aren't orphaned; the alias also counts as a known kind for FetchOnlyKnownKinds. |
+| `interface.JobArgsWithPlugins.Plugins` | api_equivalent | | Per-kind plugins in each language's idiom; plugin hook and middleware ordering is checked through globally installed plugins in extension_hook_middleware_order. |
+| `interface.PeriodicSchedule.Next` | api_equivalent | `cron_schedule_goldens` (TestMaintenanceConformance) | Periodic schedule in each language's idiom; cron schedules are checked against Go-generated goldens. |
+| `interface.Worker.Middleware` | api_equivalent | `extension_hook_middleware_order` (TestMixedConformance) | Per-worker middleware in each language's idiom; middleware ordering is checked through globally installed plugins. |
+| `interface.Worker.NextRetry` | api_equivalent | | Per-worker retry override in each language's idiom; like config.RetryPolicy, its effect is the persisted scheduled_at of a retryable job, which shared scenarios check for the client-level policy. |
+| `interface.Worker.Timeout` | api_equivalent | `maintenance_rescuer_full_batch_of_unexpired_jobs` (TestMaintenanceConformance)
`timeout_cancellation` (TestMixedConformance) | Per-worker timeout override in each language's idiom; the client-level timeout's cancellation and the rescuer's use of timeouts are checked by shared scenarios. |
+| `interface.Worker.Work` | api_equivalent | `single_implementation_worker_outcomes` (TestMixedConformance)
`sqlite_runtime_job_rows` (TestMixedSQLiteRuntimeConformance) | The work function in each language's idiom; its outcomes are persisted the same way in every implementation. |
+
+## function
+
+Exported functions of the `river` package.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `function.AddWorker` | api_equivalent | `candidate_insert_reference_work` (TestMixedConformance)
`reference_insert_candidate_work` (TestMixedConformance)
`sqlite_runtime_cross_language_work` (TestMixedSQLiteRuntimeConformance) | Language-native worker registration; the kinds a client registers decide which jobs it can work, exercised whenever one implementation works another's jobs. |
+| `function.AddWorkerArgs` | not_applicable | | Go test helper that registers a worker for an explicit args value; documented as internal-only, with no counterpart elsewhere. |
+| `function.AddWorkerSafely` | api_equivalent | `candidate_insert_reference_work` (TestMixedConformance)
`reference_insert_candidate_work` (TestMixedConformance)
`sqlite_runtime_cross_language_work` (TestMixedSQLiteRuntimeConformance) | Language-native worker registration; the kinds a client registers decide which jobs it can work, exercised whenever one implementation works another's jobs. |
+| `function.ClientFromContext` | api_equivalent | | Language-native access to the working client from a worker's context; has no persisted effect of its own. |
+| `function.ClientFromContextSafely` | api_equivalent | | Language-native access to the working client from a worker's context; has no persisted effect of its own. |
+| `function.JobCancel` | api_equivalent | `single_implementation_worker_outcomes` (TestMixedConformance) | Worker-side cancellation in each language's idiom; the persisted cancelled row and its error are exercised through the adapter's `cancel` worker behavior. |
+| `function.JobCompleteTx` | api_equivalent | `transactional_completion` (TestMixedConformance) | Transactional completion from a worker in each language's idiom; exercised through the adapter's `transactional_complete` worker behavior. |
+| `function.JobListCursorFromJob` | api_equivalent | `job_list_cursor_interchange` (TestMixedConformance)
`sqlite_runtime_job_list_cursor_interchange` (TestMixedSQLiteRuntimeConformance) | Builds a job list cursor from a row; the cursor's encoding is exchanged between implementations through the adapter's list method. |
+| `function.JobSnooze` | api_equivalent | `snooze_once_metadata_transition` (TestMixedConformance)
`sqlite_runtime_job_rows` (TestMixedSQLiteRuntimeConformance) | Worker-side snooze in each language's idiom; the persisted snooze transition is exercised through the adapter's `snooze_once` worker behavior. |
+| `function.MetadataSet` | api_equivalent | `resumable_cross_engine_cursor` (TestMixedConformance)
`transactional_completion` (TestMixedConformance) | Worker-side metadata updates merged into the row when the attempt finishes, in each language's idiom; exercised by the adapter's resumable cursor and transactional completion behaviors. |
+| `function.NeverSchedule` | api_equivalent | | Language-native periodic schedule that never fires; it inserts nothing, so it has no cross-language effect. |
+| `function.NewClient` | api_equivalent | | Language-native client construction. |
+| `function.NewJobDeleteManyParams` | api_equivalent | | Language-native constructor for bulk delete parameters; the filters are classified under job_delete_many_params. |
+| `function.NewJobListParams` | api_equivalent | | Language-native constructor for job list parameters; the filters are classified under job_list_params. |
+| `function.NewPeriodicJob` | api_equivalent | `periodic_run_on_start` (TestMixedConformance)
`periodic_unique_cross_engine` (TestMixedConformance) | Language-native periodic job construction; the enqueue behavior is classified under config.PeriodicJobs. |
+| `function.NewQueueListParams` | api_equivalent | | Language-native constructor for queue list parameters. |
+| `function.NewWorkers` | api_equivalent | `candidate_insert_reference_work` (TestMixedConformance)
`reference_insert_candidate_work` (TestMixedConformance)
`sqlite_runtime_cross_language_work` (TestMixedSQLiteRuntimeConformance) | Language-native worker registration; the kinds a client registers decide which jobs it can work, exercised whenever one implementation works another's jobs. |
+| `function.PeriodicInterval` | api_equivalent | `periodic_run_on_start` (TestMixedConformance) | Fixed-interval periodic schedule in each language's idiom; the adapter's run-on-start periodic job uses it. |
+| `function.RecordOutput` | api_equivalent | `single_implementation_worker_outcomes` (TestMixedConformance)
`sqlite_runtime_job_rows` (TestMixedSQLiteRuntimeConformance) | Records job output from a worker in each language's idiom; the persisted `output` metadata is exercised through the adapter's `output` worker behavior. |
+| `function.ReindexerIndexNamesDefault` | api_equivalent | `maintenance_reindexer_skips_artifacts` (TestMaintenanceConformance) | Default index set of the leader's reindexer; scenarios pass an explicit set through the adapter's `reindexer_index_names`. |
+| `function.ResumableSetCursor` | api_equivalent | `resumable_cross_engine_cursor` (TestMixedConformance)
`resumable_retry` (TestMixedConformance)
`sqlite_runtime_resumable_cross_engine_cursor` (TestMixedSQLiteRuntimeConformance) | Resumable step cursor in each language's idiom; the persisted cursor metadata is read across implementations. |
+| `function.ResumableSetStepCursorTx` | api_equivalent | | Transactional resumable checkpoint in each language's idiom; it writes the same reserved metadata as the non-transactional path, which shared scenarios cover. |
+| `function.ResumableSetStepTx` | api_equivalent | | Transactional resumable checkpoint in each language's idiom; it writes the same reserved metadata as the non-transactional path, which shared scenarios cover. |
+| `function.ResumableStep` | api_equivalent | `resumable_cross_engine_cursor` (TestMixedConformance)
`resumable_retry` (TestMixedConformance)
`sqlite_runtime_resumable_cross_engine_cursor` (TestMixedSQLiteRuntimeConformance) | Resumable step in each language's idiom; the persisted step metadata is read across implementations. |
+| `function.ResumableStepCursor` | api_equivalent | `resumable_cross_engine_cursor` (TestMixedConformance)
`resumable_retry` (TestMixedConformance)
`sqlite_runtime_resumable_cross_engine_cursor` (TestMixedSQLiteRuntimeConformance) | Resumable step with a cursor in each language's idiom; the persisted cursor metadata is read across implementations. |
+| `function.WorkFunc` | api_equivalent | | Language-native shorthand for a worker defined by a function. |
+
+## rivertype_field
+
+Exported fields of the exported structs in `rivertype`.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `rivertype_field.AttemptError.At` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`panic_attempt_trace` (TestMixedConformance)
`single_implementation_worker_outcomes` (TestMixedConformance)
`sqlite_runtime_job_rows` (TestMixedSQLiteRuntimeConformance) | Field of an element of `river_job.errors`, written by the implementation that finishes an attempt and read by every other. |
+| `rivertype_field.AttemptError.Attempt` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`panic_attempt_trace` (TestMixedConformance)
`single_implementation_worker_outcomes` (TestMixedConformance)
`sqlite_runtime_job_rows` (TestMixedSQLiteRuntimeConformance) | Field of an element of `river_job.errors`, written by the implementation that finishes an attempt and read by every other. |
+| `rivertype_field.AttemptError.Error` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`panic_attempt_trace` (TestMixedConformance)
`single_implementation_worker_outcomes` (TestMixedConformance)
`sqlite_runtime_job_rows` (TestMixedSQLiteRuntimeConformance) | Field of an element of `river_job.errors`, written by the implementation that finishes an attempt and read by every other. |
+| `rivertype_field.AttemptError.Trace` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`panic_attempt_trace` (TestMixedConformance)
`single_implementation_worker_outcomes` (TestMixedConformance)
`sqlite_runtime_job_rows` (TestMixedSQLiteRuntimeConformance) | Field of an element of `river_job.errors`, written by the implementation that finishes an attempt and read by every other. |
+| `rivertype_field.DurablePeriodicJob.CreatedAt` | internal | | Record of a periodic job persisted through the unstable Go extension seam (riverpilot); River itself persists none, so it carries no cross-language contract. |
+| `rivertype_field.DurablePeriodicJob.ID` | internal | | Record of a periodic job persisted through the unstable Go extension seam (riverpilot); River itself persists none, so it carries no cross-language contract. |
+| `rivertype_field.DurablePeriodicJob.NextRunAt` | internal | | Record of a periodic job persisted through the unstable Go extension seam (riverpilot); River itself persists none, so it carries no cross-language contract. |
+| `rivertype_field.DurablePeriodicJob.UpdatedAt` | internal | | Record of a periodic job persisted through the unstable Go extension seam (riverpilot); River itself persists none, so it carries no cross-language contract. |
+| `rivertype_field.HookMetricEmitParams.Metric` | not_applicable | | Parameter of the Go-specific metric hook; other implementations expose telemetry through their own instrumentation. |
+| `rivertype_field.HookPeriodicJobsStartParams.DurableJobs` | internal | | Record of a periodic job persisted through the unstable Go extension seam (riverpilot); River itself persists none, so it carries no cross-language contract. |
+| `rivertype_field.JobGetAvailableCountMetric.Count` | not_applicable | | Parameter of the Go-specific metric hook; other implementations expose telemetry through their own instrumentation. |
+| `rivertype_field.JobGetAvailableCountMetric.Queue` | not_applicable | | Parameter of the Go-specific metric hook; other implementations expose telemetry through their own instrumentation. |
+| `rivertype_field.JobGetAvailableDurationMetric.Duration` | not_applicable | | Parameter of the Go-specific metric hook; other implementations expose telemetry through their own instrumentation. |
+| `rivertype_field.JobGetAvailableDurationMetric.Queue` | not_applicable | | Parameter of the Go-specific metric hook; other implementations expose telemetry through their own instrumentation. |
+| `rivertype_field.JobInsertParams.Args` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.CreatedAt` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.EncodedArgs` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.ID` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.Kind` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.MaxAttempts` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.Metadata` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.Priority` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.Queue` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.ScheduledAt` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.State` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.Tags` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.UniqueKey` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertParams.UniqueStates` | api_equivalent | | Field of a job as insert middleware sees it before insertion, in each language's idiom; the persisted values are classified under rivertype_field.JobRow. |
+| `rivertype_field.JobInsertResult.Job` | api_equivalent | `cross_language_unique_conflict` (TestMixedConformance)
`unique_skip_keeps_existing_kind` (TestMixedConformance) | Field of an insertion result in each language's idiom; the adapter's insert methods report it. |
+| `rivertype_field.JobInsertResult.UniqueSkippedAsDuplicate` | api_equivalent | `cross_language_unique_conflict` (TestMixedConformance)
`unique_skip_keeps_existing_kind` (TestMixedConformance) | Field of an insertion result in each language's idiom; the adapter's insert methods report it. |
+| `rivertype_field.JobRow.Attempt` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`single_implementation_worker_outcomes` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.AttemptedAt` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.AttemptedBy` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance)
`sqlite_runtime_attempted_by_ordering` (TestMixedSQLiteRuntimeConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.CreatedAt` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.EncodedArgs` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.Errors` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`panic_attempt_trace` (TestMixedConformance)
`single_implementation_worker_outcomes` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.FinalizedAt` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.ID` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance)
`sqlite_unsafe_int64_job_ids_rpc_list_cursors` (TestMixedSQLiteConformance)
`unsafe_int64_job_ids_rpc_list_cursors` (TestMixedConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.Kind` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.MaxAttempts` | protocol_visible | `exhausted_job_retry` (TestMixedConformance)
`job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance)
`sqlite_runtime_exhausted_job_retry` (TestMixedSQLiteRuntimeConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.Metadata` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.Priority` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.Queue` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.ScheduledAt` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.State` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.Tags` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.UniqueKey` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance)
`sqlite_unique_column_bytes` (TestMixedSQLiteConformance)
`unique_column_bytes` (TestMixedConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobRow.UniqueStates` | protocol_visible | `job_row_round_trip_all_fields` (TestMixedConformance)
`sqlite_job_rows` (TestMixedSQLiteConformance)
`sqlite_unique_column_bytes` (TestMixedSQLiteConformance)
`unique_column_bytes` (TestMixedConformance) | Column of `river_job` that every implementation reads and writes. |
+| `rivertype_field.JobSnoozeError.Duration` | api_equivalent | `snooze_once_metadata_transition` (TestMixedConformance) | Snooze duration carried by the language's snooze error or outcome; the persisted transition is classified under function.JobSnooze. |
+| `rivertype_field.Queue.CreatedAt` | protocol_visible | `differential_queue_crud` (TestMixedConformance)
`sqlite_runtime_queue_crud_reconfigure_pause` (TestMixedSQLiteRuntimeConformance) | Column of `river_queue` that every implementation reads and writes. |
+| `rivertype_field.Queue.Metadata` | protocol_visible | `differential_queue_crud` (TestMixedConformance)
`sqlite_runtime_queue_crud_reconfigure_pause` (TestMixedSQLiteRuntimeConformance) | Column of `river_queue` that every implementation reads and writes. |
+| `rivertype_field.Queue.Name` | protocol_visible | `differential_queue_crud` (TestMixedConformance)
`sqlite_runtime_queue_crud_reconfigure_pause` (TestMixedSQLiteRuntimeConformance) | Column of `river_queue` that every implementation reads and writes. |
+| `rivertype_field.Queue.PausedAt` | protocol_visible | `differential_queue_crud` (TestMixedConformance)
`sqlite_runtime_queue_crud_reconfigure_pause` (TestMixedSQLiteRuntimeConformance) | Column of `river_queue` that every implementation reads and writes. |
+| `rivertype_field.Queue.UpdatedAt` | protocol_visible | `differential_queue_crud` (TestMixedConformance)
`sqlite_runtime_queue_crud_reconfigure_pause` (TestMixedSQLiteRuntimeConformance) | Column of `river_queue` that every implementation reads and writes. |
+| `rivertype_field.UnknownJobKindError.Kind` | api_equivalent | `mixed_unknown_kind_error` (TestMixedConformance)
`sqlite_runtime_unknown_kind_error` (TestMixedSQLiteRuntimeConformance) | Kind carried by the language's unknown-kind error; the persisted failure is checked across implementations. |
+| `rivertype_field.WorkerMetadata.JobArgHooks` | api_equivalent | | Description of a registered worker passed to Go plugins; other implementations describe registered workers in their own idiom. |
+| `rivertype_field.WorkerMetadata.Kind` | api_equivalent | | Description of a registered worker passed to Go plugins; other implementations describe registered workers in their own idiom. |
+
+## migration
+
+Main-line migrations for PostgreSQL and SQLite.
+
+| Item | Applicability | Scenarios (owner test) | Notes |
+|---|---|---|---|
+| `migration.postgres.001` | protocol_visible | `candidate_migrator_reference_runtime` (TestMixedConformance)
`historical_migration_down_up` (TestMixedConformance)
`reference_migrator_candidate_runtime` (TestMixedConformance) | Main-line PostgreSQL schema version. |
+| `migration.postgres.002` | protocol_visible | `candidate_migrator_reference_runtime` (TestMixedConformance)
`historical_migration_down_up` (TestMixedConformance)
`reference_migrator_candidate_runtime` (TestMixedConformance) | Main-line PostgreSQL schema version. |
+| `migration.postgres.003` | protocol_visible | `candidate_migrator_reference_runtime` (TestMixedConformance)
`historical_migration_down_up` (TestMixedConformance)
`reference_migrator_candidate_runtime` (TestMixedConformance) | Main-line PostgreSQL schema version. |
+| `migration.postgres.004` | protocol_visible | `candidate_migrator_reference_runtime` (TestMixedConformance)
`historical_migration_down_up` (TestMixedConformance)
`reference_migrator_candidate_runtime` (TestMixedConformance) | Main-line PostgreSQL schema version. |
+| `migration.postgres.005` | protocol_visible | `candidate_migrator_reference_runtime` (TestMixedConformance)
`historical_migration_down_up` (TestMixedConformance)
`reference_migrator_candidate_runtime` (TestMixedConformance) | Main-line PostgreSQL schema version. |
+| `migration.postgres.006` | protocol_visible | `candidate_migrator_reference_runtime` (TestMixedConformance)
`historical_migration_down_up` (TestMixedConformance)
`reference_migrator_candidate_runtime` (TestMixedConformance) | Main-line PostgreSQL schema version. |
+| `migration.postgres.007` | protocol_visible | `candidate_migrator_reference_runtime` (TestMixedConformance)
`historical_migration_down_up` (TestMixedConformance)
`reference_migrator_candidate_runtime` (TestMixedConformance) | Main-line PostgreSQL schema version. |
+| `migration.postgres.008` | protocol_visible | `candidate_migrator_reference_runtime` (TestMixedConformance)
`historical_migration_down_up` (TestMixedConformance)
`reference_migrator_candidate_runtime` (TestMixedConformance) | Main-line PostgreSQL schema version. |
+| `migration.sqlite.001` | protocol_visible | `sqlite_migration_cross_language` (TestMixedSQLiteConformance) | Main-line SQLite schema version. |
+| `migration.sqlite.002` | protocol_visible | `sqlite_migration_cross_language` (TestMixedSQLiteConformance) | Main-line SQLite schema version. |
+| `migration.sqlite.003` | protocol_visible | `sqlite_migration_cross_language` (TestMixedSQLiteConformance) | Main-line SQLite schema version. |
+| `migration.sqlite.004` | protocol_visible | `sqlite_migration_cross_language` (TestMixedSQLiteConformance) | Main-line SQLite schema version. |
+| `migration.sqlite.005` | protocol_visible | `sqlite_migration_cross_language` (TestMixedSQLiteConformance) | Main-line SQLite schema version. |
+| `migration.sqlite.006` | protocol_visible | `sqlite_migration_cross_language` (TestMixedSQLiteConformance) | Main-line SQLite schema version. |
+| `migration.sqlite.007` | protocol_visible | `sqlite_migration_cross_language` (TestMixedSQLiteConformance) | Main-line SQLite schema version. |
+| `migration.sqlite.008` | protocol_visible | `sqlite_migration_cross_language` (TestMixedSQLiteConformance) | Main-line SQLite schema version. |
diff --git a/conformance/fixtures/maintenance_values.json b/conformance/fixtures/maintenance_values.json
new file mode 100644
index 000000000..3e29a6e66
--- /dev/null
+++ b/conformance/fixtures/maintenance_values.json
@@ -0,0 +1,681 @@
+{
+ "$schema": "../schema/maintenance-values.schema.json",
+ "cron_cases": [
+ {
+ "expression": "* * * * *",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "every_minute",
+ "next": [
+ "2026-01-02T03:05:00Z",
+ "2026-01-02T03:06:00Z",
+ "2026-01-02T03:07:00Z",
+ "2026-01-02T03:08:00Z",
+ "2026-01-02T03:09:00Z"
+ ]
+ },
+ {
+ "expression": "30 * * * *",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "half_past_every_hour",
+ "next": [
+ "2026-01-02T03:30:00Z",
+ "2026-01-02T04:30:00Z",
+ "2026-01-02T05:30:00Z",
+ "2026-01-02T06:30:00Z",
+ "2026-01-02T07:30:00Z"
+ ]
+ },
+ {
+ "expression": "0 9 * * 1",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "monday_numeric_weekday",
+ "next": [
+ "2026-01-05T09:00:00Z",
+ "2026-01-12T09:00:00Z",
+ "2026-01-19T09:00:00Z",
+ "2026-01-26T09:00:00Z",
+ "2026-02-02T09:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 9 * * mon",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "monday_named_weekday",
+ "next": [
+ "2026-01-05T09:00:00Z",
+ "2026-01-12T09:00:00Z",
+ "2026-01-19T09:00:00Z",
+ "2026-01-26T09:00:00Z",
+ "2026-02-02T09:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 0 * * 0",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "sunday_is_zero",
+ "next": [
+ "2026-01-04T00:00:00Z",
+ "2026-01-11T00:00:00Z",
+ "2026-01-18T00:00:00Z",
+ "2026-01-25T00:00:00Z",
+ "2026-02-01T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 0 * * SUN",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "weekday_names_ignore_case",
+ "next": [
+ "2026-01-04T00:00:00Z",
+ "2026-01-11T00:00:00Z",
+ "2026-01-18T00:00:00Z",
+ "2026-01-25T00:00:00Z",
+ "2026-02-01T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "*/15 9-17 * * mon-fri",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "business_hours_steps",
+ "next": [
+ "2026-01-02T09:00:00Z",
+ "2026-01-02T09:15:00Z",
+ "2026-01-02T09:30:00Z",
+ "2026-01-02T09:45:00Z",
+ "2026-01-02T10:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 0 1 * *",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "first_of_month",
+ "next": [
+ "2026-02-01T00:00:00Z",
+ "2026-03-01T00:00:00Z",
+ "2026-04-01T00:00:00Z",
+ "2026-05-01T00:00:00Z",
+ "2026-06-01T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 0 1 jan,JUL *",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "named_months",
+ "next": [
+ "2026-07-01T00:00:00Z",
+ "2027-01-01T00:00:00Z",
+ "2027-07-01T00:00:00Z",
+ "2028-01-01T00:00:00Z",
+ "2028-07-01T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 0 29 2 *",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "leap_day",
+ "next": [
+ "2028-02-29T00:00:00Z",
+ "2032-02-29T00:00:00Z",
+ "2036-02-29T00:00:00Z",
+ "2040-02-29T00:00:00Z",
+ "2044-02-29T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 0 30 2 *",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "impossible_date_never_runs",
+ "next": []
+ },
+ {
+ "expression": "0 12 1,15 * 5",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "day_of_month_or_weekday",
+ "next": [
+ "2026-01-02T12:00:00Z",
+ "2026-01-09T12:00:00Z",
+ "2026-01-15T12:00:00Z",
+ "2026-01-16T12:00:00Z",
+ "2026-01-23T12:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 12 * * 5",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "wildcard_day_of_month_and_weekday",
+ "next": [
+ "2026-01-02T12:00:00Z",
+ "2026-01-09T12:00:00Z",
+ "2026-01-16T12:00:00Z",
+ "2026-01-23T12:00:00Z",
+ "2026-01-30T12:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 12 ? * 5",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "question_mark_wildcard",
+ "next": [
+ "2026-01-02T12:00:00Z",
+ "2026-01-09T12:00:00Z",
+ "2026-01-16T12:00:00Z",
+ "2026-01-23T12:00:00Z",
+ "2026-01-30T12:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 12 */2 * 5",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "stepped_day_of_month_or_weekday",
+ "next": [
+ "2026-01-02T12:00:00Z",
+ "2026-01-03T12:00:00Z",
+ "2026-01-05T12:00:00Z",
+ "2026-01-07T12:00:00Z",
+ "2026-01-09T12:00:00Z"
+ ]
+ },
+ {
+ "expression": "0 12 */1 * 5",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "unit_step_keeps_wildcard",
+ "next": [
+ "2026-01-02T12:00:00Z",
+ "2026-01-09T12:00:00Z",
+ "2026-01-16T12:00:00Z",
+ "2026-01-23T12:00:00Z",
+ "2026-01-30T12:00:00Z"
+ ]
+ },
+ {
+ "expression": "5/15 * * * *",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "start_with_step",
+ "next": [
+ "2026-01-02T03:05:00Z",
+ "2026-01-02T03:20:00Z",
+ "2026-01-02T03:35:00Z",
+ "2026-01-02T03:50:00Z",
+ "2026-01-02T04:05:00Z"
+ ]
+ },
+ {
+ "expression": "0-10/5 * * * *",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "range_with_step",
+ "next": [
+ "2026-01-02T03:05:00Z",
+ "2026-01-02T03:10:00Z",
+ "2026-01-02T04:00:00Z",
+ "2026-01-02T04:05:00Z",
+ "2026-01-02T04:10:00Z"
+ ]
+ },
+ {
+ "expression": "59 23 31 12 *",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "year_end",
+ "next": [
+ "2026-12-31T23:59:00Z",
+ "2027-12-31T23:59:00Z",
+ "2028-12-31T23:59:00Z",
+ "2029-12-31T23:59:00Z",
+ "2030-12-31T23:59:00Z"
+ ]
+ },
+ {
+ "expression": "@hourly",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "descriptor_hourly",
+ "next": [
+ "2026-01-02T04:00:00Z",
+ "2026-01-02T05:00:00Z",
+ "2026-01-02T06:00:00Z",
+ "2026-01-02T07:00:00Z",
+ "2026-01-02T08:00:00Z"
+ ]
+ },
+ {
+ "expression": "@daily",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "descriptor_daily",
+ "next": [
+ "2026-01-03T00:00:00Z",
+ "2026-01-04T00:00:00Z",
+ "2026-01-05T00:00:00Z",
+ "2026-01-06T00:00:00Z",
+ "2026-01-07T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "@midnight",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "descriptor_midnight",
+ "next": [
+ "2026-01-03T00:00:00Z",
+ "2026-01-04T00:00:00Z",
+ "2026-01-05T00:00:00Z",
+ "2026-01-06T00:00:00Z",
+ "2026-01-07T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "@weekly",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "descriptor_weekly",
+ "next": [
+ "2026-01-04T00:00:00Z",
+ "2026-01-11T00:00:00Z",
+ "2026-01-18T00:00:00Z",
+ "2026-01-25T00:00:00Z",
+ "2026-02-01T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "@monthly",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "descriptor_monthly",
+ "next": [
+ "2026-02-01T00:00:00Z",
+ "2026-03-01T00:00:00Z",
+ "2026-04-01T00:00:00Z",
+ "2026-05-01T00:00:00Z",
+ "2026-06-01T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "@yearly",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "descriptor_yearly",
+ "next": [
+ "2027-01-01T00:00:00Z",
+ "2028-01-01T00:00:00Z",
+ "2029-01-01T00:00:00Z",
+ "2030-01-01T00:00:00Z",
+ "2031-01-01T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "@annually",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "descriptor_annually",
+ "next": [
+ "2027-01-01T00:00:00Z",
+ "2028-01-01T00:00:00Z",
+ "2029-01-01T00:00:00Z",
+ "2030-01-01T00:00:00Z",
+ "2031-01-01T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "@every 1h30m",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "every_compound_duration",
+ "next": [
+ "2026-01-02T04:34:05Z",
+ "2026-01-02T06:04:05Z",
+ "2026-01-02T07:34:05Z",
+ "2026-01-02T09:04:05Z",
+ "2026-01-02T10:34:05Z"
+ ]
+ },
+ {
+ "expression": "@every 1.5h",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "every_fractional_duration",
+ "next": [
+ "2026-01-02T04:34:05Z",
+ "2026-01-02T06:04:05Z",
+ "2026-01-02T07:34:05Z",
+ "2026-01-02T09:04:05Z",
+ "2026-01-02T10:34:05Z"
+ ]
+ },
+ {
+ "expression": "@every 90s",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "every_seconds",
+ "next": [
+ "2026-01-02T03:05:35Z",
+ "2026-01-02T03:07:05Z",
+ "2026-01-02T03:08:35Z",
+ "2026-01-02T03:10:05Z",
+ "2026-01-02T03:11:35Z"
+ ]
+ },
+ {
+ "expression": "@every 500ms",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "every_rounds_up_to_one_second",
+ "next": [
+ "2026-01-02T03:04:06Z",
+ "2026-01-02T03:04:07Z",
+ "2026-01-02T03:04:08Z",
+ "2026-01-02T03:04:09Z",
+ "2026-01-02T03:04:10Z"
+ ]
+ },
+ {
+ "expression": "@every 1500ms",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "every_truncates_subseconds",
+ "next": [
+ "2026-01-02T03:04:06Z",
+ "2026-01-02T03:04:07Z",
+ "2026-01-02T03:04:08Z",
+ "2026-01-02T03:04:09Z",
+ "2026-01-02T03:04:10Z"
+ ]
+ },
+ {
+ "expression": "0 9 * * *",
+ "from": "2026-03-07T08:00:00-05:00",
+ "name": "reference_time_offset",
+ "next": [
+ "2026-03-07T09:00:00-05:00",
+ "2026-03-08T09:00:00-05:00",
+ "2026-03-09T09:00:00-05:00",
+ "2026-03-10T09:00:00-05:00",
+ "2026-03-11T09:00:00-05:00"
+ ]
+ },
+ {
+ "expression": "30 0 * * *",
+ "from": "2026-03-07T23:45:00+05:30",
+ "name": "reference_time_half_hour_offset",
+ "next": [
+ "2026-03-08T00:30:00+05:30",
+ "2026-03-09T00:30:00+05:30",
+ "2026-03-10T00:30:00+05:30",
+ "2026-03-11T00:30:00+05:30",
+ "2026-03-12T00:30:00+05:30"
+ ]
+ },
+ {
+ "expression": "CRON_TZ=UTC 0 9 * * *",
+ "from": "2026-03-07T08:00:00-05:00",
+ "name": "cron_tz_utc_prefix",
+ "next": [
+ "2026-03-08T04:00:00-05:00",
+ "2026-03-09T04:00:00-05:00",
+ "2026-03-10T04:00:00-05:00",
+ "2026-03-11T04:00:00-05:00",
+ "2026-03-12T04:00:00-05:00"
+ ]
+ },
+ {
+ "expression": "TZ=UTC 0 9 * * *",
+ "from": "2026-03-07T08:00:00-05:00",
+ "name": "tz_utc_prefix",
+ "next": [
+ "2026-03-08T04:00:00-05:00",
+ "2026-03-09T04:00:00-05:00",
+ "2026-03-10T04:00:00-05:00",
+ "2026-03-11T04:00:00-05:00",
+ "2026-03-12T04:00:00-05:00"
+ ]
+ },
+ {
+ "expression": " 0 9 * * 1 ",
+ "from": "2026-01-02T03:04:05.6789Z",
+ "name": "extra_whitespace",
+ "next": [
+ "2026-01-05T09:00:00Z",
+ "2026-01-12T09:00:00Z",
+ "2026-01-19T09:00:00Z",
+ "2026-01-26T09:00:00Z",
+ "2026-02-02T09:00:00Z"
+ ]
+ }
+ ],
+ "cron_invalid": [
+ "",
+ "* * * *",
+ "* * * * * *",
+ "0 9 * * 7",
+ "60 * * * *",
+ "* 24 * * *",
+ "* * 0 * *",
+ "* * 32 * *",
+ "* * * 0 *",
+ "* * * 13 *",
+ "-1 * * * *",
+ "5-1 * * * *",
+ "1-2-3 * * * *",
+ "1/2/3 * * * *",
+ "*/0 * * * *",
+ "*/x * * * *",
+ "0 9 * * funday",
+ "@every",
+ "@every 5x",
+ "@reboot",
+ "CRON_TZ=Nowhere/Invalid 0 9 * * *"
+ ],
+ "cron_named_zone_cases": [
+ {
+ "expression": "CRON_TZ=America/New_York 0 9 * * *",
+ "from": "2026-03-06T12:00:00Z",
+ "name": "new_york_across_dst_start",
+ "next": [
+ "2026-03-06T14:00:00Z",
+ "2026-03-07T14:00:00Z",
+ "2026-03-08T13:00:00Z",
+ "2026-03-09T13:00:00Z",
+ "2026-03-10T13:00:00Z"
+ ]
+ },
+ {
+ "expression": "CRON_TZ=America/New_York 30 2 * * *",
+ "from": "2026-03-06T12:00:00Z",
+ "name": "new_york_skipped_wall_time",
+ "next": [
+ "2026-03-07T07:30:00Z",
+ "2026-03-09T06:30:00Z",
+ "2026-03-10T06:30:00Z",
+ "2026-03-11T06:30:00Z",
+ "2026-03-12T06:30:00Z"
+ ]
+ },
+ {
+ "expression": "CRON_TZ=America/New_York 30 1 * * *",
+ "from": "2026-10-30T12:00:00Z",
+ "name": "new_york_repeated_wall_time",
+ "next": [
+ "2026-10-31T05:30:00Z",
+ "2026-11-01T05:30:00Z",
+ "2026-11-01T06:30:00Z",
+ "2026-11-02T06:30:00Z",
+ "2026-11-03T06:30:00Z"
+ ]
+ },
+ {
+ "expression": "CRON_TZ=America/New_York 0 * * * *",
+ "from": "2026-11-01T04:30:00Z",
+ "name": "new_york_hourly_across_dst_end",
+ "next": [
+ "2026-11-01T05:00:00Z",
+ "2026-11-01T06:00:00Z",
+ "2026-11-01T07:00:00Z",
+ "2026-11-01T08:00:00Z",
+ "2026-11-01T09:00:00Z"
+ ]
+ },
+ {
+ "expression": "CRON_TZ=Europe/London 0 0 * * *",
+ "from": "2026-10-23T12:00:00Z",
+ "name": "london_across_dst_end",
+ "next": [
+ "2026-10-23T23:00:00Z",
+ "2026-10-24T23:00:00Z",
+ "2026-10-26T00:00:00Z",
+ "2026-10-27T00:00:00Z",
+ "2026-10-28T00:00:00Z"
+ ]
+ },
+ {
+ "expression": "CRON_TZ=America/Santiago 0 0 * * *",
+ "from": "2026-09-03T12:00:00Z",
+ "name": "santiago_skipped_midnight",
+ "next": [
+ "2026-09-04T04:00:00Z",
+ "2026-09-05T04:00:00Z",
+ "2026-09-07T03:00:00Z",
+ "2026-09-08T03:00:00Z",
+ "2026-09-09T03:00:00Z"
+ ]
+ },
+ {
+ "expression": "CRON_TZ=America/Santiago 0 12 * * *",
+ "from": "2026-09-03T12:00:00Z",
+ "name": "santiago_day_after_skipped_midnight",
+ "next": [
+ "2026-09-03T16:00:00Z",
+ "2026-09-04T16:00:00Z",
+ "2026-09-05T16:00:00Z",
+ "2026-09-06T15:00:00Z",
+ "2026-09-07T15:00:00Z"
+ ]
+ },
+ {
+ "expression": "CRON_TZ=America/Santiago 30 23 * * *",
+ "from": "2026-04-02T12:00:00Z",
+ "name": "santiago_repeated_hour_before_midnight",
+ "next": [
+ "2026-04-03T02:30:00Z",
+ "2026-04-04T02:30:00Z",
+ "2026-04-05T02:30:00Z",
+ "2026-04-05T03:30:00Z",
+ "2026-04-06T03:30:00Z"
+ ]
+ },
+ {
+ "expression": "TZ=Asia/Kolkata 0 9 * * mon",
+ "from": "2026-01-02T03:04:05-05:00",
+ "name": "kolkata_tz_prefix",
+ "next": [
+ "2026-01-04T22:30:00-05:00",
+ "2026-01-11T22:30:00-05:00",
+ "2026-01-18T22:30:00-05:00",
+ "2026-01-25T22:30:00-05:00",
+ "2026-02-01T22:30:00-05:00"
+ ]
+ }
+ ],
+ "protocol_revision": 1,
+ "snooze_counters": [
+ {
+ "expected_snoozes": 1,
+ "metadata": {},
+ "name": "absent"
+ },
+ {
+ "expected_snoozes": 3,
+ "metadata": {
+ "snoozes": 2
+ },
+ "name": "integer"
+ },
+ {
+ "expected_snoozes": 3,
+ "metadata": {
+ "snoozes": 2.9
+ },
+ "name": "fraction_truncates"
+ },
+ {
+ "expected_snoozes": -1,
+ "metadata": {
+ "snoozes": -2.5
+ },
+ "name": "negative_fraction_truncates_toward_zero"
+ },
+ {
+ "expected_snoozes": 1001,
+ "metadata": {
+ "snoozes": 1e3
+ },
+ "name": "exponent"
+ },
+ {
+ "expected_snoozes": 9007199254740994,
+ "metadata": {
+ "snoozes": 9007199254740993
+ },
+ "name": "beyond_float_precision"
+ },
+ {
+ "expected_snoozes": 5,
+ "metadata": {
+ "snoozes": "4"
+ },
+ "name": "numeric_string"
+ },
+ {
+ "expected_snoozes": -6,
+ "metadata": {
+ "snoozes": "-7"
+ },
+ "name": "negative_numeric_string"
+ },
+ {
+ "expected_snoozes": 1,
+ "metadata": {
+ "snoozes": "4.5"
+ },
+ "name": "fractional_string_is_zero"
+ },
+ {
+ "expected_snoozes": 1,
+ "metadata": {
+ "snoozes": " 5"
+ },
+ "name": "padded_string_is_zero"
+ },
+ {
+ "expected_snoozes": 1,
+ "metadata": {
+ "snoozes": "abc"
+ },
+ "name": "non_numeric_string_is_zero"
+ },
+ {
+ "expected_snoozes": 2,
+ "metadata": {
+ "snoozes": true
+ },
+ "name": "true_is_one"
+ },
+ {
+ "expected_snoozes": 1,
+ "metadata": {
+ "snoozes": false
+ },
+ "name": "false_is_zero"
+ },
+ {
+ "expected_snoozes": 1,
+ "metadata": {
+ "snoozes": null
+ },
+ "name": "null_is_zero"
+ },
+ {
+ "expected_snoozes": 1,
+ "metadata": {
+ "snoozes": [
+ 3
+ ]
+ },
+ "name": "array_is_zero"
+ },
+ {
+ "expected_snoozes": 1,
+ "metadata": {
+ "snoozes": {
+ "count": 3
+ }
+ },
+ "name": "object_is_zero"
+ }
+ ]
+}
diff --git a/conformance/fixtures/protocol_values.json b/conformance/fixtures/protocol_values.json
new file mode 100644
index 000000000..a64af845b
--- /dev/null
+++ b/conformance/fixtures/protocol_values.json
@@ -0,0 +1,319 @@
+{
+ "$schema": "../schema/protocol-values.schema.json",
+ "attempt_error": {
+ "at": "2026-01-02T03:04:05.6789Z",
+ "attempt": 3,
+ "error": "worker failed: escaped \"detail\"",
+ "trace": "frame one\nframe two"
+ },
+ "job_states": [
+ {
+ "state": "available",
+ "unique_bit": 1
+ },
+ {
+ "state": "cancelled",
+ "unique_bit": 2
+ },
+ {
+ "state": "completed",
+ "unique_bit": 4
+ },
+ {
+ "state": "discarded",
+ "unique_bit": 8
+ },
+ {
+ "state": "pending",
+ "unique_bit": 16
+ },
+ {
+ "state": "retryable",
+ "unique_bit": 32
+ },
+ {
+ "state": "running",
+ "unique_bit": 64
+ },
+ {
+ "state": "scheduled",
+ "unique_bit": 128
+ }
+ ],
+ "metadata_keys": {
+ "output": "output",
+ "periodic_job_id": "river:periodic_job_id",
+ "rescue_count": "river:rescue_count",
+ "resumable_cursor": "river:resumable_cursor",
+ "resumable_step": "river:resumable_step",
+ "unique_nonce": "river:unique_nonce"
+ },
+ "notifications": [
+ {
+ "fields": [
+ {
+ "name": "action",
+ "omitempty": false
+ },
+ {
+ "name": "job_id",
+ "omitempty": true
+ },
+ {
+ "name": "metadata",
+ "omitempty": true
+ },
+ {
+ "name": "queue",
+ "omitempty": false
+ }
+ ],
+ "name": "cancel",
+ "payload": {
+ "action": "cancel",
+ "job_id": 42,
+ "queue": "priority"
+ },
+ "source": "producer.go:controlEventPayload; riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobCancel",
+ "topic": "river_control"
+ },
+ {
+ "fields": [
+ {
+ "name": "queue",
+ "omitempty": false
+ }
+ ],
+ "name": "insert",
+ "payload": {
+ "queue": "priority"
+ },
+ "source": "producer.go:insertPayload",
+ "topic": "river_insert"
+ },
+ {
+ "fields": [
+ {
+ "name": "action",
+ "omitempty": false
+ },
+ {
+ "name": "job_id",
+ "omitempty": true
+ },
+ {
+ "name": "metadata",
+ "omitempty": true
+ },
+ {
+ "name": "queue",
+ "omitempty": false
+ }
+ ],
+ "name": "metadata_changed",
+ "payload": {
+ "action": "metadata_changed",
+ "metadata": {
+ "owner": "candidate"
+ },
+ "queue": "priority"
+ },
+ "source": "producer.go:controlEventPayload",
+ "topic": "river_control"
+ },
+ {
+ "fields": [
+ {
+ "name": "action",
+ "omitempty": false
+ },
+ {
+ "name": "job_id",
+ "omitempty": true
+ },
+ {
+ "name": "metadata",
+ "omitempty": true
+ },
+ {
+ "name": "queue",
+ "omitempty": false
+ }
+ ],
+ "name": "pause",
+ "payload": {
+ "action": "pause",
+ "queue": "priority"
+ },
+ "source": "producer.go:controlEventPayload",
+ "topic": "river_control"
+ },
+ {
+ "fields": [
+ {
+ "name": "action",
+ "omitempty": false
+ },
+ {
+ "name": "leader_id",
+ "omitempty": false
+ }
+ ],
+ "name": "request_resign",
+ "payload": {
+ "action": "request_resign",
+ "leader_id": ""
+ },
+ "source": "internal/leadership/elector.go:DBNotification",
+ "topic": "river_leadership"
+ },
+ {
+ "fields": [
+ {
+ "name": "action",
+ "omitempty": false
+ },
+ {
+ "name": "leader_id",
+ "omitempty": false
+ }
+ ],
+ "name": "resigned",
+ "payload": {
+ "action": "resigned",
+ "leader_id": "client-1"
+ },
+ "source": "internal/leadership/elector.go:DBNotification; riverdriver/riverpgxv5/internal/dbsqlc/river_leader.sql:LeaderResign",
+ "topic": "river_leadership"
+ },
+ {
+ "fields": [
+ {
+ "name": "action",
+ "omitempty": false
+ },
+ {
+ "name": "job_id",
+ "omitempty": true
+ },
+ {
+ "name": "metadata",
+ "omitempty": true
+ },
+ {
+ "name": "queue",
+ "omitempty": false
+ }
+ ],
+ "name": "resume",
+ "payload": {
+ "action": "resume",
+ "queue": "priority"
+ },
+ "source": "producer.go:controlEventPayload",
+ "topic": "river_control"
+ }
+ ],
+ "protocol_revision": 1,
+ "reserved_metadata_keys": [
+ {
+ "applicability": "protocol_visible",
+ "key": "cancel_attempted_at"
+ },
+ {
+ "applicability": "protocol_visible",
+ "key": "output"
+ },
+ {
+ "applicability": "protocol_visible",
+ "key": "periodic"
+ },
+ {
+ "applicability": "protocol_visible",
+ "key": "river:log"
+ },
+ {
+ "applicability": "protocol_visible",
+ "key": "river:periodic_job_id"
+ },
+ {
+ "applicability": "protocol_visible",
+ "key": "river:rescue_count"
+ },
+ {
+ "applicability": "protocol_visible",
+ "key": "river:resumable_cursor"
+ },
+ {
+ "applicability": "protocol_visible",
+ "key": "river:resumable_step"
+ },
+ {
+ "applicability": "protocol_visible",
+ "key": "river:unique_nonce"
+ },
+ {
+ "applicability": "protocol_visible",
+ "key": "snoozes"
+ },
+ {
+ "applicability": "protocol_visible",
+ "key": "unique_key_conflict"
+ }
+ ],
+ "retry_cases": [
+ {
+ "error_count": 1,
+ "job_id": 42,
+ "max_delay_ns": 1100000000,
+ "min_delay_ns": 900000000,
+ "now": "2026-01-02T03:04:05.6789Z",
+ "seed": 0
+ },
+ {
+ "error_count": 2,
+ "job_id": 42,
+ "max_delay_ns": 17600000000,
+ "min_delay_ns": 14400000000,
+ "now": "2026-01-02T03:04:05.6789Z",
+ "seed": 123
+ },
+ {
+ "error_count": 3,
+ "job_id": 9007199254740991,
+ "max_delay_ns": 89100000000,
+ "min_delay_ns": 72900000000,
+ "now": "2026-01-02T03:04:05.6789Z",
+ "seed": 18446744073709551615
+ },
+ {
+ "error_count": 11,
+ "job_id": 1,
+ "max_delay_ns": 16105100000000,
+ "min_delay_ns": 13176900000000,
+ "now": "2026-01-02T03:04:05.6789Z",
+ "seed": 456
+ },
+ {
+ "error_count": 309,
+ "job_id": 42,
+ "max_delay_ns": 9223372036854775807,
+ "min_delay_ns": 8204959224899999744,
+ "now": "2026-01-02T03:04:05.6789Z",
+ "seed": 789
+ },
+ {
+ "error_count": 310,
+ "job_id": 42,
+ "max_delay_ns": 9223372036854775807,
+ "min_delay_ns": 9223372036854775807,
+ "now": "2026-01-02T03:04:05.6789Z",
+ "seed": 123
+ }
+ ],
+ "topics": {
+ "control": "river_control",
+ "insert": "river_insert",
+ "leadership": "river_leadership"
+ }
+}
diff --git a/conformance/fixtures/unique_keys.json b/conformance/fixtures/unique_keys.json
new file mode 100644
index 000000000..cbfd04cf5
--- /dev/null
+++ b/conformance/fixtures/unique_keys.json
@@ -0,0 +1,950 @@
+{
+ "$schema": "../schema/unique-keys.schema.json",
+ "cases": [
+ {
+ "args": {},
+ "expected_sha256": "23aa86692d9807ab10e433e378f1c0804573f5e345818461b919322dd381b4c3",
+ "expected_state_mask": 245,
+ "kind": "conformance_selected_args",
+ "name": "all_selected_fields_omitted",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_components": [
+ [
+ "account",
+ "id"
+ ],
+ [
+ "account",
+ "region"
+ ],
+ [
+ "label"
+ ],
+ [
+ "path/key"
+ ]
+ ],
+ "selected_unique_paths": [
+ "account.id",
+ "account.region",
+ "label",
+ "path/key"
+ ]
+ },
+ {
+ "args": {
+ "account": {
+ "id": "acct",
+ "ignored": "irrelevant",
+ "region": "west"
+ },
+ "path/key": "slash"
+ },
+ "expected_sha256": "7d62e81ac25cfa2dec69ad5a41e0b78188ee1b299bed329b453da6b3abca70bd",
+ "expected_state_mask": 245,
+ "kind": "conformance_selected_args",
+ "name": "selected_siblings_and_slash_key",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_components": [
+ [
+ "account",
+ "id"
+ ],
+ [
+ "account",
+ "region"
+ ],
+ [
+ "label"
+ ],
+ [
+ "path/key"
+ ]
+ ],
+ "selected_unique_paths": [
+ "account.id",
+ "account.region",
+ "label",
+ "path/key"
+ ]
+ },
+ {
+ "args": {
+ "nested": {
+ "z": 1,
+ "a": 2
+ }
+ },
+ "expected_sha256": "46ff499cb031d0458bb00ef87e7b83321eefb6e7534497c279c4ae0f474cdde0",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "nested_struct_wire_order",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "zeta": "quoted \\\"value\\\" and \\\\ slash",
+ "alpha": "\u003calpha\u003e\u0026\u2028line",
+ "maximum": 9007199254740991
+ },
+ "expected_sha256": "7a84c62c8d470ca388a0a1e41c311b9eb1ea21f7b88157ceb876fc82e698b6af",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "all_args_sorted_and_escaped",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "2": 2,
+ "10": 10,
+ "zero": -0,
+ "😀": 1,
+ "": 2
+ },
+ "expected_sha256": "fcdf33e0c39c1fc7e956876345a985f2418bd69c6e4d6a5c794abf1e78cdfdb6",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "map_order_and_negative_zero",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "": 0,
+ "a.b": 1,
+ "@x": 2,
+ ":lead": 3,
+ "!bang": 4,
+ "[open": 5,
+ "{brace": 6,
+ "a\\b": 7
+ },
+ "expected_sha256": "1d254dda1efe1009ffb205ede791d481545d66abb8542e85f0d895415b05cdf9",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "all_args_literal_path_syntax",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "a\"b": 1,
+ "line\n": 2,
+ "é": 3,
+ "a\u003cb": 4,
+ "a\u0026b": 5,
+ "a\u2028b": 6
+ },
+ "expected_sha256": "bab84635792449d758b18950f6f014bfb16d26430e798d84afee9fc9cb793163",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "all_args_escaped_key_encoding",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": [],
+ "expected_sha256": "fe05a58ddb79a8d4544da962582d9a290d59788c920afd3597da3a62e3c1b0ac",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "all_args_empty_array",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": [
+ 1
+ ],
+ "expected_error": "rejected",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "all_args_array_rejected",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": null,
+ "expected_error": "rejected",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "all_args_null_rejected",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": "args",
+ "expected_error": "rejected",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "all_args_scalar_rejected",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "exponent": 1e+100,
+ "fraction": 1.25,
+ "maximum": 9223372036854775807,
+ "minimum": -9223372036854775808,
+ "unsigned_maximum": 18446744073709551615
+ },
+ "expected_sha256": "2c1533b3ab43068407d14e82ddb34a295a51375ae3a27fef6931123f07677f38",
+ "expected_state_mask": 245,
+ "kind": "conformance_numeric_boundaries",
+ "name": "numeric_boundaries",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "account": {
+ "id": "acct-123",
+ "ignored": "not selected"
+ },
+ "ignored": true,
+ "label": "selected"
+ },
+ "expected_sha256": "6130dc4f753402d1faeb6bbc3e6c21415245bb282ad1fd16bcbfeebde525e726",
+ "expected_state_mask": 245,
+ "kind": "conformance_selected_args",
+ "name": "selected_nested_args",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_components": [
+ [
+ "account",
+ "id"
+ ],
+ [
+ "account",
+ "region"
+ ],
+ [
+ "label"
+ ],
+ [
+ "path/key"
+ ]
+ ],
+ "selected_unique_paths": [
+ "account.id",
+ "account.region",
+ "label",
+ "path/key"
+ ]
+ },
+ {
+ "args": {
+ "user.id": "literal",
+ "user": {}
+ },
+ "expected_sha256": "7d478fa6978b3fbb5c326d90fd10c2eab663cf2c9c2e1e3ddcce032989a05cdb",
+ "expected_state_mask": 245,
+ "kind": "conformance_dotted_selected_args",
+ "name": "selected_literal_dotted_name",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_components": [
+ [
+ "@user"
+ ],
+ [
+ "!x"
+ ],
+ [
+ "{x}"
+ ],
+ [
+ "[x]"
+ ],
+ [
+ ":id"
+ ],
+ [
+ "user",
+ "id"
+ ],
+ [
+ "user.id"
+ ],
+ [
+ "a*b?c#d|e"
+ ],
+ [
+ "é"
+ ]
+ ],
+ "selected_unique_paths": [
+ "\\@user",
+ "\\!x",
+ "\\{x\\}",
+ "\\[x\\]",
+ "\\:id",
+ "user.id",
+ "user\\.id",
+ "a\\*b\\?c\\#d\\|e",
+ "é"
+ ]
+ },
+ {
+ "args": {
+ "user": {
+ "id": "nested"
+ }
+ },
+ "expected_sha256": "6fd34aa5a46274e4f7d159063be43c2c02e90438b84dab208c8c5209f94dda25",
+ "expected_state_mask": 245,
+ "kind": "conformance_dotted_selected_args",
+ "name": "selected_nested_dotted_path",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_components": [
+ [
+ "@user"
+ ],
+ [
+ "!x"
+ ],
+ [
+ "{x}"
+ ],
+ [
+ "[x]"
+ ],
+ [
+ ":id"
+ ],
+ [
+ "user",
+ "id"
+ ],
+ [
+ "user.id"
+ ],
+ [
+ "a*b?c#d|e"
+ ],
+ [
+ "é"
+ ]
+ ],
+ "selected_unique_paths": [
+ "\\@user",
+ "\\!x",
+ "\\{x\\}",
+ "\\[x\\]",
+ "\\:id",
+ "user.id",
+ "user\\.id",
+ "a\\*b\\?c\\#d\\|e",
+ "é"
+ ]
+ },
+ {
+ "args": {
+ "user": {},
+ "é": "café"
+ },
+ "expected_sha256": "28513f484784e6b0fe8aed6cc1fadb04498f43305b74619aa56e701a2feff578",
+ "expected_state_mask": 245,
+ "kind": "conformance_dotted_selected_args",
+ "name": "selected_unicode_field_name",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_components": [
+ [
+ "@user"
+ ],
+ [
+ "!x"
+ ],
+ [
+ "{x}"
+ ],
+ [
+ "[x]"
+ ],
+ [
+ ":id"
+ ],
+ [
+ "user",
+ "id"
+ ],
+ [
+ "user.id"
+ ],
+ [
+ "a*b?c#d|e"
+ ],
+ [
+ "é"
+ ]
+ ],
+ "selected_unique_paths": [
+ "\\@user",
+ "\\!x",
+ "\\{x\\}",
+ "\\[x\\]",
+ "\\:id",
+ "user.id",
+ "user\\.id",
+ "a\\*b\\?c\\#d\\|e",
+ "é"
+ ]
+ },
+ {
+ "args": {
+ "@user": "at",
+ "!x": "bang",
+ "{x}": "brace",
+ "[x]": "bracket",
+ ":id": "colon",
+ "a*b?c#d|e": "symbols",
+ "user": {}
+ },
+ "expected_sha256": "d00ff085218024d7059a4556b24af92cbe57744935e2d37ff644b149fae4c2f3",
+ "expected_state_mask": 245,
+ "kind": "conformance_dotted_selected_args",
+ "name": "selected_punctuation_field_names",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_components": [
+ [
+ "@user"
+ ],
+ [
+ "!x"
+ ],
+ [
+ "{x}"
+ ],
+ [
+ "[x]"
+ ],
+ [
+ ":id"
+ ],
+ [
+ "user",
+ "id"
+ ],
+ [
+ "user.id"
+ ],
+ [
+ "a*b?c#d|e"
+ ],
+ [
+ "é"
+ ]
+ ],
+ "selected_unique_paths": [
+ "\\@user",
+ "\\!x",
+ "\\{x\\}",
+ "\\[x\\]",
+ "\\:id",
+ "user.id",
+ "user\\.id",
+ "a\\*b\\?c\\#d\\|e",
+ "é"
+ ]
+ },
+ {
+ "args": {
+ "empty": [],
+ "labels": {
+ "alpha": "first",
+ "k10": "ten",
+ "k2": "two",
+ "zulu": "last"
+ },
+ "matrix": [
+ [
+ 3,
+ 1
+ ],
+ [],
+ [
+ 2
+ ]
+ ],
+ "missing": null,
+ "objects": [
+ {
+ "zulu": "z",
+ "alpha": 1
+ },
+ {
+ "zulu": "y",
+ "alpha": null
+ }
+ ],
+ "pointer": null
+ },
+ "expected_sha256": "66d457888b4b71f0a0041251f75d494e1ac543717283d84633a274648559306d",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "typed_collections_and_nulls",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {},
+ "expected_sha256": "fe05a58ddb79a8d4544da962582d9a290d59788c920afd3597da3a62e3c1b0ac",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "typed_empty_args",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "a\u003cb\u003e": "\u003cangle\u003e",
+ "controls": "\b\f\n\r\t\u0000\u0001\u001f",
+ "html": "\u003ca href=\"x\"\u003e\u0026amp;\u003c/a\u003e",
+ "keys": {
+ "\u003ck\u003e": 1,
+ "a\u0026b": 2,
+ "é": 3,
+ "é\u003c": 4
+ },
+ "separators": "line\u2028paragraph\u2029end",
+ "unicode": "é😀/\\",
+ "é\u0026": "unicode key"
+ },
+ "expected_sha256": "a4c2a164225cba2e3ae56edbc11258e49a7325d73a9b70e5c9b47a51963d00c5",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "typed_escaping",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "label": null
+ },
+ "expected_sha256": "d137d7c4f1e3f8369037b1890357655b4fa1978a329bcf51435f9f0b54abcab7",
+ "expected_state_mask": 245,
+ "kind": "conformance_selected_args",
+ "name": "selected_explicit_null",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_components": [
+ [
+ "account",
+ "id"
+ ],
+ [
+ "account",
+ "region"
+ ],
+ [
+ "label"
+ ],
+ [
+ "path/key"
+ ]
+ ],
+ "selected_unique_paths": [
+ "account.id",
+ "account.region",
+ "label",
+ "path/key"
+ ]
+ },
+ {
+ "args": {
+ "fraction": "2026-01-02T03:04:05.5Z",
+ "micros": "2026-01-02T03:04:05.123456Z",
+ "millis": "2026-01-02T03:04:05.12Z",
+ "whole": "2026-01-02T03:04:05Z"
+ },
+ "expected_sha256": "18b38780de3019cc75d49ff24a74eecdcff99fda0bb36c0244a35f262b15322b",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "typed_time_values",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "below_large": 999999999999999900000,
+ "large": 100000000000000000000,
+ "large_boundary": 1e+21,
+ "largest": 1.7976931348623157e+308,
+ "negative": -1.5e-9,
+ "negative_zero": -0,
+ "one": 1,
+ "single": 1.1,
+ "single_large": 1e+21,
+ "single_small": 1e-7,
+ "small": 1e-7,
+ "small_boundary": 0.000001,
+ "smallest": 5e-324,
+ "tenth": 0.1
+ },
+ "expected_sha256": "dc330477ebe8bf2bc3402476365fcfe22c82253f744481c49a23f760f059cad4",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "typed_float_formatting",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "id": 42
+ },
+ "expected_sha256": "5396f06a082abd7a929915135ebd363a9a47d800176b03ce7736f93a5ba9e22e",
+ "expected_state_mask": 245,
+ "kind": "conformance_simple",
+ "name": "period_from_now",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": false,
+ "by_period_nanos": 5400000000000,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "id": 42
+ },
+ "expected_sha256": "b7f3c49952996b760b8b3ff6cf48f426e03a6ef0f004fb6faa51725365cf309a",
+ "expected_state_mask": 245,
+ "kind": "conformance_simple",
+ "name": "period_from_schedule",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": false,
+ "by_period_nanos": 3600000000000,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": "2026-01-02T05:21:05.6789Z",
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "id": 42
+ },
+ "expected_sha256": "5396f06a082abd7a929915135ebd363a9a47d800176b03ce7736f93a5ba9e22e",
+ "expected_state_mask": 245,
+ "kind": "conformance_simple",
+ "name": "period_from_non_utc_now",
+ "now": "2026-01-01T22:04:05.6789-05:00",
+ "options": {
+ "by_args": false,
+ "by_period_nanos": 3600000000000,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "id": 42
+ },
+ "expected_sha256": "b7f3c49952996b760b8b3ff6cf48f426e03a6ef0f004fb6faa51725365cf309a",
+ "expected_state_mask": 245,
+ "kind": "conformance_simple",
+ "name": "period_from_non_utc_schedule",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": false,
+ "by_period_nanos": 3600000000000,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": "2026-01-02T10:51:05.6789+05:30",
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "id": 42
+ },
+ "expected_sha256": "d20ce47da8e8015e68b020bbca2b17494139519ae05f8985e5992db4d8dd8a09",
+ "expected_state_mask": 245,
+ "kind": "conformance_simple",
+ "name": "queue_without_kind",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": false,
+ "by_period_nanos": 0,
+ "by_queue": true,
+ "exclude_kind": true
+ },
+ "queue": "priority_emails",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "id": 42
+ },
+ "expected_sha256": "6f20262e7b1fa9beaf98255f23030800376636484a98b20c2c04a303bab5a8d5",
+ "expected_state_mask": 213,
+ "kind": "conformance_simple",
+ "name": "all_dimensions_custom_states",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 60000000000,
+ "by_queue": true,
+ "by_state": [
+ "available",
+ "completed",
+ "pending",
+ "running",
+ "scheduled"
+ ],
+ "exclude_kind": false
+ },
+ "queue": "priority_emails",
+ "scheduled_at": "2026-01-02T05:21:05.6789Z",
+ "selected_unique_paths": null
+ }
+ ],
+ "protocol_revision": 1,
+ "typed_only_cases": [
+ {
+ "args": {
+ "a": 1,
+ "a": 2,
+ "b": 3
+ },
+ "expected_sha256": "f3568e94e18a68ce633bd444fe9ec448382740bcf02b83b210cb68e74220fb5f",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "typed_duplicate_top_level_keys",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ },
+ {
+ "args": {
+ "empty": [],
+ "labels": {
+ "10": "ten",
+ "2": "two",
+ "alpha": "first",
+ "zulu": "last"
+ },
+ "matrix": [],
+ "missing": null,
+ "objects": [],
+ "pointer": null
+ },
+ "expected_sha256": "38406019aea1ea67f81186d845e983b197b2d919ded5eca871e4fef0c8b0ad30",
+ "expected_state_mask": 245,
+ "kind": "conformance_all_args",
+ "name": "typed_integer_like_map_keys",
+ "now": "2026-01-02T03:04:05.6789Z",
+ "options": {
+ "by_args": true,
+ "by_period_nanos": 0,
+ "by_queue": false,
+ "exclude_kind": false
+ },
+ "queue": "default",
+ "scheduled_at": null,
+ "selected_unique_paths": null
+ }
+ ]
+}
diff --git a/conformance/harness/adapter_decode_test.go b/conformance/harness/adapter_decode_test.go
new file mode 100644
index 000000000..c7b992ee5
--- /dev/null
+++ b/conformance/harness/adapter_decode_test.go
@@ -0,0 +1,38 @@
+package harness_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// Each response is a complete observation. json.Unmarshal alone merges maps
+// into previous responses and can make deleted metadata appear to survive.
+func decodeAdapterResult(encoded []byte, result any) error {
+ value := reflect.ValueOf(result)
+ if value.Kind() != reflect.Pointer || value.IsNil() {
+ return fmt.Errorf("adapter result must be a non-nil pointer, got %T", result)
+ }
+ fresh := reflect.New(value.Elem().Type())
+ if err := json.Unmarshal(encoded, fresh.Interface()); err != nil {
+ return err
+ }
+ value.Elem().Set(fresh.Elem())
+ return nil
+}
+
+func TestDecodeAdapterResult(t *testing.T) {
+ t.Parallel()
+
+ var job struct {
+ Metadata map[string]any `json:"metadata"`
+ }
+ require.NoError(t, decodeAdapterResult([]byte(`{"metadata":{"output":1,"nested":{"stale":true}}}`), &job))
+ require.NoError(t, decodeAdapterResult([]byte(`{"metadata":{"nested":{"current":true}}}`), &job))
+ require.Equal(t, map[string]any{"nested": map[string]any{"current": true}}, job.Metadata)
+ require.NoError(t, decodeAdapterResult([]byte(`null`), &job))
+ require.Nil(t, job.Metadata)
+}
diff --git a/conformance/harness/adapter_test.go b/conformance/harness/adapter_test.go
new file mode 100644
index 000000000..f1924c54d
--- /dev/null
+++ b/conformance/harness/adapter_test.go
@@ -0,0 +1,438 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// adapter is one running conformance adapter process. Requests are strictly
+// sequential within a process: a request is written, then its response is
+// read before the next request is sent.
+type adapter struct {
+ *adapterProcess
+
+ // applicationName is the PostgreSQL application_name of the adapter's
+ // connections: unique to the process when the adapter reports it in its
+ // handshake, otherwise its descriptor's. Harness observations such as
+ // lock waits, and fault injection, target it. Empty on SQLite.
+ applicationName string
+ expectedExitError bool
+ name string
+ nextID int
+ openHandles map[string]bool
+ running bool
+ // spec describes the implementation behind the adapter, including the
+ // optional start tuning it honors.
+ spec adapterSpec
+}
+
+type adapterHandshake struct {
+ AdapterVersion int `json:"adapter_version"`
+ ApplicationName string `json:"application_name"`
+ Backend string `json:"backend"`
+ Capabilities []string `json:"capabilities"`
+ Implementation string `json:"implementation"`
+ ImplementationVersion string `json:"implementation_version"`
+ Methods []string `json:"methods"`
+ MigrationLines map[string]int `json:"migration_lines"`
+ Profile string `json:"profile"`
+ ProtocolRevision int `json:"protocol_revision"`
+}
+
+type adapterProfile struct {
+ Backend string `json:"backend"`
+ Capabilities []string `json:"capabilities"`
+ Methods []string `json:"methods"`
+ Name string `json:"name"`
+ ProtocolRevision int `json:"protocol_revision"`
+}
+
+type rpcResponse struct {
+ Error *rpcError `json:"error"`
+ ID int `json:"id"`
+ Result json.RawMessage `json:"result"`
+}
+
+type rpcError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+type normalizedJob struct {
+ Args map[string]any `json:"args"`
+ Attempt int `json:"attempt"`
+ AttemptedAt *string `json:"attempted_at"`
+ AttemptedBy []string `json:"attempted_by"`
+ CreatedAt string `json:"created_at"`
+ Errors []normalizedAttemptError `json:"errors"`
+ FinalizedAt *string `json:"finalized_at"`
+ ID int64 `json:"id"`
+ Kind string `json:"kind"`
+ MaxAttempts int `json:"max_attempts"`
+ Metadata map[string]any `json:"metadata"`
+ Priority int `json:"priority"`
+ Queue string `json:"queue"`
+ ScheduledAt string `json:"scheduled_at"`
+ State string `json:"state"`
+ Tags []string `json:"tags"`
+ UniqueKey *string `json:"unique_key"`
+ UniqueStates []string `json:"unique_states"`
+}
+
+type normalizedAttemptError struct {
+ At string `json:"at"`
+ Attempt int `json:"attempt"`
+ Error string `json:"error"`
+ Trace string `json:"trace"`
+}
+
+type normalizedInsertResult struct {
+ Job normalizedJob `json:"job"`
+ UniqueSkippedAsDuplicate bool `json:"unique_skipped_as_duplicate"`
+}
+
+type normalizedQueue struct {
+ CreatedAt string `json:"created_at"`
+ Metadata map[string]any `json:"metadata"`
+ Name string `json:"name"`
+ PausedAt *string `json:"paused_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// call performs a request that must succeed and decodes its result.
+func (adapter *adapter) call(t *testing.T, method string, params any, result any) {
+ t.Helper()
+
+ response, err := adapter.roundTrip(method, params)
+ require.NoErrorf(t, err, "%s adapter stderr: %s", adapter.name, adapter.stderr.String())
+ if response.Error != nil {
+ t.Fatalf("%s adapter %s failed (%d): %s\nstderr: %s", adapter.name, method, response.Error.Code, response.Error.Message, adapter.stderr.String())
+ }
+ if result != nil {
+ require.NoError(t, decodeAdapterResult(response.Result, result))
+ }
+}
+
+// callWithoutTest performs a serialized adapter call without invoking testing.T
+// methods, so a deliberately blocking request can run in a helper goroutine.
+func (adapter *adapter) callWithoutTest(method string, params any, result any) error {
+ response, err := adapter.roundTrip(method, params)
+ if err != nil {
+ return err
+ }
+ if response.Error != nil {
+ return fmt.Errorf("%s adapter %s failed (%d): %s", adapter.name, method, response.Error.Code, response.Error.Message)
+ }
+ if result != nil {
+ if err := decodeAdapterResult(response.Result, result); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// kill kills the adapter process, as a crash would, and waits for it to be
+// reaped so later steps observe a process that is really gone.
+func (adapter *adapter) kill(t *testing.T) {
+ t.Helper()
+
+ adapter.expectedExitError = true
+ require.NoError(t, adapter.adapterProcess.kill(adapterKillTimeout), "%s adapter", adapter.name)
+ adapter.running = false
+ adapter.openHandles = nil
+}
+
+// requireCallError performs a request that must fail with the named contract
+// error code.
+func (adapter *adapter) requireCallError(t *testing.T, method string, params any, errorName string) {
+ t.Helper()
+
+ requireResponseError(t, adapter, method, adapter.callResponse(t, method, params), errorName)
+}
+
+// requireUnvalidatedCallError sends a deliberately invalid request, bypassing
+// the harness's own contract validation, and requires the named error code.
+func (adapter *adapter) requireUnvalidatedCallError(t *testing.T, method string, params any, errorName string) {
+ t.Helper()
+
+ response, err := adapter.unvalidatedRoundTrip(method, params)
+ require.NoErrorf(t, err, "%s adapter stderr: %s", adapter.name, adapter.stderr.String())
+ requireResponseError(t, adapter, method, response, errorName)
+}
+
+func requireResponseError(t *testing.T, adapter *adapter, method string, response rpcResponse, errorName string) {
+ t.Helper()
+
+ contract, err := sharedAdapterContract()
+ require.NoError(t, err)
+ code, ok := contract.errorCodes[errorName]
+ require.True(t, ok, "unknown contract error %q", errorName)
+ require.NotNil(t, response.Error, "%s adapter %s unexpectedly succeeded", adapter.name, method)
+ require.Equal(t, code, response.Error.Code, "%s adapter %s returned %s (%d) instead of %s: %s",
+ adapter.name, method, contract.errorNames[response.Error.Code], response.Error.Code, errorName, response.Error.Message)
+}
+
+func (adapter *adapter) callResponse(t *testing.T, method string, params any) rpcResponse {
+ t.Helper()
+
+ response, err := adapter.roundTrip(method, params)
+ require.NoErrorf(t, err, "%s adapter stderr: %s", adapter.name, adapter.stderr.String())
+ return response
+}
+
+// recover returns an adapter to a state where the next scenario can reset
+// the database after an earlier scenario failed midway: it stops a running
+// client and rolls back transactions the harness opened. Errors are ignored
+// because the process may already be unusable, in which case the following
+// scenario reports the failure.
+func (adapter *adapter) recover() {
+ if adapter.expectedExitError {
+ return
+ }
+ if adapter.running {
+ _, _ = adapter.roundTrip("stop", map[string]any{"cancel": true})
+ }
+ handles := mapKeys(adapter.openHandles)
+ slices.Sort(handles)
+ for _, handle := range handles {
+ _, _ = adapter.roundTrip("tx_rollback", map[string]any{"handle": handle})
+ }
+}
+
+// roundTrip writes one request and reads its response, validating params,
+// results, and error codes against the adapter contract. It also tracks which
+// runtime client and transaction handles the adapter holds so recover can
+// release them.
+func (adapter *adapter) roundTrip(method string, params any) (rpcResponse, error) {
+ contract, err := sharedAdapterContract()
+ if err != nil {
+ return rpcResponse{}, err
+ }
+ if err := contract.validate(method, "params", params); err != nil {
+ return rpcResponse{}, fmt.Errorf("harness request invalid: %w", err)
+ }
+ response, err := adapter.unvalidatedRoundTrip(method, params)
+ if err != nil {
+ return response, err
+ }
+ if response.Error != nil {
+ if _, known := contract.errorNames[response.Error.Code]; !known {
+ return response, fmt.Errorf("%s adapter %s returned error code %d, which the contract does not define: %s",
+ adapter.name, method, response.Error.Code, response.Error.Message)
+ }
+ return response, nil
+ }
+ var result any
+ if len(response.Result) > 0 {
+ if result, err = decodeJSONWithNumbers(response.Result); err != nil {
+ return response, fmt.Errorf("decode %s adapter %s result: %w", adapter.name, method, err)
+ }
+ }
+ if err := contract.validate(method, "result", result); err != nil {
+ return response, fmt.Errorf("%s adapter: %w", adapter.name, err)
+ }
+ return response, nil
+}
+
+// unvalidatedRoundTrip sends a request without contract validation, for
+// scenarios that deliberately send invalid requests.
+func (adapter *adapter) unvalidatedRoundTrip(method string, params any) (rpcResponse, error) {
+ adapter.nextID++
+ requestID := adapter.nextID
+ encoded, err := json.Marshal(map[string]any{
+ "id": requestID,
+ "jsonrpc": "2.0",
+ "method": method,
+ "params": params,
+ })
+ if err != nil {
+ return rpcResponse{}, err
+ }
+ line, err := adapter.exchange(encoded, adapterRequestTimeout)
+ if errors.Is(err, errAdapterStopped) {
+ return rpcResponse{}, fmt.Errorf("%s adapter %s: %w: %s", adapter.name, method, err, adapter.stderr.String())
+ }
+ if err != nil {
+ return rpcResponse{}, fmt.Errorf("%s adapter %s: %w", adapter.name, method, err)
+ }
+
+ var response rpcResponse
+ if err := json.Unmarshal(line, &response); err != nil {
+ return rpcResponse{}, fmt.Errorf("decode %s adapter response: %w", adapter.name, err)
+ }
+ if response.ID != requestID {
+ return rpcResponse{}, fmt.Errorf("%s adapter response ID %d, expected %d", adapter.name, response.ID, requestID)
+ }
+ // Commit and rollback consume a handle even when they report an error.
+ if response.Error == nil || method == "tx_commit" || method == "tx_rollback" {
+ adapter.trackState(method, params)
+ }
+ return response, nil
+}
+
+func (adapter *adapter) trackState(method string, params any) {
+ switch method {
+ case "start":
+ adapter.running = true
+ case "stop":
+ adapter.running = false
+ case "tx_begin", "tx_commit", "tx_rollback":
+ encoded, err := json.Marshal(params)
+ if err != nil {
+ return
+ }
+ var decoded struct {
+ Handle string `json:"handle"`
+ }
+ if err := json.Unmarshal(encoded, &decoded); err != nil || decoded.Handle == "" {
+ return
+ }
+ if adapter.openHandles == nil {
+ adapter.openHandles = make(map[string]bool)
+ }
+ if method == "tx_begin" {
+ adapter.openHandles[decoded.Handle] = true
+ } else {
+ delete(adapter.openHandles, decoded.Handle)
+ }
+ }
+}
+
+var loadedContract struct { //nolint:gochecknoglobals // parsed once per test process
+ contract *adapterContract
+ err error
+ once sync.Once
+}
+
+// sharedAdapterContract returns the parsed adapter contract.
+func sharedAdapterContract() (*adapterContract, error) {
+ loadedContract.once.Do(func() {
+ _, filename, _, ok := runtime.Caller(0)
+ if !ok {
+ loadedContract.err = errors.New("locate harness source")
+ return
+ }
+ path := filepath.Clean(filepath.Join(filepath.Dir(filename), "../adapter/contract.json"))
+ loadedContract.contract, loadedContract.err = parseAdapterContract(path)
+ })
+ return loadedContract.contract, loadedContract.err
+}
+
+func repoRoot(t *testing.T) string {
+ t.Helper()
+
+ _, filename, _, ok := runtime.Caller(0)
+ require.True(t, ok)
+ return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
+}
+
+// startCandidateAdapter starts a candidate adapter from its descriptor on
+// PostgreSQL.
+func startCandidateAdapter(t *testing.T, root, databaseURL, name string, spec adapterSpec, command []string) *adapter {
+ t.Helper()
+
+ return startAdapterCommandForProfile(t, root, databaseURL, "postgres", "", name, spec, command)
+}
+
+// startReferenceAdapter starts the Go reference adapter on PostgreSQL.
+func startReferenceAdapter(t *testing.T, root, databaseURL, name string) *adapter {
+ t.Helper()
+
+ return startReferenceAdapterForProfile(t, root, databaseURL, "postgres", "", name)
+}
+
+// startReferenceAdapterForProfile starts the Go reference adapter for a
+// database kind and profile.
+func startReferenceAdapterForProfile(t *testing.T, root, databaseURL, databaseKind, profile, name string) *adapter {
+ t.Helper()
+
+ return startAdapterCommandForProfile(t, root, databaseURL, databaseKind, profile, name, referenceSpec(), referenceAdapterCommand(t, root))
+}
+
+// startWithTuning starts the adapter's client with params plus whichever
+// optional tuning parameters its implementation declares it honors.
+func (adapter *adapter) startWithTuning(t *testing.T, params, tuning map[string]any) {
+ t.Helper()
+
+ adapter.call(t, "start", adapter.spec.withStartOptions(params, tuning), nil)
+}
+
+// startAdapterCommandForProfile starts command as an adapter for the
+// implementation spec describes, on a database kind and profile (empty for
+// the adapter's default).
+//
+// On PostgreSQL the adapter is asked, through
+// RIVER_CONFORMANCE_APPLICATION_NAME, to identify its connections with an
+// application_name unique to the process, and the handshake reports whether
+// it did. Harness observations and fault injection then target this process
+// alone, even while another process of the same implementation is attached
+// to the database. An adapter that doesn't report the name keeps its
+// descriptor's shared application_name.
+func startAdapterCommandForProfile(
+ t *testing.T,
+ root, databaseURL, databaseKind, profile, name string,
+ spec adapterSpec,
+ command []string,
+) *adapter {
+ t.Helper()
+
+ require.NotEmpty(t, command)
+ // Keep cancellation after the adapter's graceful cleanup (LIFO), rather
+ // than using t.Context(), which is cancelled before cleanup begins.
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ executable, args := command[0], command[1:]
+ process := exec.CommandContext(ctx, executable, args...)
+ process.Dir = root
+ process.Env = append(
+ os.Environ(),
+ "RIVER_CONFORMANCE_DATABASE_KIND="+databaseKind,
+ "RIVER_CONFORMANCE_DATABASE_URL="+databaseURL,
+ )
+ if profile != "" {
+ process.Env = append(process.Env, "RIVER_CONFORMANCE_PROFILE="+profile)
+ }
+ var requestedApplicationName string
+ if databaseKind == "postgres" {
+ var err error
+ requestedApplicationName, err = processApplicationName(spec.ApplicationName)
+ require.NoError(t, err)
+ process.Env = append(process.Env, "RIVER_CONFORMANCE_APPLICATION_NAME="+requestedApplicationName)
+ }
+ started, err := startAdapterProcess(process)
+ require.NoError(t, err, "start %s adapter", name)
+ adapter := &adapter{adapterProcess: started, name: name, spec: spec}
+ t.Cleanup(func() {
+ // A killed adapter already exited with an error, but one that must be
+ // killed now was wedged and always fails the test.
+ err := adapter.shutdown(adapterExitTimeout)
+ if errors.Is(err, errAdapterExitTimeout) || (err != nil && !adapter.expectedExitError) {
+ t.Errorf("%s adapter exit: %v\nstderr: %s", name, err, adapter.stderr.String())
+ }
+ })
+ if requestedApplicationName != "" {
+ var handshake adapterHandshake
+ adapter.call(t, "handshake", map[string]any{}, &handshake)
+ adapter.applicationName, err = resolveApplicationName(requestedApplicationName, spec.ApplicationName, handshake.ApplicationName)
+ require.NoError(t, err, "%s adapter", name)
+ }
+ return adapter
+}
+
+func Example_protocolRequest() {
+ fmt.Println(`{"id":1,"jsonrpc":"2.0","method":"handshake","params":{}}`)
+ // Output: {"id":1,"jsonrpc":"2.0","method":"handshake","params":{}}
+}
diff --git a/conformance/harness/artifacts_test.go b/conformance/harness/artifacts_test.go
new file mode 100644
index 000000000..4420e4d80
--- /dev/null
+++ b/conformance/harness/artifacts_test.go
@@ -0,0 +1,398 @@
+package harness_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// conformanceTestsStarted counts conformance tests that began executing so a
+// required run can detect a -run pattern that matched nothing.
+var conformanceTestsStarted atomic.Int32 //nolint:gochecknoglobals // shared with TestMain
+
+func TestCompatibilityArtifacts(t *testing.T) {
+ t.Parallel()
+
+ conformanceTestsStarted.Add(1)
+
+ root := compatibilityRepositoryRoot(t)
+ readJSON := func(t *testing.T, path string, target any) {
+ t.Helper()
+
+ contents, err := os.ReadFile(filepath.Join(root, path))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(contents, target))
+ }
+
+ t.Run("CapabilitiesDecided", func(t *testing.T) {
+ t.Parallel()
+
+ type implementation struct {
+ Package string `json:"package"`
+ Registry string `json:"registry"`
+ Version string `json:"version"`
+ }
+ var manifest struct {
+ Capabilities map[string]string `json:"capabilities"`
+ CapabilityDecisions map[string]string `json:"capability_decisions"`
+ Implementations map[string]implementation `json:"implementations"`
+ }
+ readJSON(t, "conformance/manifest.json", &manifest)
+ require.NotEmpty(t, manifest.Capabilities)
+ for capability, status := range manifest.Capabilities {
+ require.Contains(t, []string{"complete", "in_progress", "not_applicable", "planned"}, status,
+ "capability %s", capability)
+ if status == "complete" {
+ require.NotContains(t, manifest.CapabilityDecisions, capability,
+ "complete capability %s needs no applicability decision", capability)
+ } else {
+ require.NotEmpty(t, manifest.CapabilityDecisions[capability],
+ "capability %s is %s and must record its applicability decision", capability, status)
+ }
+ }
+ for capability := range manifest.CapabilityDecisions {
+ require.Contains(t, manifest.Capabilities, capability, "decision for unknown capability %s", capability)
+ }
+
+ // Go is the reference; every other entry is a candidate, and the
+ // set of candidates is open.
+ require.Contains(t, manifest.Implementations, "go")
+ for name, implementation := range manifest.Implementations {
+ require.NotEmpty(t, implementation.Package, "implementation %s package", name)
+ require.NotEmpty(t, implementation.Registry, "implementation %s registry", name)
+ require.NotEmpty(t, implementation.Version, "implementation %s version", name)
+ }
+ })
+
+ t.Run("AdapterContractComplete", func(t *testing.T) {
+ t.Parallel()
+
+ var manifest struct {
+ Capabilities map[string]string `json:"capabilities"`
+ ProtocolRevision int `json:"protocol_revision"`
+ }
+ readJSON(t, "conformance/manifest.json", &manifest)
+ var contract struct {
+ AdapterVersion int `json:"adapter_version"`
+ Methods []struct {
+ Capability string `json:"capability"`
+ Description string `json:"description"`
+ Name string `json:"name"`
+ } `json:"methods"`
+ ProtocolRevision int `json:"protocol_revision"`
+ }
+ readJSON(t, "conformance/adapter/contract.json", &contract)
+ require.Positive(t, contract.AdapterVersion)
+ require.Equal(t, manifest.ProtocolRevision, contract.ProtocolRevision)
+ names := make([]string, 0, len(contract.Methods))
+ for _, method := range contract.Methods {
+ require.Contains(t, manifest.Capabilities, method.Capability, "method %s", method.Name)
+ require.NotEmpty(t, method.Description, "method %s", method.Name)
+ require.NotContains(t, names, method.Name)
+ names = append(names, method.Name)
+ }
+ require.True(t, slices.IsSorted(names))
+ require.Contains(t, names, "handshake")
+
+ parsed, err := parseAdapterContract(filepath.Join(root, "conformance/adapter/contract.json"))
+ require.NoError(t, err)
+ require.Len(t, parsed.errorNames, len(parsed.errorCodes), "error codes and names must be unique")
+ for _, name := range []string{
+ "database_error", "internal", "invalid_params", "invalid_request", "method_not_found",
+ "not_found", "parse_error", "rejected", "unsupported",
+ } {
+ require.Contains(t, parsed.errorCodes, name)
+ }
+ // Method schemas resolve, including references to shared schema
+ // files, and reject undeclared parameters.
+ require.NoError(t, parsed.validate("get", "params", map[string]any{"id": 1}))
+ require.ErrorContains(t, parsed.validate("get", "params", map[string]any{"id": 1, "extra": true}), "unknown property")
+ require.NoError(t, parsed.validate("queue_get", "result", map[string]any{
+ "created_at": "2026-01-02T03:04:05Z", "metadata": map[string]any{}, "name": "default",
+ "paused_at": nil, "updated_at": "2026-01-02T03:04:05Z",
+ }))
+ })
+
+ t.Run("AdapterProfilesAreContractSubsets", func(t *testing.T) {
+ t.Parallel()
+
+ var contract struct {
+ Methods []struct {
+ Capability string `json:"capability"`
+ Name string `json:"name"`
+ } `json:"methods"`
+ ProtocolRevision int `json:"protocol_revision"`
+ }
+ readJSON(t, "conformance/adapter/contract.json", &contract)
+ contractMethods := make(map[string]string, len(contract.Methods))
+ for _, method := range contract.Methods {
+ contractMethods[method.Name] = method.Capability
+ }
+
+ type profileArtifact struct {
+ Backend string `json:"backend"`
+ Capabilities []string `json:"capabilities"`
+ Extends string `json:"extends"`
+ Methods []string `json:"methods"`
+ Name string `json:"name"`
+ ProtocolRevision int `json:"protocol_revision"`
+ }
+ profiles := make(map[string]profileArtifact)
+ var manifest struct {
+ Capabilities map[string]string `json:"capabilities"`
+ }
+ readJSON(t, "conformance/manifest.json", &manifest)
+ paths, err := filepath.Glob(filepath.Join(root, "conformance/adapter/profiles/*.json"))
+ require.NoError(t, err)
+ require.NotEmpty(t, paths)
+ for _, path := range paths {
+ relative, err := filepath.Rel(root, path)
+ require.NoError(t, err)
+ var profile profileArtifact
+ readJSON(t, relative, &profile)
+ profiles[profile.Name] = profile
+ require.Contains(t, []string{"postgres", "sqlite"}, profile.Backend)
+ for _, capability := range profile.Capabilities {
+ require.Equal(t, "complete", manifest.Capabilities[capability],
+ "profile %q claims capability %q, which the manifest does not mark complete", profile.Name, capability)
+ }
+ require.NotEmpty(t, profile.Name)
+ require.Equal(t, contract.ProtocolRevision, profile.ProtocolRevision)
+ require.True(t, slices.IsSorted(profile.Capabilities))
+ require.True(t, slices.IsSorted(profile.Methods))
+ require.Contains(t, profile.Methods, "handshake")
+ for _, method := range profile.Methods {
+ capability, ok := contractMethods[method]
+ require.True(t, ok, "profile method %q is absent from the full contract", method)
+ require.Contains(t, profile.Capabilities, capability,
+ "profile method %q requires capability %q", method, capability)
+ }
+ }
+ // postgres-full-v1 is the whole contract and every complete capability.
+ full, ok := profiles["postgres-full-v1"]
+ require.True(t, ok, "profiles/postgres-full.json is missing")
+ fullMethods := make([]string, 0, len(contract.Methods))
+ for _, method := range contract.Methods {
+ fullMethods = append(fullMethods, method.Name)
+ }
+ require.Equal(t, fullMethods, full.Methods)
+ var complete []string
+ for capability, status := range manifest.Capabilities {
+ if status == "complete" {
+ complete = append(complete, capability)
+ }
+ }
+ slices.Sort(complete)
+ require.Equal(t, complete, full.Capabilities)
+ for name, profile := range profiles {
+ if profile.Extends == "" {
+ continue
+ }
+ base, ok := profiles[profile.Extends]
+ require.True(t, ok, "profile %q extends missing profile %q", name, profile.Extends)
+ require.Subset(t, profile.Capabilities, base.Capabilities,
+ "profile %q must retain all %q capabilities", name, profile.Extends)
+ require.Subset(t, profile.Methods, base.Methods,
+ "profile %q must retain all %q methods", name, profile.Extends)
+ }
+ })
+
+ t.Run("CandidateDescriptorsValid", func(t *testing.T) {
+ t.Parallel()
+
+ var manifest struct {
+ Implementations map[string]struct {
+ Version string `json:"version"`
+ } `json:"implementations"`
+ }
+ readJSON(t, "conformance/manifest.json", &manifest)
+ paths, err := filepath.Glob(filepath.Join(root, "conformance/adapter/candidates/*.json"))
+ require.NoError(t, err)
+ require.NotEmpty(t, paths)
+ for _, path := range paths {
+ contents, err := os.ReadFile(path)
+ require.NoError(t, err)
+ descriptor := decodeDescriptor(t, contents)
+ require.Equal(t, strings.TrimSuffix(filepath.Base(path), ".json"), descriptor.Implementation,
+ "%s must be named after its implementation", path)
+ require.NotEqual(t, "go", descriptor.Implementation, "Go is the reference, not a candidate")
+ require.Contains(t, manifest.Implementations, descriptor.Implementation)
+ require.Equal(t, manifest.Implementations[descriptor.Implementation].Version, descriptor.Version)
+ }
+ })
+
+ t.Run("MigrationInventoryComplete", func(t *testing.T) {
+ t.Parallel()
+
+ type migrationInventory struct {
+ Database string `json:"database"`
+ Files []struct {
+ Path string `json:"path"`
+ SHA256 string `json:"sha256"`
+ } `json:"files"`
+ Line string `json:"line"`
+ }
+ var manifest struct {
+ Migration struct {
+ Latest int `json:"latest"`
+ } `json:"migration"`
+ }
+ readJSON(t, "conformance/manifest.json", &manifest)
+ for path, database := range map[string]string{
+ "conformance/migrations.json": "postgres",
+ "conformance/migrations-sqlite.json": "sqlite",
+ } {
+ var migrations migrationInventory
+ readJSON(t, path, &migrations)
+ require.Equal(t, database, migrations.Database)
+ require.Equal(t, "main", migrations.Line)
+ // An up and a down file for each version.
+ require.Len(t, migrations.Files, 2*manifest.Migration.Latest)
+ for _, file := range migrations.Files {
+ require.Len(t, file.SHA256, 64)
+ require.True(t, strings.HasSuffix(file.Path, ".sql"))
+ }
+ }
+ })
+
+ t.Run("ScenariosUniqueAndSorted", func(t *testing.T) {
+ t.Parallel()
+
+ for _, inventory := range []struct {
+ path string
+ profile string
+ }{
+ {path: "conformance/scenarios/core.json"},
+ {path: "conformance/scenarios/insert-only.json", profile: "insert-only-v1"},
+ {path: "conformance/scenarios/sqlite-runtime.json", profile: "sqlite-runtime-v1"},
+ {path: "conformance/scenarios/sqlite-storage.json", profile: "portable-storage-v1"},
+ } {
+ verifyScenarioInventory(t, root, inventory.path, inventory.profile)
+ }
+ })
+
+ t.Run("ArtifactsMatchSchemas", func(t *testing.T) {
+ t.Parallel()
+
+ // Every checked-in conformance artifact declares a local schema and
+ // must validate against it.
+ validator := newSchemaValidator()
+ var validated []string
+ err := filepath.WalkDir(filepath.Join(root, "conformance"), func(path string, entry fs.DirEntry, err error) error {
+ if err != nil || entry.IsDir() || filepath.Ext(path) != ".json" {
+ return err
+ }
+ relative, err := filepath.Rel(root, path)
+ if err != nil || strings.HasPrefix(relative, "conformance/schema/") {
+ return err
+ }
+ contents, err := os.ReadFile(path) //nolint:gosec // Walks checked-in artifacts only.
+ if err != nil {
+ return err
+ }
+ document, err := decodeJSONWithNumbers(contents)
+ if err != nil {
+ return fmt.Errorf("%s: %w", relative, err)
+ }
+ object, _ := document.(map[string]any)
+ schema, _ := object["$schema"].(string)
+ if schema == "" {
+ // The migration inventories are checked by
+ // MigrationInventoryComplete and by their generator.
+ if strings.HasPrefix(relative, "conformance/migrations") {
+ return nil
+ }
+ return fmt.Errorf("%s must declare a local $schema", relative)
+ }
+ if err := validator.validateFile(document, filepath.Clean(filepath.Join(filepath.Dir(path), schema)), ""); err != nil {
+ return fmt.Errorf("%s: %w", relative, err)
+ }
+ validated = append(validated, relative)
+ return nil
+ })
+ require.NoError(t, err)
+ for _, required := range []string{
+ "conformance/adapter/candidates/rust.json",
+ "conformance/adapter/contract.json",
+ "conformance/fixtures/maintenance_values.json",
+ "conformance/fixtures/protocol_values.json",
+ "conformance/fixtures/unique_keys.json",
+ "conformance/manifest.json",
+ "conformance/scenarios/core.json",
+ } {
+ require.Contains(t, validated, required)
+ }
+ })
+}
+
+func verifyScenarioInventory(t *testing.T, root, path, profile string) {
+ t.Helper()
+
+ var inventory struct {
+ Scenarios []struct {
+ Evidence []struct {
+ Path string `json:"path"`
+ Symbol string `json:"symbol"`
+ } `json:"evidence"`
+ Name string `json:"name"`
+ Tier string `json:"tier"`
+ } `json:"scenarios"`
+ }
+ contents, err := os.ReadFile(filepath.Join(root, path))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(contents, &inventory))
+
+ names := make([]string, 0, len(inventory.Scenarios))
+ tiers := make(map[string]bool)
+ for _, scenario := range inventory.Scenarios {
+ require.NotContains(t, names, scenario.Name)
+ binding, ok := scenarioRegistry[scenario.Name]
+ require.True(t, ok, "scenario %q has no executable test binding", scenario.Name)
+ require.Equal(t, profile, binding.profile, "scenario %q profile", scenario.Name)
+ require.Equal(t, binding.tier, scenario.Tier, "scenario %q tier", scenario.Name)
+ require.NotEmpty(t, scenario.Evidence, "scenario %q has no executable evidence", scenario.Name)
+ for _, evidence := range scenario.Evidence {
+ require.True(t, strings.HasPrefix(evidence.Path, "conformance/harness/"),
+ "scenario %q evidence must point into the executable harness", scenario.Name)
+ require.NotContains(t, evidence.Path, "..")
+ evidenceContents, err := os.ReadFile(filepath.Join(root, evidence.Path))
+ require.NoError(t, err, "scenario %q evidence path", scenario.Name)
+ require.Contains(t, string(evidenceContents), "func "+evidence.Symbol+"(",
+ "scenario %q evidence symbol", scenario.Name)
+ }
+ names = append(names, scenario.Name)
+ tiers[scenario.Tier] = true
+ }
+ require.True(t, slices.IsSorted(names))
+ registeredNames := make([]string, 0, len(scenarioRegistry))
+ for name, binding := range scenarioRegistry {
+ if binding.profile == profile {
+ registeredNames = append(registeredNames, name)
+ }
+ }
+ slices.Sort(registeredNames)
+ require.Equal(t, registeredNames, names,
+ "%s and the executable scenario registry must have identical IDs", path)
+ if profile == "" {
+ for _, tier := range []string{"chaos", "codec", "mixed", "performance", "runtime", "storage"} {
+ require.True(t, tiers[tier], "missing scenario tier %s", tier)
+ }
+ }
+}
+
+func compatibilityRepositoryRoot(t *testing.T) string {
+ t.Helper()
+
+ _, filename, _, ok := runtime.Caller(0)
+ require.True(t, ok)
+ return filepath.Clean(filepath.Join(filepath.Dir(filename), "../.."))
+}
diff --git a/conformance/harness/budget_test.go b/conformance/harness/budget_test.go
new file mode 100644
index 000000000..9d195a214
--- /dev/null
+++ b/conformance/harness/budget_test.go
@@ -0,0 +1,42 @@
+package harness_test
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// soakFinishMargin covers what a soak does once its duration elapses:
+// waiting for the final batch, stopping clients, and shutting adapters down.
+// It exceeds one adapterRequestTimeout plus adapterExitTimeout, so a hung
+// adapter at the end of a soak still fails on the harness's own bounds.
+const soakFinishMargin = 5 * time.Minute
+
+// soakBudgetError reports whether a soak of duration, plus soakFinishMargin,
+// fits in the time remaining before the test's deadline.
+func soakBudgetError(variable string, duration, remaining time.Duration) error {
+ if needed := duration + soakFinishMargin; needed > remaining {
+ return fmt.Errorf("%s=%s needs %s including time to finish, but only %s remain before go test's -timeout; raise -timeout (CONFORMANCE_SOAK_TIMEOUT for make) or shorten the soak",
+ variable, duration, needed, remaining.Round(time.Second))
+ }
+ return nil
+}
+
+func TestSoakBudgetError(t *testing.T) {
+ t.Parallel()
+
+ t.Run("FitsWithinDeadline", func(t *testing.T) {
+ t.Parallel()
+
+ require.NoError(t, soakBudgetError("RIVER_CONFORMANCE_SOAK_DURATION", 10*time.Minute, 15*time.Minute))
+ })
+
+ t.Run("RejectsSoakOutlastingDeadline", func(t *testing.T) {
+ t.Parallel()
+
+ err := soakBudgetError("RIVER_CONFORMANCE_SOAK_DURATION", 6*time.Hour, 6*time.Hour+time.Minute)
+ require.EqualError(t, err, "RIVER_CONFORMANCE_SOAK_DURATION=6h0m0s needs 6h5m0s including time to finish, but only 6h1m0s remain before go test's -timeout; raise -timeout (CONFORMANCE_SOAK_TIMEOUT for make) or shorten the soak")
+ })
+}
diff --git a/conformance/harness/candidate_test.go b/conformance/harness/candidate_test.go
new file mode 100644
index 000000000..3de48f80c
--- /dev/null
+++ b/conformance/harness/candidate_test.go
@@ -0,0 +1,206 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "maps"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// Profile names a candidate descriptor may declare.
+const (
+ profileInsertOnly = "insert-only-v1"
+ profilePortableStorage = "portable-storage-v1"
+ profilePostgresFull = "postgres-full-v1"
+ profileSQLiteRuntime = "sqlite-runtime-v1"
+)
+
+// defaultCandidateProfiles are assumed for descriptors that do not declare
+// profiles, which keeps descriptors written before profiles existed valid.
+var defaultCandidateProfiles = []string{profilePortableStorage, profilePostgresFull, profileSQLiteRuntime} //nolint:gochecknoglobals // descriptor default
+
+// performanceBound returns the candidate's bound for a benchmark mode.
+func (spec adapterSpec) performanceBound(mode string) performanceBound {
+ if bound, ok := spec.Performance[mode]; ok {
+ return bound
+ }
+ return defaultPerformanceBounds[mode]
+}
+
+// servesProfile reports whether the candidate declares a conformance profile.
+func (spec adapterSpec) servesProfile(profile string) bool {
+ if spec.Profiles == nil {
+ return slices.Contains(defaultCandidateProfiles, profile)
+ }
+ return slices.Contains(spec.Profiles, profile)
+}
+
+// requireProfile skips an owner whose profile the candidate does not
+// declare, or fails when RIVER_CONFORMANCE_REQUIRED=1 selected it anyway.
+func (spec adapterSpec) requireProfile(t *testing.T, profile string) {
+ t.Helper()
+
+ if spec.servesProfile(profile) {
+ return
+ }
+ if conformanceRequired() {
+ t.Fatalf("%s candidate does not declare the %s profile this test requires", spec.Implementation, profile)
+ }
+ t.Skipf("%s candidate does not declare the %s profile", spec.Implementation, profile)
+}
+
+// supportsStartOption reports whether the candidate honors an optional
+// `start` tuning parameter.
+func (spec adapterSpec) supportsStartOption(option string) bool {
+ return slices.Contains(spec.StartOptions, option)
+}
+
+// withStartOptions copies params and adds the optional tuning parameters the
+// candidate declares it honors.
+func (spec adapterSpec) withStartOptions(params map[string]any, options map[string]any) map[string]any {
+ merged := make(map[string]any, len(params)+len(options))
+ maps.Copy(merged, params)
+ for key, value := range options {
+ if spec.supportsStartOption(key) {
+ merged[key] = value
+ }
+ }
+ return merged
+}
+
+// referenceSpec describes the Go reference implementation. The reference
+// honors no optional start tuning parameters because Go does not expose
+// them as configuration.
+func referenceSpec() adapterSpec {
+ return adapterSpec{ApplicationName: referenceApplicationName, Implementation: "go"}
+}
+
+// conformanceCandidateSpec loads the candidate descriptor from
+// RIVER_CONFORMANCE_CANDIDATE (inline JSON) or RIVER_CONFORMANCE_CANDIDATE_FILE,
+// defaulting to the checked Rust descriptor, and builds it once.
+func conformanceCandidateSpec(t *testing.T, root string, release bool) adapterSpec {
+ t.Helper()
+
+ specs := loadDescriptors(t, root, "RIVER_CONFORMANCE_CANDIDATE", "RIVER_CONFORMANCE_CANDIDATE_FILE")
+ require.Len(t, specs, 1, "exactly one candidate descriptor is required")
+ return prepareCandidate(t, root, specs[0], release)
+}
+
+// conformancePeerSpecs loads additional candidates for multi-engine tiers
+// from RIVER_CONFORMANCE_PEER (an inline descriptor object or array) or
+// RIVER_CONFORMANCE_PEER_FILE (one or more descriptor paths separated by the
+// platform's path list separator), defaulting to the checked Rust descriptor.
+func conformancePeerSpecs(t *testing.T, root string, release bool) []adapterSpec {
+ t.Helper()
+
+ specs := loadDescriptors(t, root, "RIVER_CONFORMANCE_PEER", "RIVER_CONFORMANCE_PEER_FILE")
+ for index := range specs {
+ specs[index] = prepareCandidate(t, root, specs[index], release)
+ }
+ return specs
+}
+
+func loadDescriptors(t *testing.T, root, inlineVariable, fileVariable string) []adapterSpec {
+ t.Helper()
+
+ encoded := os.Getenv(inlineVariable)
+ paths := os.Getenv(fileVariable)
+ require.False(t, encoded != "" && paths != "", "set only one of %s or %s", inlineVariable, fileVariable)
+
+ var documents [][]byte
+ switch {
+ case encoded != "":
+ trimmed := bytes.TrimSpace([]byte(encoded))
+ if len(trimmed) > 0 && trimmed[0] == '[' {
+ var raw []json.RawMessage
+ require.NoError(t, json.Unmarshal(trimmed, &raw), "%s must be a descriptor object or array", inlineVariable)
+ for _, document := range raw {
+ documents = append(documents, document)
+ }
+ } else {
+ documents = append(documents, trimmed)
+ }
+ default:
+ if paths == "" {
+ paths = "conformance/adapter/candidates/rust.json"
+ }
+ for _, descriptorPath := range filepath.SplitList(paths) {
+ if !filepath.IsAbs(descriptorPath) {
+ descriptorPath = filepath.Join(root, descriptorPath)
+ }
+ //nolint:gosec // The caller explicitly selects a local candidate descriptor.
+ document, err := os.ReadFile(descriptorPath)
+ require.NoError(t, err)
+ documents = append(documents, document)
+ }
+ }
+ require.NotEmpty(t, documents, "%s selects no descriptor", fileVariable)
+
+ specs := make([]adapterSpec, 0, len(documents))
+ for _, document := range documents {
+ specs = append(specs, decodeDescriptor(t, document))
+ }
+ return specs
+}
+
+// candidateBuilds records build commands that already ran in this test
+// process, keyed by their arguments.
+var candidateBuilds sync.Map //nolint:gochecknoglobals // one build per process
+
+// prepareCandidate selects the debug or release commands, runs the
+// descriptor's build command once per process, and requires the restart
+// command's executable to exist. Restart scenarios run the executable
+// directly, so it must be the artifact the build just produced rather than
+// whatever an earlier build left behind.
+func prepareCandidate(t *testing.T, root string, spec adapterSpec, release bool) adapterSpec {
+ t.Helper()
+
+ build := spec.BuildCommand
+ if release {
+ if len(spec.ReleaseCommand) > 0 {
+ spec.Command = slices.Clone(spec.ReleaseCommand)
+ spec.RestartCommand = slices.Clone(spec.ReleaseCommand)
+ }
+ if len(spec.ReleaseBuildCommand) > 0 {
+ build = spec.ReleaseBuildCommand
+ }
+ }
+ if len(spec.RestartCommand) == 0 {
+ spec.RestartCommand = slices.Clone(spec.Command)
+ }
+ if len(build) > 0 {
+ key := strings.Join(build, "\x00")
+ once, _ := candidateBuilds.LoadOrStore(key, &sync.Once{})
+ once.(*sync.Once).Do(func() { //nolint:forcetypeassert // The map only stores *sync.Once under build keys.
+ //nolint:gosec // The descriptor explicitly names its build command.
+ command := exec.CommandContext(context.Background(), build[0], build[1:]...)
+ command.Dir = root
+ if output, err := command.CombinedOutput(); err != nil {
+ candidateBuilds.Store(key+"\x00failed", fmt.Sprintf("%v\n%s", err, output))
+ }
+ })
+ if failure, failed := candidateBuilds.Load(key + "\x00failed"); failed {
+ t.Fatalf("%s candidate build %v failed:\n%s", spec.Implementation, build, failure)
+ }
+ }
+ if executable := spec.RestartCommand[0]; strings.ContainsRune(executable, filepath.Separator) {
+ if !filepath.IsAbs(executable) {
+ executable = filepath.Join(root, executable)
+ }
+ _, err := os.Stat(executable)
+ require.NoError(t, err, "%s restart_command executable does not exist; build it first or set build_command", spec.Implementation)
+ }
+ return spec
+}
diff --git a/conformance/harness/contract_test.go b/conformance/harness/contract_test.go
new file mode 100644
index 000000000..d679164b4
--- /dev/null
+++ b/conformance/harness/contract_test.go
@@ -0,0 +1,72 @@
+package harness_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+)
+
+// adapterContract is conformance/adapter/contract.json with lookups for
+// validating requests and responses.
+type adapterContract struct {
+ errorNames map[int]string
+ errorCodes map[string]int
+ methods map[string]int
+ path string
+ validator *schemaValidator
+}
+
+func parseAdapterContract(path string) (*adapterContract, error) {
+ contents, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var decoded struct {
+ Errors []struct {
+ Code int `json:"code"`
+ Name string `json:"name"`
+ } `json:"errors"`
+ Methods []struct {
+ Name string `json:"name"`
+ } `json:"methods"`
+ }
+ if err := json.Unmarshal(contents, &decoded); err != nil {
+ return nil, fmt.Errorf("decode %s: %w", path, err)
+ }
+ contract := &adapterContract{
+ errorCodes: make(map[string]int, len(decoded.Errors)),
+ errorNames: make(map[int]string, len(decoded.Errors)),
+ methods: make(map[string]int, len(decoded.Methods)),
+ path: path,
+ validator: newSchemaValidator(),
+ }
+ for _, contractError := range decoded.Errors {
+ contract.errorCodes[contractError.Name] = contractError.Code
+ contract.errorNames[contractError.Code] = contractError.Name
+ }
+ for index, method := range decoded.Methods {
+ contract.methods[method.Name] = index
+ }
+ return contract, nil
+}
+
+// validate checks a method's params or result against its contract schema.
+// Methods outside the contract are not validated.
+func (contract *adapterContract) validate(method, part string, value any) error {
+ index, ok := contract.methods[method]
+ if !ok {
+ return nil
+ }
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ decoded, err := decodeJSONWithNumbers(encoded)
+ if err != nil {
+ return err
+ }
+ if err := contract.validator.validateFile(decoded, contract.path, fmt.Sprintf("#/methods/%d/%s", index, part)); err != nil {
+ return fmt.Errorf("%s %s do not match the adapter contract: %w", method, part, err)
+ }
+ return nil
+}
diff --git a/conformance/harness/coordination_scenarios_test.go b/conformance/harness/coordination_scenarios_test.go
new file mode 100644
index 000000000..4dbc5a845
--- /dev/null
+++ b/conformance/harness/coordination_scenarios_test.go
@@ -0,0 +1,816 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// verifyUnknownKind checks that a job whose kind has no registered worker is
+// fetched and failed with the canonical unknown-kind error rather than being
+// skipped. The error is retryable, so a job with attempts left is retried.
+func verifyUnknownKind(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ inserter *adapter
+ worker *adapter
+ }{
+ {inserter: goAdapter, worker: candidateAdapter},
+ {inserter: candidateAdapter, worker: goAdapter},
+ } {
+ pair.inserter.call(t, "reset", map[string]any{}, nil)
+ var discarded, retryable normalizedJob
+ pair.inserter.call(t, "raw_insert_no_notify", map[string]any{
+ "kind": "conformance_unregistered", "message": "must fail compatibly",
+ "opts": map[string]any{"max_attempts": 1},
+ }, &discarded)
+ pair.inserter.call(t, "raw_insert_no_notify", map[string]any{
+ "kind": "conformance_unregistered", "message": "must be retried",
+ "opts": map[string]any{"max_attempts": 5},
+ }, &retryable)
+ workerID := pair.worker.name + "-unknown-kind"
+ pair.worker.call(t, "start", map[string]any{
+ "client_id": workerID, "max_workers": 1,
+ }, nil)
+ var known normalizedJob
+ pair.inserter.call(t, "insert", map[string]any{"message": "known kind from " + pair.inserter.name}, &known)
+ pair.worker.call(t, "wait", map[string]any{"id": known.ID}, &known)
+ require.Equal(t, "completed", known.State)
+
+ pair.worker.call(t, "wait", map[string]any{
+ "id": discarded.ID, "states": []string{"discarded"},
+ }, &discarded)
+ require.Equal(t, 1, discarded.Attempt)
+ require.Equal(t, []string{workerID}, discarded.AttemptedBy)
+ require.Len(t, discarded.Errors, 1)
+ require.Equal(t,
+ "job kind is not registered in the client's Workers bundle: conformance_unregistered",
+ discarded.Errors[0].Error,
+ )
+
+ // The first retry delay (about one second) is inside the scheduler
+ // interval, so the failed job is made available again immediately
+ // and retried. The second delay (about sixteen seconds) is not, so
+ // the job then waits as retryable.
+ pair.worker.call(t, "wait", map[string]any{
+ "id": retryable.ID, "states": []string{"retryable"},
+ }, &retryable)
+ require.Equal(t, 2, retryable.Attempt)
+ require.Equal(t, []string{workerID, workerID}, retryable.AttemptedBy)
+ require.Len(t, retryable.Errors, 2)
+ for _, attemptError := range retryable.Errors {
+ require.Equal(t, discarded.Errors[0].Error, attemptError.Error)
+ }
+ require.Nil(t, retryable.FinalizedAt)
+ pair.worker.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+// verifyInsertNotificationWakeup proves an insert from one implementation
+// wakes the other's worker through a notification. The worker polls only
+// once a minute, so prompt completion cannot come from polling.
+func verifyInsertNotificationWakeup(t *testing.T, controller, worker *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-notification-only", "fetch_poll_interval_ms": 60_000,
+ "max_workers": 1,
+ }, nil)
+ startedAt := time.Now()
+ var inserted normalizedJob
+ controller.call(t, "insert", map[string]any{
+ "message": "cross-language insert notification",
+ }, &inserted)
+ worker.call(t, "wait", map[string]any{"id": inserted.ID}, &inserted)
+ require.Equal(t, "completed", inserted.State)
+ require.Less(t, time.Since(startedAt), 5*time.Second)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyPauseResumeNotification pauses a queue from one implementation and
+// proves the other's running worker stops working it until it is resumed.
+// The worker first reports that it applied the pause. A marker job on a
+// second, unpaused queue then proves the worker kept fetching after the
+// paused job was inserted, and the paused job's attempt must start no
+// earlier than the resume.
+func verifyPauseResumeNotification(t *testing.T, controller, worker *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-pause-resume", "instrumented": true, "max_workers": 1,
+ }, nil)
+ worker.call(t, "queue_add", map[string]any{"max_workers": 1, "name": "pause_marker"}, nil)
+
+ controller.call(t, "queue_pause", map[string]any{"name": "default"}, nil)
+ waitForRuntimeStats(t, worker, func(stats runtimeStats) bool {
+ return slices.Contains(stats.Events, "queue_paused")
+ })
+ var paused, marker normalizedJob
+ controller.call(t, "insert", map[string]any{"message": "inserted while paused"}, &paused)
+ controller.call(t, "insert", map[string]any{
+ "message": "unpaused marker", "opts": map[string]any{"queue": "pause_marker"},
+ }, &marker)
+ worker.call(t, "wait", map[string]any{"id": marker.ID}, &marker)
+ require.Equal(t, "completed", marker.State)
+ worker.call(t, "get", map[string]any{"id": paused.ID}, &paused)
+ require.Equal(t, "available", paused.State, "a paused queue was worked")
+
+ controller.call(t, "queue_resume", map[string]any{"name": "default"}, nil)
+ var queue normalizedQueue
+ controller.call(t, "queue_get", map[string]any{"name": "default"}, &queue)
+ require.Nil(t, queue.PausedAt)
+ resumedAt := parseTime(t, queue.UpdatedAt)
+ worker.call(t, "wait", map[string]any{"id": paused.ID}, &paused)
+ require.Equal(t, "completed", paused.State)
+ require.NotNil(t, paused.AttemptedAt)
+ require.False(t, parseTime(t, *paused.AttemptedAt).Before(resumedAt),
+ "paused job attempted at %s before the queue resumed at %s", *paused.AttemptedAt, queue.UpdatedAt)
+ waitForRuntimeStats(t, worker, func(stats runtimeStats) bool {
+ return slices.Contains(stats.Events, "queue_resumed")
+ })
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyRemoteCancelNotification cancels a running job from the other
+// implementation and requires the cancellation to reach the worker through a
+// control notification, recording the cancellation request in metadata.
+func verifyRemoteCancelNotification(t *testing.T, controller, worker *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-remote-cancel", "fetch_poll_interval_ms": 60_000,
+ "max_workers": 1,
+ }, nil)
+ var cancellable normalizedJob
+ controller.call(t, "insert", map[string]any{
+ "behavior": "cooperative_cancel", "message": "cross-language cancel notification",
+ }, &cancellable)
+ worker.call(t, "wait", map[string]any{
+ "id": cancellable.ID, "states": []string{"running"},
+ }, &cancellable)
+ startedAt := time.Now()
+ var requested normalizedJob
+ controller.call(t, "cancel", map[string]any{"id": cancellable.ID}, &requested)
+ require.Equal(t, "running", requested.State, "cancelling a running job only requests cancellation")
+ cancelAttemptedAt, ok := requested.Metadata["cancel_attempted_at"].(string)
+ require.True(t, ok, "cancel_attempted_at metadata must be a timestamp string: %v", requested.Metadata)
+ parseTime(t, cancelAttemptedAt)
+ worker.call(t, "wait", map[string]any{"id": cancellable.ID}, &cancellable)
+ require.Equal(t, "cancelled", cancellable.State)
+ require.Less(t, time.Since(startedAt), 5*time.Second)
+ require.Equal(t, cancelAttemptedAt, cancellable.Metadata["cancel_attempted_at"])
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyPollOnlyRemoteCancellation cancels a running job from the other
+// implementation while the worker runs without notifications. The worker
+// polls its running jobs for cancellation requests every two seconds, so
+// the job is cancelled without a control notification reaching it.
+func verifyPollOnlyRemoteCancellation(t *testing.T, controller, worker *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-poll-only-cancel", "fetch_poll_interval_ms": 100,
+ "max_workers": 1, "poll_only": true,
+ }, nil)
+ var cancellable normalizedJob
+ controller.call(t, "insert", map[string]any{
+ "behavior": "cooperative_cancel", "message": "poll-only cancel",
+ }, &cancellable)
+ worker.call(t, "wait", map[string]any{
+ "id": cancellable.ID, "states": []string{"running"},
+ }, &cancellable)
+ startedAt := time.Now()
+ controller.call(t, "cancel", map[string]any{"id": cancellable.ID}, nil)
+ worker.call(t, "wait", map[string]any{"id": cancellable.ID}, &cancellable)
+ require.Equal(t, "cancelled", cancellable.State)
+ require.Len(t, cancellable.Errors, 1)
+ require.Equal(t, "JobCancelError: job cancelled remotely", cancellable.Errors[0].Error)
+ require.Less(t, time.Since(startedAt), 6*time.Second)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyCooperativeRemoteCancellation checks the canonical persisted outcome
+// and event of a worker that honors a remote cancellation.
+func verifyCooperativeRemoteCancellation(t *testing.T, controller, worker *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-cooperative-cancel", "fetch_poll_interval_ms": 60_000,
+ "instrumented": true, "max_workers": 1,
+ }, nil)
+ var cancellable normalizedJob
+ controller.call(t, "insert", map[string]any{
+ "behavior": "cooperative_cancel", "message": "cooperative cancellation",
+ }, &cancellable)
+ worker.call(t, "wait", map[string]any{
+ "id": cancellable.ID, "states": []string{"running"},
+ }, &cancellable)
+ controller.call(t, "cancel", map[string]any{"id": cancellable.ID}, &cancellable)
+ worker.call(t, "wait", map[string]any{"id": cancellable.ID}, &cancellable)
+ require.Equal(t, "cancelled", cancellable.State)
+ require.Equal(t, 1, cancellable.Attempt)
+ require.NotNil(t, cancellable.FinalizedAt)
+ require.Len(t, cancellable.Errors, 1)
+ require.Equal(t, "JobCancelError: job cancelled remotely", cancellable.Errors[0].Error)
+ stats := waitForRuntimeStats(t, worker, func(stats runtimeStats) bool {
+ return slices.Contains(stats.Events, "job_cancelled")
+ })
+ require.NotContains(t, stats.Events, "job_failed")
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyRemoteQueueSubscriptionEvents checks that a pause or resume issued
+// by one implementation produces exactly one subscription event in the other
+// and that repeated requests are not delivered again. Control notifications
+// are processed in order, so waiting for the next state change proves any
+// event from a repeated request would already have been observed.
+func verifyRemoteQueueSubscriptionEvents(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ controller *adapter
+ observer *adapter
+ }{
+ {controller: candidateAdapter, observer: goAdapter},
+ {controller: goAdapter, observer: candidateAdapter},
+ } {
+ pair.observer.call(t, "reset", map[string]any{}, nil)
+ pair.observer.call(t, "start", map[string]any{
+ "client_id": pair.observer.name + "-remote-queue-subscriber",
+ "fetch_poll_interval_ms": 60_000,
+ "instrumented": true,
+ "max_workers": 1,
+ }, nil)
+
+ var warmup normalizedJob
+ pair.controller.call(t, "insert", map[string]any{
+ "message": "activate remote queue subscriber",
+ }, &warmup)
+ pair.observer.call(t, "wait", map[string]any{"id": warmup.ID}, &warmup)
+ require.Equal(t, "completed", warmup.State)
+
+ waitForEventCounts := func(paused, resumed int) {
+ stats := waitForRuntimeStats(t, pair.observer, func(stats runtimeStats) bool {
+ return countRuntimeEvent(stats, "queue_paused") >= paused &&
+ countRuntimeEvent(stats, "queue_resumed") >= resumed
+ })
+ require.Equal(t, paused, countRuntimeEvent(stats, "queue_paused"))
+ require.Equal(t, resumed, countRuntimeEvent(stats, "queue_resumed"))
+ }
+ pair.controller.call(t, "queue_pause", map[string]any{"name": "*"}, nil)
+ waitForEventCounts(1, 0)
+ pair.controller.call(t, "queue_pause", map[string]any{"name": "*"}, nil)
+ pair.controller.call(t, "queue_resume", map[string]any{"name": "*"}, nil)
+ waitForEventCounts(1, 1)
+ pair.controller.call(t, "queue_resume", map[string]any{"name": "*"}, nil)
+ pair.controller.call(t, "queue_pause", map[string]any{"name": "*"}, nil)
+ waitForEventCounts(2, 1)
+ pair.controller.call(t, "queue_resume", map[string]any{"name": "*"}, nil)
+ waitForEventCounts(2, 2)
+
+ pair.observer.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+// verifyTransactionalNotificationWakeups checks that transactional batch
+// inserts notify only on commit. Commit must wake a worker that polls once a
+// minute. Rollback must publish nothing: the harness listens to the raw
+// insert channel and sends its own marker after the rollback, so any
+// notification the rolled-back transaction leaked would arrive first.
+func verifyTransactionalNotificationWakeups(t *testing.T, observer *postgresObserver, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ const method = "tx_insert_many"
+ insertChannel := observer.currentSchema(t) + ".river_insert"
+ for _, pair := range []struct {
+ controller *adapter
+ worker *adapter
+ }{
+ {controller: candidateAdapter, worker: goAdapter},
+ {controller: goAdapter, worker: candidateAdapter},
+ } {
+ for _, commit := range []bool{false, true} {
+ pair.worker.call(t, "reset", map[string]any{}, nil)
+ pair.worker.call(t, "start", map[string]any{
+ "client_id": pair.worker.name + "-transaction-notification",
+ "fetch_poll_interval_ms": 60_000,
+ "max_workers": 2,
+ }, nil)
+ listener := observer.listen(t, insertChannel)
+
+ outcome := "rollback"
+ if commit {
+ outcome = "commit"
+ }
+ handle := fmt.Sprintf("notification-%s-%s-%s", pair.controller.name, method, outcome)
+ tag := strings.ReplaceAll(handle, "-", "_")
+ pair.controller.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ jobs := []map[string]any{
+ {"message": handle + " first", "opts": map[string]any{"tags": []string{tag}}},
+ {"message": handle + " second", "opts": map[string]any{"tags": []string{tag}}},
+ }
+ var inserted struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ pair.controller.call(t, method, map[string]any{
+ "handle": handle, "jobs": jobs,
+ }, &inserted)
+ require.Len(t, inserted.Results, 2)
+
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.worker.call(t, "list", map[string]any{"tags_all": []string{tag}}, &listed)
+ require.Empty(t, listed.Jobs, "transactional batch became visible before commit")
+
+ if commit {
+ startedAt := time.Now()
+ pair.controller.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ waitForListedJobCount(t, pair.worker, map[string]any{
+ "states": []string{"completed"}, "tags_all": []string{tag},
+ }, 2)
+ require.Less(t, time.Since(startedAt), 5*time.Second,
+ "committed transactional insert did not wake a 60-second polling worker")
+ require.NotEmpty(t, listener.receiveUntilMarker(t, observer, handle+"-marker"),
+ "commit published no insert notification")
+ } else {
+ pair.controller.call(t, "tx_rollback", map[string]any{"handle": handle}, nil)
+ require.Empty(t, listener.receiveUntilMarker(t, observer, handle+"-marker"),
+ "rolled-back transaction published an insert notification")
+ pair.worker.call(t, "list", map[string]any{"tags_all": []string{tag}}, &listed)
+ require.Empty(t, listed.Jobs)
+ }
+ pair.worker.call(t, "stop", map[string]any{}, nil)
+ }
+ }
+}
+
+// verifyLeadershipRequestLifecycle requests resignation from each
+// implementation, directly and in transactions, while the other leads. On
+// PostgreSQL the harness also listens to the raw leadership channel to prove
+// a rolled-back request publishes nothing.
+func verifyLeadershipRequestLifecycle(t *testing.T, observer *postgresObserver, first, second *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ leader *adapter
+ requester *adapter
+ }{
+ {leader: first, requester: second},
+ {leader: second, requester: first},
+ } {
+ pair.leader.call(t, "reset", map[string]any{}, nil)
+ pair.leader.call(t, "start", map[string]any{
+ "client_id": pair.leader.name + "-resign-lifecycle", "max_workers": 1,
+ }, nil)
+ initial := waitForLeaderTerm(t, pair.leader, "")
+ require.Equal(t, pair.leader.name+"-resign-lifecycle", initial.LeaderID)
+
+ pair.requester.call(t, "request_resign", map[string]any{}, nil)
+ afterDirect := waitForLeaderTerm(t, pair.leader, initial.ElectedAt)
+
+ var listener *postgresNotificationListener
+ if observer != nil {
+ listener = observer.listen(t, observer.currentSchema(t)+".river_leadership")
+ }
+ rollbackHandle := pair.requester.name + "-resign-rollback"
+ pair.requester.call(t, "tx_begin", map[string]any{"handle": rollbackHandle}, nil)
+ pair.requester.call(t, "request_resign", map[string]any{"handle": rollbackHandle}, nil)
+ pair.requester.call(t, "tx_rollback", map[string]any{"handle": rollbackHandle}, nil)
+ if listener != nil {
+ require.Empty(t, listener.receiveUntilMarker(t, observer, rollbackHandle+"-marker"),
+ "rolled-back resignation request published a notification")
+ }
+ require.Equal(t, afterDirect.ElectedAt, readLeader(t, pair.leader).ElectedAt)
+
+ commitHandle := pair.requester.name + "-resign-commit"
+ pair.requester.call(t, "tx_begin", map[string]any{"handle": commitHandle}, nil)
+ pair.requester.call(t, "request_resign", map[string]any{"handle": commitHandle}, nil)
+ pair.requester.call(t, "tx_commit", map[string]any{"handle": commitHandle}, nil)
+ if listener != nil {
+ // The leader may already have answered with a resigned
+ // notification; only resignation requests are counted.
+ requests := 0
+ for _, payload := range listener.receiveUntilMarker(t, observer, commitHandle+"-marker") {
+ var notification struct {
+ Action string `json:"action"`
+ }
+ require.NoError(t, json.Unmarshal([]byte(payload), ¬ification))
+ if notification.Action == "request_resign" {
+ requests++
+ }
+ }
+ require.Equal(t, 1, requests, "committed resignation request was not published exactly once")
+ }
+ _ = waitForLeaderTerm(t, pair.leader, afterDirect.ElectedAt)
+ pair.leader.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+// verifyGracefulLeaderFailover moves leadership between the reference and the
+// candidate with resignation requests and graceful stops in both directions,
+// requiring both implementations to agree on the single current leader.
+func verifyGracefulLeaderFailover(t *testing.T, pair mixedPair) {
+ t.Helper()
+
+ goID := "go-mixed-worker"
+ candidateID := pair.candidateSpec.Implementation + "-mixed-worker"
+ pair.reference.call(t, "reset", map[string]any{}, nil)
+ pair.reference.call(t, "start", map[string]any{"client_id": goID, "max_workers": 2}, nil)
+ pair.candidate.call(t, "start", map[string]any{"client_id": candidateID, "max_workers": 2}, nil)
+ firstTerm := waitForLeaderTerm(t, pair.reference, "")
+ pair.reference.call(t, "request_resign", map[string]any{}, nil)
+ secondTerm := waitForLeaderTerm(t, pair.reference, firstTerm.ElectedAt)
+ pair.candidate.call(t, "request_resign", map[string]any{}, nil)
+ thirdTerm := waitForLeaderTerm(t, pair.candidate, secondTerm.ElectedAt)
+ require.Equal(t, thirdTerm, readLeader(t, pair.reference), "implementations disagree about the leader")
+
+ leaderAdapter, leaderID := pair.reference, goID
+ followerAdapter, followerID := pair.candidate, candidateID
+ if thirdTerm.LeaderID == candidateID {
+ leaderAdapter, leaderID = pair.candidate, candidateID
+ followerAdapter, followerID = pair.reference, goID
+ } else {
+ require.Equal(t, goID, thirdTerm.LeaderID)
+ }
+ leaderAdapter.call(t, "stop", map[string]any{}, nil)
+ require.Equal(t, followerID, waitForLeader(t, followerAdapter, leaderID))
+ leaderAdapter.call(t, "start", map[string]any{"client_id": leaderID, "max_workers": 2}, nil)
+ followerAdapter.call(t, "stop", map[string]any{}, nil)
+ require.Equal(t, leaderID, waitForLeader(t, leaderAdapter, followerID))
+ require.Equal(t, readLeader(t, leaderAdapter), readLeader(t, followerAdapter))
+ leaderAdapter.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyLeaderElectionDisabled starts a client with leader election disabled
+// alongside an eligible client of another implementation. The disabled
+// client must reject periodic jobs, work the periodic job the eligible
+// leader enqueues into its queue, run no leader-only maintenance, and never
+// become leader, including after the eligible leader stops and after the
+// disabled client restarts. Where the implementation allows it, the disabled
+// client uses a short election interval, so one that still took part in
+// elections would become leader within the scenario.
+func verifyLeaderElectionDisabled(t *testing.T, disabled, eligible *adapter) {
+ t.Helper()
+
+ // SQLite can't filter job lists by metadata, so periodic jobs are
+ // selected from the full list.
+ periodicJobs := func() []normalizedJob {
+ var result struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ disabled.call(t, "list", map[string]any{"limit": 100}, &result)
+ var periodic []normalizedJob
+ for _, job := range result.Jobs {
+ if job.Metadata["river:periodic_job_id"] == "conformance-periodic" {
+ periodic = append(periodic, job)
+ }
+ }
+ return periodic
+ }
+ disabledID := disabled.spec.Implementation + "-election-disabled"
+ eligibleID := eligible.spec.Implementation + "-election-eligible"
+ disabledParams := map[string]any{
+ "client_id": disabledID, "instrumented": true, "leader_election_disabled": true, "max_workers": 1,
+ }
+ fastElection := map[string]any{"elect_interval_ms": 20}
+ disabled.call(t, "reset", map[string]any{}, nil)
+
+ disabled.requireCallError(t, "start", map[string]any{
+ "client_id": disabledID, "leader_election_disabled": true, "periodic_run_on_start": true,
+ }, "rejected")
+ disabled.startWithTuning(t, disabledParams, fastElection)
+ var marker normalizedJob
+ eligible.call(t, "insert", map[string]any{"message": "before an eligible client starts"}, &marker)
+ disabled.call(t, "wait", map[string]any{"id": marker.ID}, &marker)
+ require.Equal(t, []string{disabledID}, marker.AttemptedBy)
+ require.Empty(t, readLeader(t, eligible).LeaderID, "a client with leader election disabled became leader")
+
+ // The eligible client works a separate queue, so only the disabled
+ // client works the periodic job it enqueues into the default queue.
+ eligible.startWithTuning(t, map[string]any{
+ "client_id": eligibleID, "instrumented": true, "max_workers": 1,
+ "periodic_run_on_start": true, "queue": "election_eligible",
+ }, fastElection)
+ require.Equal(t, eligibleID, waitForLeader(t, disabled, ""))
+ waitForRuntimeStats(t, eligible, func(stats runtimeStats) bool { return stats.PeriodicStarts == 1 })
+ deadline := time.Now().Add(5 * time.Second)
+ for len(periodicJobs()) == 0 && time.Now().Before(deadline) {
+ time.Sleep(10 * time.Millisecond)
+ }
+ enqueued := periodicJobs()
+ require.Len(t, enqueued, 1, "the eligible leader did not enqueue its periodic job")
+ periodic := enqueued[0]
+ disabled.call(t, "wait", map[string]any{"id": periodic.ID}, &periodic)
+ require.Equal(t, "completed", periodic.State)
+ require.Equal(t, []string{disabledID}, periodic.AttemptedBy)
+ stats := waitForRuntimeStats(t, disabled, func(runtimeStats) bool { return true })
+ require.Zero(t, stats.PeriodicStarts, "a client with leader election disabled ran the periodic enqueuer")
+
+ eligible.call(t, "stop", map[string]any{}, nil)
+ for _, step := range []string{"after the eligible leader stops", "after a restart"} {
+ if step == "after a restart" {
+ disabled.call(t, "stop", map[string]any{}, nil)
+ disabled.startWithTuning(t, disabledParams, fastElection)
+ }
+ eligible.call(t, "insert", map[string]any{"message": step}, &marker)
+ disabled.call(t, "wait", map[string]any{"id": marker.ID}, &marker)
+ require.Equal(t, "completed", marker.State)
+ require.Equal(t, []string{disabledID}, marker.AttemptedBy)
+ require.Empty(t, readLeader(t, eligible).LeaderID, "a client with leader election disabled became leader %s", step)
+ }
+ stats = waitForRuntimeStats(t, disabled, func(runtimeStats) bool { return true })
+ require.Zero(t, stats.PeriodicStarts, "a client with leader election disabled ran the periodic enqueuer")
+ require.Len(t, periodicJobs(), 1)
+ disabled.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyListenerReconnect terminates each worker's listener backend and then
+// all of its database connections, and requires a notification round trip
+// from the other implementation after each fault.
+func verifyListenerReconnect(t *testing.T, pair mixedPair) {
+ t.Helper()
+
+ pair.eachDirection(func(worker, controller *adapter) {
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-reconnect", "fetch_poll_interval_ms": 60_000, "max_workers": 1,
+ }, nil)
+ waitForListener(t, worker)
+ requireNotificationRoundTrip(t, controller, worker, "before_fault")
+
+ var disconnected struct {
+ Count int `json:"count"`
+ }
+ worker.call(t, "fault_disconnect_listeners", map[string]any{}, &disconnected)
+ require.GreaterOrEqual(t, disconnected.Count, 1)
+ waitForListener(t, worker)
+ requireNotificationRoundTrip(t, controller, worker, "after_listener_fault")
+
+ controller.call(t, "fault_disconnect_application", map[string]any{
+ "application_name": worker.applicationName,
+ }, &disconnected)
+ require.GreaterOrEqual(t, disconnected.Count, 1)
+ waitForListener(t, worker)
+ requireNotificationRoundTrip(t, controller, worker, "after_application_fault")
+ worker.call(t, "stop", map[string]any{}, nil)
+ })
+}
+
+// requireNotificationRoundTrip requires an insert by the controller to wake a
+// worker that polls once a minute. A listener that has just reconnected may
+// miss a notification sent before it resubscribed, so inserts repeat until
+// one wakes the worker or the bound elapses.
+func requireNotificationRoundTrip(t *testing.T, controller, worker *adapter, label string) {
+ t.Helper()
+
+ tag := "round_trip_" + label
+ deadline := time.Now().Add(10 * time.Second)
+ for attempt := 0; time.Now().Before(deadline); attempt++ {
+ var inserted normalizedJob
+ controller.call(t, "insert", map[string]any{
+ "message": fmt.Sprintf("%s %d", label, attempt), "opts": map[string]any{"tags": []string{tag}},
+ }, &inserted)
+ attemptDeadline := time.Now().Add(500 * time.Millisecond)
+ for time.Now().Before(attemptDeadline) {
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ worker.call(t, "list", map[string]any{"states": []string{"completed"}, "tags_all": []string{tag}}, &listed)
+ if len(listed.Jobs) > 0 {
+ return
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ }
+ t.Fatalf("%s: %s inserts never woke %s's listener", label, controller.name, worker.name)
+}
+
+// verifyLostNotificationPollRecovery inserts without a notification and
+// requires the worker's poll loop to find the job.
+func verifyLostNotificationPollRecovery(t *testing.T, inserter, worker *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-poll-recovery", "fetch_poll_interval_ms": 250, "max_workers": 1,
+ }, nil)
+ var notificationLost normalizedJob
+ inserter.call(t, "raw_insert_no_notify", map[string]any{"message": "poll recovery"}, ¬ificationLost)
+ worker.call(t, "wait", map[string]any{"id": notificationLost.ID}, ¬ificationLost)
+ require.Equal(t, "completed", notificationLost.State)
+ require.Equal(t, []string{worker.name + "-poll-recovery"}, notificationLost.AttemptedBy)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifySkipLockedCompetition has both implementations compete for a burst
+// of short jobs and requires every job to run exactly once.
+func verifySkipLockedCompetition(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ const jobsPerInserter = 150
+ goID, candidateID := goAdapter.name+"-competitor", candidateAdapter.name+"-competitor"
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ goAdapter.call(t, "start", map[string]any{"client_id": goID, "max_workers": 8}, nil)
+ candidateAdapter.call(t, "start", map[string]any{"client_id": candidateID, "max_workers": 8}, nil)
+ for _, inserter := range []*adapter{goAdapter, candidateAdapter} {
+ jobs := make([]map[string]any, jobsPerInserter)
+ for index := range jobs {
+ jobs[index] = map[string]any{
+ "behavior": "sleep", "duration_ms": 5,
+ "message": fmt.Sprintf("competition %s %d", inserter.name, index),
+ "opts": map[string]any{"tags": []string{"competition"}},
+ }
+ }
+ var inserted struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ inserter.call(t, "insert_many", map[string]any{"jobs": jobs}, &inserted)
+ require.Len(t, inserted.Results, jobsPerInserter)
+ }
+ worked := waitForListedJobCountWithin(t, goAdapter, map[string]any{
+ "limit": 2 * jobsPerInserter, "states": []string{"completed"}, "tags_all": []string{"competition"},
+ }, 2*jobsPerInserter, 30*time.Second)
+ perWorker := make(map[string]int)
+ for _, job := range worked {
+ require.Equal(t, 1, job.Attempt, "job %d ran more than once", job.ID)
+ require.Len(t, job.AttemptedBy, 1)
+ require.Empty(t, job.Errors)
+ perWorker[job.AttemptedBy[0]]++
+ }
+ require.Positive(t, perWorker[goID], "Go worker claimed no jobs")
+ require.Positive(t, perWorker[candidateID], "candidate worker claimed no jobs")
+ require.Len(t, perWorker, 2)
+ t.Logf("competition split: %v", perWorker)
+ goAdapter.call(t, "stop", map[string]any{}, nil)
+ candidateAdapter.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyIgnoredCancellationHardAbort hard-stops a disposable candidate
+// process whose worker ignores cancellation. The job gets the stuck threshold
+// to respond, and is then aborted, which fails its attempt: the attempt
+// counts, its error is recorded, and the job follows the retry path. Go
+// cannot abort a goroutine that ignores its context, so this scenario
+// exercises the candidate's runtime only.
+func verifyIgnoredCancellationHardAbort(t *testing.T, repositoryRoot, databaseURL string, pair mixedPair) {
+ t.Helper()
+
+ pair.reference.call(t, "reset", map[string]any{}, nil)
+ stuck := startCandidateAdapter(t, repositoryRoot, databaseURL, "candidate-stuck", pair.candidateSpec, pair.candidateSpec.RestartCommand)
+ stuckClientID := pair.candidateSpec.Implementation + "-stuck-worker"
+ stuck.call(t, "start", map[string]any{
+ "client_id": stuckClientID, "job_stuck_threshold_ms": 100, "max_workers": 1, "queue": "ignored",
+ }, nil)
+ var stuckJob normalizedJob
+ pair.reference.call(t, "insert", map[string]any{
+ "behavior": "ignored_cancel",
+ "message": "ignored cancellation",
+ "opts": map[string]any{"queue": "ignored"},
+ }, &stuckJob)
+ pair.reference.call(t, "wait", map[string]any{
+ "id": stuckJob.ID, "states": []string{"running"},
+ }, &stuckJob)
+ stuck.call(t, "stop", map[string]any{"cancel": true}, nil)
+ pair.reference.call(t, "get", map[string]any{"id": stuckJob.ID}, &stuckJob)
+ require.Contains(t, []string{"available", "retryable"}, stuckJob.State)
+ require.Equal(t, 1, stuckJob.Attempt)
+ require.Len(t, stuckJob.Errors, 1)
+ require.Equal(t, 1, stuckJob.Errors[0].Attempt)
+ require.NotEmpty(t, stuckJob.Errors[0].Error)
+}
+
+// verifyProcessKillRestartAndRescue kills a candidate process mid-attempt and
+// requires a restarted candidate process to rescue and complete the job.
+func verifyProcessKillRestartAndRescue(t *testing.T, repositoryRoot, databaseURL string, pair mixedPair) {
+ t.Helper()
+
+ pair.reference.call(t, "reset", map[string]any{}, nil)
+ crashing := startCandidateAdapter(t, repositoryRoot, databaseURL, "candidate-crashing", pair.candidateSpec, pair.candidateSpec.RestartCommand)
+ crashingClientID := pair.candidateSpec.Implementation + "-crashing-worker"
+ crashing.call(t, "start", map[string]any{
+ "client_id": crashingClientID, "max_workers": 1,
+ }, nil)
+ var crashJob normalizedJob
+ pair.reference.call(t, "insert", map[string]any{
+ "behavior": "sleep", "duration_ms": 1_000, "message": "process death rescue",
+ }, &crashJob)
+ pair.reference.call(t, "wait", map[string]any{
+ "id": crashJob.ID, "states": []string{"running"},
+ }, &crashJob)
+ crashing.kill(t)
+ pair.reference.call(t, "fault_expire_leader", map[string]any{}, nil)
+
+ recovery := startCandidateAdapter(t, repositoryRoot, databaseURL, "candidate-recovery", pair.candidateSpec, pair.candidateSpec.RestartCommand)
+ recoveryClientID := pair.candidateSpec.Implementation + "-recovery-worker"
+ recovery.startWithTuning(t, map[string]any{
+ "client_id": recoveryClientID,
+ "job_timeout_ms": 1_500,
+ "max_workers": 1,
+ "rescue_after_ms": 1_500,
+ }, map[string]any{"elect_interval_ms": 20, "rescuer_interval_ms": 20, "scheduler_interval_ms": 20})
+ recovery.call(t, "wait", map[string]any{"id": crashJob.ID}, &crashJob)
+ require.Equal(t, "completed", crashJob.State)
+ require.Equal(t, 2, crashJob.Attempt)
+ require.Equal(t, []string{crashingClientID, recoveryClientID}, crashJob.AttemptedBy)
+ require.EqualValues(t, 1, crashJob.Metadata["river:rescue_count"])
+ recovery.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyClaimOrder checks the order in which a client claims available
+// jobs, which every implementation writes in its own SQL: Go claims by
+// priority, then scheduled_at, then ID. One implementation inserts jobs
+// whose ID order, scheduled_at order, and priority order all differ,
+// including two with equal priority and scheduled_at, and the other works
+// them one at a time once its scheduler makes them all available together.
+// Each job sleeps briefly, so the claims' attempted_at times are distinct and
+// record the order.
+func verifyClaimOrder(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ inserter *adapter
+ worker *adapter
+ }{
+ {inserter: goAdapter, worker: candidateAdapter},
+ {inserter: candidateAdapter, worker: goAdapter},
+ } {
+ pair.inserter.call(t, "reset", map[string]any{}, nil)
+ base := time.Now().UTC().Truncate(time.Millisecond)
+ ids := make(map[string]int64)
+ // In insertion (ID) order.
+ for _, job := range []struct {
+ name string
+ priority int
+ ago time.Duration
+ }{
+ {name: "priority 1, latest", priority: 1, ago: 30 * time.Second},
+ {name: "priority 4", priority: 4, ago: time.Minute},
+ {name: "priority 1, later", priority: 1, ago: time.Minute},
+ {name: "priority 3, earliest", priority: 3, ago: 3 * time.Minute},
+ {name: "priority 1, earliest, lower ID", priority: 1, ago: 2 * time.Minute},
+ {name: "priority 1, earliest, higher ID", priority: 1, ago: 2 * time.Minute},
+ } {
+ var inserted normalizedJob
+ pair.inserter.call(t, "insert", map[string]any{
+ "behavior": "sleep", "duration_ms": 5, "message": "claim order " + job.name,
+ "opts": map[string]any{
+ "priority": job.priority, "scheduled_at": base.Add(-job.ago).Format(time.RFC3339Nano),
+ },
+ }, &inserted)
+ // Like Go, an explicit schedule inserts the job scheduled even
+ // when it's due, and the leader's scheduler makes it available.
+ require.Equal(t, "scheduled", inserted.State, job.name)
+ ids[job.name] = inserted.ID
+ }
+ expected := []string{
+ "priority 1, earliest, lower ID",
+ "priority 1, earliest, higher ID",
+ "priority 1, later",
+ "priority 1, latest",
+ "priority 3, earliest",
+ "priority 4",
+ }
+
+ clientID := pair.worker.name + "-claim-order"
+ pair.worker.startWithTuning(t, map[string]any{"client_id": clientID, "max_workers": 1},
+ map[string]any{"elect_interval_ms": 20, "scheduler_interval_ms": 20})
+ type claim struct {
+ at time.Time
+ name string
+ }
+ claims := make([]claim, 0, len(ids))
+ for name, id := range ids {
+ var worked normalizedJob
+ pair.worker.call(t, "wait", map[string]any{"id": id}, &worked)
+ require.Equal(t, "completed", worked.State, name)
+ require.Equal(t, []string{clientID}, worked.AttemptedBy, name)
+ require.NotNil(t, worked.AttemptedAt, name)
+ claims = append(claims, claim{at: parseTime(t, *worked.AttemptedAt), name: name})
+ }
+ pair.worker.call(t, "stop", map[string]any{}, nil)
+
+ slices.SortFunc(claims, func(a, b claim) int { return a.at.Compare(b.at) })
+ actual := make([]string, len(claims))
+ for index, claim := range claims {
+ if index > 0 {
+ require.True(t, claim.at.After(claims[index-1].at), "%s claimed two jobs at the same time", pair.worker.name)
+ }
+ actual[index] = claim.name
+ }
+ require.Equal(t, expected, actual, "%s claimed jobs out of order", pair.worker.name)
+ }
+}
diff --git a/conformance/harness/descriptor_test.go b/conformance/harness/descriptor_test.go
new file mode 100644
index 000000000..21eb0dcae
--- /dev/null
+++ b/conformance/harness/descriptor_test.go
@@ -0,0 +1,95 @@
+package harness_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// adapterSpec is a candidate descriptor (see candidate.schema.json) after
+// environment expansion.
+type adapterSpec struct {
+ ApplicationName string `json:"application_name"`
+ BuildCommand []string `json:"build_command"`
+ Command []string `json:"command"`
+ Implementation string `json:"implementation"`
+ Performance map[string]performanceBound `json:"performance"`
+ Profiles []string `json:"profiles"`
+ ReleaseBuildCommand []string `json:"release_build_command"`
+ ReleaseCommand []string `json:"release_command"`
+ RestartCommand []string `json:"restart_command"`
+ StartOptions []string `json:"start_options"`
+ Version string `json:"version"`
+}
+
+// performanceBound is a candidate's declared release performance bound
+// relative to the reference implementation for one benchmark mode.
+type performanceBound struct {
+ MaxP95Ratio float64 `json:"max_p95_ratio"`
+ MinThroughputRatio float64 `json:"min_throughput_ratio"`
+}
+
+// defaultPerformanceBounds apply to modes a descriptor does not declare.
+var defaultPerformanceBounds = map[string]performanceBound{ //nolint:gochecknoglobals // descriptor default
+ // Enqueue uses equivalent ordinary insertion mechanisms but remains
+ // driver/runtime-language sensitive. It is a regression guard, not an
+ // incentive to add a candidate-only fast producer path.
+ "enqueue": {MaxP95Ratio: 2, MinThroughputRatio: 0.4},
+ "mixed": {MaxP95Ratio: 1.25, MinThroughputRatio: 0.8},
+ "worker": {MaxP95Ratio: 1.25, MinThroughputRatio: 0.8},
+}
+
+func decodeDescriptor(t *testing.T, document []byte) adapterSpec {
+ t.Helper()
+
+ decoder := json.NewDecoder(bytes.NewReader(document))
+ var raw map[string]json.RawMessage
+ require.NoError(t, decoder.Decode(&raw))
+ delete(raw, "$schema")
+ stripped, err := json.Marshal(raw)
+ require.NoError(t, err)
+ decoder = json.NewDecoder(bytes.NewReader(stripped))
+ decoder.DisallowUnknownFields()
+ var spec adapterSpec
+ require.NoError(t, decoder.Decode(&spec), "candidate descriptor has an unknown or invalid field")
+ require.NotEmpty(t, spec.ApplicationName)
+ require.True(t, strings.HasPrefix(spec.ApplicationName, "river-conformance-"),
+ "candidate application_name %q must start with river-conformance- so fault injection can target it", spec.ApplicationName)
+ require.NotEmpty(t, spec.Command)
+ require.NotEmpty(t, spec.Implementation)
+ for _, command := range []*[]string{
+ &spec.BuildCommand, &spec.Command, &spec.ReleaseBuildCommand, &spec.ReleaseCommand, &spec.RestartCommand,
+ } {
+ *command = expandDescriptorCommand(*command)
+ }
+ for mode, bound := range spec.Performance {
+ require.Contains(t, defaultPerformanceBounds, mode, "unknown performance mode %q", mode)
+ require.Positive(t, bound.MaxP95Ratio, "performance.%s.max_p95_ratio", mode)
+ require.Positive(t, bound.MinThroughputRatio, "performance.%s.min_throughput_ratio", mode)
+ }
+ return spec
+}
+
+// expandDescriptorCommand expands `${NAME}` and `${NAME:-default}` in each
+// argument, so a descriptor can reference a build output directory such as
+// CARGO_TARGET_DIR without hardcoding it.
+func expandDescriptorCommand(command []string) []string {
+ if command == nil {
+ return nil
+ }
+ expanded := make([]string, len(command))
+ for index, argument := range command {
+ expanded[index] = os.Expand(argument, func(reference string) string {
+ name, fallback, hasFallback := strings.Cut(reference, ":-")
+ if value := os.Getenv(name); value != "" || !hasFallback {
+ return value
+ }
+ return fallback
+ })
+ }
+ return expanded
+}
diff --git a/conformance/harness/insert_only_test.go b/conformance/harness/insert_only_test.go
new file mode 100644
index 000000000..8b21d0f29
--- /dev/null
+++ b/conformance/harness/insert_only_test.go
@@ -0,0 +1,241 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestInsertOnlyConformance checks the insert-only-v1 profile: a client that
+// only enqueues jobs, such as a producer library for a language without a
+// River worker runtime. The candidate inserts; the Go reference observes and
+// works every job.
+//
+//nolint:paralleltest // Scenarios share one database and adapter processes, so they run sequentially.
+func TestInsertOnlyConformance(t *testing.T) {
+ databaseURL := requireEnv(t, "RIVER_CONFORMANCE_DATABASE_URL")
+ scenarios := newScenarioTracker(t, scenarioOwnerInsertOnly)
+ repositoryRoot := repoRoot(t)
+ candidateSpec := conformanceCandidateSpec(t, repositoryRoot, false)
+ candidateSpec.requireProfile(t, profileInsertOnly)
+ observer := newPostgresObserver(t, databaseURL)
+ reference := startReferenceAdapter(t, repositoryRoot, databaseURL, "go")
+ candidate := startAdapterCommandForProfile(t, repositoryRoot, databaseURL, "postgres", profileInsertOnly,
+ candidateSpec.Implementation, candidateSpec, candidateSpec.Command)
+ scenarios.attach(reference, candidate)
+
+ t.Run("insert_only_profile_handshake", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyInsertOnlyHandshake(t, repositoryRoot, candidateSpec, candidate)
+ })
+ reference.call(t, "migrate", map[string]any{}, nil)
+ t.Run("insert_only_insert_reference_work", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyInsertOnlyInsert(t, candidate, reference)
+ })
+ t.Run("insert_only_typed_batch", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyInsertOnlyBatch(t, candidate, reference)
+ })
+ t.Run("insert_only_transactional_insert", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyInsertOnlyTransactions(t, observer, candidate, reference)
+ })
+ t.Run("insert_only_unique_insert", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUniqueKeyGoldens(t, repositoryRoot, candidate)
+ verifyInsertOnlyUnique(t, candidate, reference)
+ })
+ t.Run("insert_only_insert_notification", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyInsertNotificationWakeup(t, candidate, reference)
+ })
+}
+
+// verifyInsertOnlyHandshake requires the candidate to advertise exactly the
+// insert-only profile and reject every other contract method.
+func verifyInsertOnlyHandshake(t *testing.T, repositoryRoot string, candidateSpec adapterSpec, candidate *adapter) {
+ t.Helper()
+
+ var profile adapterProfile
+ contents, err := os.ReadFile(filepath.Join(repositoryRoot, "conformance/adapter/profiles/insert-only.json"))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(contents, &profile))
+ manifest := readManifest(t, repositoryRoot)
+ var handshake adapterHandshake
+ candidate.call(t, "handshake", map[string]any{}, &handshake)
+ require.Equal(t, candidateSpec.Implementation, handshake.Implementation)
+ require.Equal(t, manifest.Implementations[candidateSpec.Implementation].Version, handshake.ImplementationVersion)
+ require.Equal(t, profile.Backend, handshake.Backend)
+ require.Equal(t, profile.Name, handshake.Profile)
+ require.Equal(t, profile.ProtocolRevision, handshake.ProtocolRevision)
+ require.Equal(t, profile.Capabilities, handshake.Capabilities)
+ require.Equal(t, profile.Methods, handshake.Methods)
+ require.Equal(t, map[string]int{manifest.Migration.Line: manifest.Migration.Latest}, handshake.MigrationLines)
+ contract, err := sharedAdapterContract()
+ require.NoError(t, err)
+ for method := range contract.methods {
+ if !slices.Contains(profile.Methods, method) {
+ candidate.requireUnvalidatedCallError(t, method, map[string]any{}, "method_not_found")
+ }
+ }
+ candidate.requireUnvalidatedCallError(t, "insert", map[string]any{"message": "unknown", "unexpected": true}, "invalid_params")
+}
+
+// verifyInsertOnlyInsert compares a candidate insert with the same insert
+// made by the reference, field by field, and has the reference work it.
+func verifyInsertOnlyInsert(t *testing.T, candidate, reference *adapter) {
+ t.Helper()
+
+ reference.call(t, "reset", map[string]any{}, nil)
+ scheduledAt := time.Now().Add(time.Hour).UTC().Truncate(time.Millisecond).Format(time.RFC3339Nano)
+ for _, params := range []map[string]any{
+ {"message": "defaults"},
+ {"message": "options", "opts": map[string]any{
+ "max_attempts": 3, "metadata": map[string]any{"source": "insert-only"}, "priority": 2,
+ "queue": "insert_only", "tags": []string{"insert_only"},
+ }},
+ {"message": "scheduled", "opts": map[string]any{"scheduled_at": scheduledAt}},
+ {"message": "pending", "opts": map[string]any{"pending": true}},
+ } {
+ var fromCandidate, fromReference, observed normalizedJob
+ candidate.call(t, "insert", params, &fromCandidate)
+ reference.call(t, "insert", params, &fromReference)
+ reference.call(t, "get", map[string]any{"id": fromCandidate.ID}, &observed)
+ require.Equal(t, fromCandidate, observed)
+ require.Equal(t, comparableJob(fromReference), comparableJob(fromCandidate), "%s insert differs from the reference", params["message"])
+ }
+
+ var inserted, worked normalizedJob
+ candidate.call(t, "insert", map[string]any{"message": "worked by the reference"}, &inserted)
+ reference.call(t, "work", map[string]any{"client_id": "go-insert-only-worker", "id": inserted.ID}, &worked)
+ require.Equal(t, "completed", worked.State)
+ require.Equal(t, []string{"go-insert-only-worker"}, worked.AttemptedBy)
+}
+
+// comparableJob clears the fields that legitimately differ between two
+// separately inserted jobs.
+func comparableJob(job normalizedJob) normalizedJob {
+ job.CreatedAt = ""
+ job.ID = 0
+ if job.State == "available" || job.State == "pending" {
+ job.ScheduledAt = ""
+ }
+ return job
+}
+
+// verifyInsertOnlyBatch checks typed batch results in input order, including
+// a duplicate of a unique job the reference inserted.
+func verifyInsertOnlyBatch(t *testing.T, candidate, reference *adapter) {
+ t.Helper()
+
+ reference.call(t, "reset", map[string]any{}, nil)
+ uniqueParams := map[string]any{
+ "message": "insert-only duplicate", "opts": map[string]any{"unique": map[string]any{"by_args": true}},
+ }
+ var existing normalizedJob
+ reference.call(t, "insert", uniqueParams, &existing)
+ var inserted struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ candidate.call(t, "insert_many", map[string]any{"jobs": []map[string]any{
+ {"message": "batch first", "opts": map[string]any{"metadata": map[string]any{"batch_index": 0}}},
+ uniqueParams,
+ {"message": "batch pending", "opts": map[string]any{"pending": true}},
+ }}, &inserted)
+ require.Len(t, inserted.Results, 3)
+ require.False(t, inserted.Results[0].UniqueSkippedAsDuplicate)
+ require.EqualValues(t, 0, inserted.Results[0].Job.Metadata["batch_index"])
+ require.True(t, inserted.Results[1].UniqueSkippedAsDuplicate)
+ require.Equal(t, existing, inserted.Results[1].Job)
+ require.Equal(t, "pending", inserted.Results[2].Job.State)
+ for _, result := range inserted.Results {
+ var observed normalizedJob
+ reference.call(t, "get", map[string]any{"id": result.Job.ID}, &observed)
+ require.Equal(t, result.Job, observed)
+ }
+ candidate.requireCallError(t, "insert_many", map[string]any{"jobs": []map[string]any{}}, "rejected")
+}
+
+// verifyInsertOnlyTransactions checks that transactional inserts become
+// visible and publish insert notifications only on commit.
+func verifyInsertOnlyTransactions(t *testing.T, observer *postgresObserver, candidate, reference *adapter) {
+ t.Helper()
+
+ insertChannel := observer.currentSchema(t) + ".river_insert"
+ for _, commit := range []bool{false, true} {
+ reference.call(t, "reset", map[string]any{}, nil)
+ listener := observer.listen(t, insertChannel)
+ outcome := "rollback"
+ if commit {
+ outcome = "commit"
+ }
+ handle := "insert-only-" + outcome
+ tag := strings.ReplaceAll(handle, "-", "_")
+ candidate.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var single normalizedJob
+ candidate.call(t, "tx_insert", map[string]any{
+ "handle": handle, "job": map[string]any{"message": handle, "opts": map[string]any{"tags": []string{tag}}},
+ }, &single)
+ var batch struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ candidate.call(t, "tx_insert_many", map[string]any{
+ "handle": handle, "jobs": []map[string]any{{"message": handle + " batch", "opts": map[string]any{"tags": []string{tag}}}},
+ }, &batch)
+ require.Len(t, batch.Results, 1)
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ reference.call(t, "list", map[string]any{"tags_all": []string{tag}}, &listed)
+ require.Empty(t, listed.Jobs, "transactional inserts became visible before commit")
+ if commit {
+ candidate.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ require.NotEmpty(t, listener.receiveUntilMarker(t, observer, handle+"-marker"), "commit published no insert notification")
+ reference.call(t, "list", map[string]any{"tags_all": []string{tag}}, &listed)
+ require.ElementsMatch(t, []int64{single.ID, batch.Results[0].Job.ID}, jobIDs(listed.Jobs))
+ } else {
+ candidate.call(t, "tx_rollback", map[string]any{"handle": handle}, nil)
+ require.Empty(t, listener.receiveUntilMarker(t, observer, handle+"-marker"), "rollback published an insert notification")
+ reference.call(t, "list", map[string]any{"tags_all": []string{tag}}, &listed)
+ require.Empty(t, listed.Jobs)
+ }
+ }
+}
+
+// verifyInsertOnlyUnique checks that unique inserts from the candidate and
+// the reference resolve to the same row in both orders and for each unique
+// dimension.
+func verifyInsertOnlyUnique(t *testing.T, candidate, reference *adapter) {
+ t.Helper()
+
+ for _, opts := range []map[string]any{
+ {"unique": map[string]any{"by_args": true}},
+ {"scheduled_at": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano), "unique": map[string]any{"by_period_ms": 60_000}},
+ {"queue": "unique_queue", "unique": map[string]any{"by_queue": true}},
+ } {
+ for _, order := range [][2]*adapter{{candidate, reference}, {reference, candidate}} {
+ reference.call(t, "reset", map[string]any{}, nil)
+ params := map[string]any{"message": "insert-only unique", "opts": opts}
+ var first, second normalizedJob
+ order[0].call(t, "insert", params, &first)
+ order[1].call(t, "insert", params, &second)
+ require.Equal(t, first, second, "%s then %s with %v", order[0].name, order[1].name, opts)
+ require.NotNil(t, first.UniqueKey)
+ }
+ }
+}
diff --git a/conformance/harness/interop_scenarios_test.go b/conformance/harness/interop_scenarios_test.go
new file mode 100644
index 000000000..ef1bd2e85
--- /dev/null
+++ b/conformance/harness/interop_scenarios_test.go
@@ -0,0 +1,429 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// uniqueColumnCases are unique options whose stored key and state mask every
+// implementation must write identically. The period case schedules the job
+// at a fixed time, so its period, derived from the scheduled time, doesn't
+// depend on when the scenario runs.
+func uniqueColumnCases() []struct {
+ name string
+ opts map[string]any
+} {
+ allStates := []string{"available", "cancelled", "completed", "discarded", "pending", "retryable", "running", "scheduled"}
+ return []struct {
+ name string
+ opts map[string]any
+ }{
+ {name: "by_args", opts: map[string]any{"unique": map[string]any{"by_args": true}}},
+ {name: "by_args_exclude_kind", opts: map[string]any{"unique": map[string]any{"by_args": true, "exclude_kind": true}}},
+ {name: "by_period", opts: map[string]any{
+ "scheduled_at": "2031-02-03T04:05:06.789Z",
+ "unique": map[string]any{"by_period_ms": 3_600_000},
+ }},
+ {name: "by_queue", opts: map[string]any{"queue": "unique_queue", "unique": map[string]any{"by_queue": true}}},
+ {name: "by_state", opts: map[string]any{"unique": map[string]any{"by_state": []string{"available", "pending", "running", "scheduled"}}}},
+ {name: "combined", opts: map[string]any{
+ "queue": "unique_queue",
+ "scheduled_at": "2031-02-03T04:05:06.789Z",
+ "unique": map[string]any{
+ "by_args": true, "by_period_ms": 86_400_000, "by_queue": true, "by_state": allStates,
+ },
+ }},
+ }
+}
+
+// uniqueColumns is the part of raw_job_row that stores a job's uniqueness.
+type uniqueColumns struct {
+ Key *string
+ KeyType *string
+ States *string
+ StatesType *string
+}
+
+func readUniqueColumns(t *testing.T, reader *adapter, id int64) uniqueColumns {
+ t.Helper()
+
+ var row rawJobRow
+ reader.call(t, "raw_job_row", map[string]any{"id": id}, &row)
+ return uniqueColumns{Key: row.UniqueKey, KeyType: row.UniqueKeyType, States: row.UniqueStates, StatesType: row.UniqueStatesType}
+}
+
+// verifyUniqueColumnBytes has each implementation insert the same unique jobs
+// and requires the stored `unique_key` and `unique_states` to be identical
+// byte for byte, including their SQLite storage types, as read by both
+// implementations. A job without unique options stores neither.
+func verifyUniqueColumnBytes(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ write := func(writer *adapter, params map[string]any) uniqueColumns {
+ t.Helper()
+
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ var inserted normalizedJob
+ writer.call(t, "insert", params, &inserted)
+ columns := readUniqueColumns(t, goAdapter, inserted.ID)
+ require.Equal(t, columns, readUniqueColumns(t, candidateAdapter, inserted.ID),
+ "%s and %s render the unique columns %s wrote differently", goAdapter.name, candidateAdapter.name, writer.name)
+ return columns
+ }
+
+ for _, testCase := range uniqueColumnCases() {
+ params := map[string]any{"message": "unique columns " + testCase.name, "opts": testCase.opts}
+ reference := write(goAdapter, params)
+ require.NotNil(t, reference.Key, "%s: Go stored no unique key", testCase.name)
+ require.NotNil(t, reference.States, "%s: Go stored no unique states", testCase.name)
+ require.Equal(t, reference, write(candidateAdapter, params),
+ "%s: %s and %s stored different unique columns", testCase.name, goAdapter.name, candidateAdapter.name)
+ }
+
+ params := map[string]any{"message": "not unique"}
+ require.Equal(t, uniqueColumns{}, write(goAdapter, params))
+ require.Equal(t, uniqueColumns{}, write(candidateAdapter, params))
+}
+
+// verifyClaimTimeCancellation cancels a job from canceller between the moment
+// claimer's claim of it commits and the moment claimer starts working it.
+// The claimer holds its claim on a barrier, so the job is already running
+// but has no executor when the cancellation arrives. The claimer must start
+// the job's worker already cancelled, which its runtime stats report, so a
+// cancellation that only arrived after the claim was released fails the
+// scenario instead of passing through the ordinary cancellation path.
+func verifyClaimTimeCancellation(t *testing.T, canceller, claimer *adapter, listens bool) {
+ t.Helper()
+
+ claimer.call(t, "reset", map[string]any{}, nil)
+ barrier := "claim-time-cancel-" + claimer.name
+ claimer.call(t, "barrier_create", map[string]any{"name": barrier}, nil)
+ clientID := claimer.name + "-claim-time-cancel"
+ claimer.call(t, "start", map[string]any{
+ "claim_barrier": barrier, "client_id": clientID, "max_workers": 1,
+ }, nil)
+ if listens {
+ // Remote cancellation arrives by notification, so the claimer must
+ // be listening before the claim it holds.
+ waitForListener(t, claimer)
+ }
+
+ var job normalizedJob
+ canceller.call(t, "insert", map[string]any{
+ "behavior": "cooperative_cancel", "message": "claim-time cancellation from " + canceller.name,
+ }, &job)
+ canceller.call(t, "wait", map[string]any{"id": job.ID, "states": []string{"running"}}, &job)
+ require.Equal(t, []string{clientID}, job.AttemptedBy)
+
+ var requested normalizedJob
+ canceller.call(t, "cancel", map[string]any{"id": job.ID}, &requested)
+ require.Equal(t, "running", requested.State, "cancelling a claimed job only requests cancellation")
+ // Give the claimer time to receive the notification while it still holds
+ // the claim. SQLite listeners poll every 50 ms, and PostgreSQL delivers
+ // notifications at commit.
+ time.Sleep(time.Second)
+ claimer.call(t, "barrier_release", map[string]any{"name": barrier}, nil)
+
+ canceller.call(t, "wait", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, "cancelled", job.State, "%s did not cancel a job %s cancelled during its claim", claimer.name, canceller.name)
+ require.Equal(t, 1, job.Attempt)
+ require.Len(t, job.Errors, 1)
+ require.Equal(t, "JobCancelError: job cancelled remotely", job.Errors[0].Error)
+ var stats runtimeStats
+ claimer.call(t, "runtime_stats", map[string]any{}, &stats)
+ require.Equal(t, 1, stats.CancelledAtStart,
+ "%s started a job %s cancelled during its claim without its cancellation", claimer.name, canceller.name)
+ claimer.call(t, "stop", map[string]any{}, nil)
+}
+
+// notificationCapture reads the notifications published since its previous
+// read.
+type notificationCapture interface {
+ next(t *testing.T) []rawNotification
+}
+
+// postgresNotificationCapture listens to River's PostgreSQL channels on
+// harness connections. Payloads are grouped by channel, each in commit order.
+type postgresNotificationCapture struct {
+ listeners []*postgresNotificationListener
+ marker int
+ observer *postgresObserver
+ schema string
+}
+
+func newPostgresNotificationCapture(t *testing.T, observer *postgresObserver) *postgresNotificationCapture {
+ t.Helper()
+
+ capture := &postgresNotificationCapture{observer: observer, schema: observer.currentSchema(t)}
+ for _, topic := range []string{"river_control", "river_insert", "river_leadership"} {
+ capture.listeners = append(capture.listeners, observer.listen(t, capture.schema+"."+topic))
+ }
+ return capture
+}
+
+func (capture *postgresNotificationCapture) next(t *testing.T) []rawNotification {
+ t.Helper()
+
+ var notifications []rawNotification
+ for _, listener := range capture.listeners {
+ capture.marker++
+ marker := fmt.Sprintf("notification-capture-marker-%d", capture.marker)
+ for _, payload := range listener.receiveUntilMarker(t, capture.observer, marker) {
+ notifications = append(notifications, rawNotification{
+ Payload: payload, Topic: strings.TrimPrefix(listener.channel, capture.schema+"."),
+ })
+ }
+ }
+ return notifications
+}
+
+// sqliteNotificationCapture reads SQLite outbox rows through an observing
+// adapter, in ID order, with IDs cleared.
+type sqliteNotificationCapture struct {
+ afterID int64
+ observer *adapter
+}
+
+func newSQLiteNotificationCapture(t *testing.T, observer *adapter) *sqliteNotificationCapture {
+ t.Helper()
+
+ capture := &sqliteNotificationCapture{observer: observer}
+ _ = capture.next(t)
+ return capture
+}
+
+func (capture *sqliteNotificationCapture) next(t *testing.T) []rawNotification {
+ t.Helper()
+
+ notifications := rawNotificationsAfter(t, capture.observer, capture.afterID)
+ for index := range notifications {
+ capture.afterID = notifications[index].ID
+ notifications[index].ID = 0
+ }
+ return notifications
+}
+
+// notificationOperation is the notifications one operation published.
+type notificationOperation struct {
+ name string
+ notifications []semanticNotification
+}
+
+// notificationQueueMetadata is the metadata a queue update sets, which its
+// `metadata_changed` notification carries.
+const notificationQueueMetadata = `{"zeta":"z","alpha":1}`
+
+// semanticNotification is a notification compared as JSON rather than as
+// text: its topic, its SQLite storage type, and its decoded payload with any
+// job ID cleared once checked.
+type semanticNotification struct {
+ Payload any
+ PayloadType string
+ Topic string
+}
+
+// semanticNotifications decodes each notification's payload, requiring it
+// to be JSON. A payload's job ID, which differs between writers, must be
+// the JSON integer jobID and is then cleared.
+func semanticNotifications(t *testing.T, notifications []rawNotification, jobID int64) []semanticNotification {
+ t.Helper()
+
+ semantic := make([]semanticNotification, len(notifications))
+ for index, notification := range notifications {
+ var payload any
+ require.NoError(t, json.Unmarshal([]byte(notification.Payload), &payload),
+ "%s notification payload isn't JSON: %s", notification.Topic, notification.Payload)
+ if fields, ok := payload.(map[string]any); ok {
+ if _, ok := fields["job_id"]; ok {
+ var raw struct {
+ JobID json.RawMessage `json:"job_id"`
+ }
+ require.NoError(t, json.Unmarshal([]byte(notification.Payload), &raw))
+ id, err := strconv.ParseInt(string(raw.JobID), 10, 64)
+ require.NoError(t, err, "%s notification's job_id isn't a JSON integer: %s", notification.Topic, notification.Payload)
+ require.Equal(t, jobID, id, "%s notification names another job: %s", notification.Topic, notification.Payload)
+ fields["job_id"] = 0
+ }
+ }
+ semantic[index] = semanticNotification{
+ Payload: payload, PayloadType: notification.PayloadType, Topic: notification.Topic,
+ }
+ }
+ return semantic
+}
+
+// publishNotificationOperations has actor perform every operation that
+// publishes a notification and returns what each published, decoded, with
+// job IDs checked and cleared. The client it starts uses a fixed ID, so
+// leadership payloads name the same leader whichever implementation runs it.
+func publishNotificationOperations(t *testing.T, actor *adapter, capture notificationCapture) []notificationOperation {
+ t.Helper()
+
+ var (
+ job normalizedJob
+ operations []notificationOperation
+ )
+ record := func(name string) {
+ t.Helper()
+
+ operations = append(operations, notificationOperation{name: name, notifications: semanticNotifications(t, capture.next(t), job.ID)})
+ }
+
+ actor.call(t, "reset", map[string]any{}, nil)
+ _ = capture.next(t)
+ actor.call(t, "insert", map[string]any{
+ "message": "notification payloads", "opts": map[string]any{"queue": "notification_payloads"},
+ }, &job)
+ record("insert")
+ actor.call(t, "cancel", map[string]any{"id": job.ID}, nil)
+ record("cancel")
+ // Outlast any insert notification throttling, so a retry that notifies
+ // isn't suppressed by the insertion above.
+ time.Sleep(250 * time.Millisecond)
+ actor.call(t, "retry", map[string]any{"id": job.ID}, nil)
+ record("retry")
+
+ const clientID = "notification-payloads"
+ actor.call(t, "start", map[string]any{"client_id": clientID, "max_workers": 1}, nil)
+ require.Equal(t, clientID, waitForLeader(t, actor, ""))
+ record("start")
+ actor.call(t, "queue_update", map[string]any{
+ "metadata": json.RawMessage(notificationQueueMetadata), "name": "default",
+ }, nil)
+ record("queue_update")
+ actor.call(t, "queue_pause", map[string]any{"name": "default"}, nil)
+ record("queue_pause")
+ actor.call(t, "queue_resume", map[string]any{"name": "default"}, nil)
+ record("queue_resume")
+ term := readLeader(t, actor)
+ actor.call(t, "request_resign", map[string]any{}, nil)
+ _ = waitForLeaderTerm(t, actor, term.ElectedAt)
+ record("request_resign")
+ actor.call(t, "stop", map[string]any{}, nil)
+ record("stop")
+ return operations
+}
+
+// verifyNotificationPayloads has each implementation perform the same
+// operations and requires the notifications they publish (insert, cancel,
+// retry, queue metadata changes, pause, resume, resignation requests, and
+// resignations) to match Go's: whether each is sent, how many and in which
+// order, the topic, on SQLite the payload's storage type, and the payload
+// as JSON, so key order, escaping, and whitespace don't matter.
+func verifyNotificationPayloads(t *testing.T, goAdapter, candidateAdapter *adapter, newCapture func(actor *adapter) notificationCapture) {
+ t.Helper()
+
+ reference := publishNotificationOperations(t, goAdapter, newCapture(goAdapter))
+ candidate := publishNotificationOperations(t, candidateAdapter, newCapture(candidateAdapter))
+ require.Len(t, candidate, len(reference))
+ byName := make(map[string][]semanticNotification, len(reference))
+ for _, operation := range reference {
+ byName[operation.name] = operation.notifications
+ }
+ require.Len(t, byName["insert"], 1, "Go published no insert notification")
+ for _, name := range []string{"cancel", "queue_update", "queue_pause", "queue_resume", "request_resign"} {
+ require.NotEmpty(t, byName[name], "Go published no notification for %s", name)
+ }
+
+ for index, expected := range reference {
+ actual := candidate[index]
+ require.Equal(t, expected.name, actual.name)
+ require.Equal(t, expected.notifications, actual.notifications,
+ "%s: %s and %s published different notifications", expected.name, goAdapter.name, candidateAdapter.name)
+ }
+}
+
+// verifyUniquePeriodicJob has one implementation's leader insert a unique
+// run-on-start periodic job and then requires a later leader of the other
+// implementation to skip its own run-on-start insertion as a duplicate, in
+// both directions. It only skips when both compute the same unique key and
+// states for the periodic job.
+func verifyUniquePeriodicJob(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ first, second *adapter
+ }{
+ {first: goAdapter, second: candidateAdapter},
+ {first: candidateAdapter, second: goAdapter},
+ } {
+ pair.first.call(t, "reset", map[string]any{}, nil)
+ start := func(leader *adapter) {
+ t.Helper()
+
+ clientID := leader.name + "-periodic-unique"
+ leader.call(t, "start", map[string]any{
+ "client_id": clientID, "instrumented": true, "max_workers": 1,
+ "periodic_run_on_start": true, "periodic_unique": true,
+ }, nil)
+ require.Equal(t, clientID, waitForLeader(t, leader, ""))
+ _ = waitForRuntimeStats(t, leader, func(stats runtimeStats) bool { return stats.PeriodicStarts == 1 })
+ }
+
+ start(pair.first)
+ periodic := waitForPeriodicJob(t, pair.first, "conformance-periodic")
+ pair.first.call(t, "wait", map[string]any{"id": periodic.ID}, &periodic)
+ require.Equal(t, "completed", periodic.State)
+ pair.first.call(t, "stop", map[string]any{}, nil)
+
+ start(pair.second)
+ // Each leader inserts a non-unique marker job after the unique
+ // job, so once the second leader's marker exists, its attempt to
+ // insert the unique job has been made.
+ var periodicJobs []normalizedJob
+ deadline := time.Now().Add(10 * time.Second)
+ for {
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.second.call(t, "list", map[string]any{}, &listed)
+ markers := 0
+ periodicJobs = periodicJobs[:0]
+ for _, job := range listed.Jobs {
+ switch job.Metadata["river:periodic_job_id"] {
+ case "conformance-periodic-marker":
+ markers++
+ case "conformance-periodic":
+ periodicJobs = append(periodicJobs, job)
+ }
+ }
+ if markers == 2 {
+ break
+ }
+ require.True(t, time.Now().Before(deadline), "%s inserted no periodic marker job", pair.second.name)
+ time.Sleep(10 * time.Millisecond)
+ }
+ require.Len(t, periodicJobs, 1, "%s inserted a unique periodic job %s already inserted", pair.second.name, pair.first.name)
+ require.Equal(t, periodic.ID, periodicJobs[0].ID)
+ pair.second.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+// waitForPeriodicJob waits for a job inserted by the periodic job with the
+// given ID and returns it.
+func waitForPeriodicJob(t *testing.T, observer *adapter, periodicJobID string) normalizedJob {
+ t.Helper()
+
+ deadline := time.Now().Add(10 * time.Second)
+ for {
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ observer.call(t, "list", map[string]any{}, &listed)
+ for _, job := range listed.Jobs {
+ if job.Metadata["river:periodic_job_id"] == periodicJobID {
+ return job
+ }
+ }
+ require.True(t, time.Now().Before(deadline), "no job from periodic job %s", periodicJobID)
+ time.Sleep(10 * time.Millisecond)
+ }
+}
diff --git a/conformance/harness/job_rows_test.go b/conformance/harness/job_rows_test.go
new file mode 100644
index 000000000..2b46514c1
--- /dev/null
+++ b/conformance/harness/job_rows_test.go
@@ -0,0 +1,767 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/big"
+ "reflect"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// rawJobRow is a job's JSON and timestamp columns as the database renders
+// them (the raw_job_row method).
+type rawJobRow struct {
+ Args string `json:"args"`
+ AttemptedAt *string `json:"attempted_at"`
+ AttemptedBy *string `json:"attempted_by"`
+ CreatedAt string `json:"created_at"`
+ Errors *string `json:"errors"`
+ FinalizedAt *string `json:"finalized_at"`
+ // JSONB holds SQLite's stored JSONB bytes as hex, and is nil on
+ // PostgreSQL.
+ JSONB *struct {
+ Args string `json:"args"`
+ AttemptedBy *string `json:"attempted_by"`
+ Errors *string `json:"errors"`
+ Metadata string `json:"metadata"`
+ Tags string `json:"tags"`
+ } `json:"jsonb"`
+ Metadata string `json:"metadata"`
+ ScheduledAt string `json:"scheduled_at"`
+ Tags string `json:"tags"`
+ // UniqueKey is the stored unique key as uppercase hex.
+ UniqueKey *string `json:"unique_key"`
+ // UniqueKeyType is SQLite's typeof(unique_key), and nil on PostgreSQL.
+ UniqueKeyType *string `json:"unique_key_type"`
+ // UniqueStates is the stored state mask rendered as text.
+ UniqueStates *string `json:"unique_states"`
+ // UniqueStatesType is SQLite's typeof(unique_states), and nil on
+ // PostgreSQL.
+ UniqueStatesType *string `json:"unique_states_type"`
+}
+
+// jsonbTypeNames names SQLite's JSONB element types by their header code.
+var jsonbTypeNames = [...]string{ //nolint:gochecknoglobals // fixed lookup table
+ "null", "true", "false", "int", "int5", "float", "float5",
+ "text", "textj", "text5", "textraw", "array", "object",
+}
+
+// jsonbNode is one decoded SQLite JSONB element.
+type jsonbNode struct {
+ children []jsonbNode
+ payload []byte
+ typ byte
+}
+
+// decodeJSONB decodes the first JSONB element of data and returns it with
+// the bytes that follow it.
+func decodeJSONB(data []byte) (jsonbNode, []byte, error) {
+ if len(data) == 0 {
+ return jsonbNode{}, nil, errors.New("empty JSONB element")
+ }
+ node := jsonbNode{typ: data[0] & 0x0f}
+ if int(node.typ) >= len(jsonbTypeNames) {
+ return jsonbNode{}, nil, fmt.Errorf("reserved JSONB type %d", node.typ)
+ }
+ size, header := uint64(data[0]>>4), 1
+ if size > 11 {
+ width := 1 << (size - 12)
+ if len(data) < 1+width {
+ return jsonbNode{}, nil, errors.New("truncated JSONB header")
+ }
+ header += width
+ // Wider sizes follow the header byte, big-endian.
+ size = 0
+ for _, b := range data[1:header] {
+ size = size<<8 | uint64(b)
+ }
+ }
+ if size > uint64(len(data)-header) { //nolint:gosec // len is never negative
+ return jsonbNode{}, nil, errors.New("truncated JSONB payload")
+ }
+ node.payload = data[header : header+int(size)]
+ rest := data[header+int(size):]
+ if node.typ == 11 || node.typ == 12 {
+ for remaining := node.payload; len(remaining) > 0; {
+ var child jsonbNode
+ var err error
+ child, remaining, err = decodeJSONB(remaining)
+ if err != nil {
+ return jsonbNode{}, nil, err
+ }
+ node.children = append(node.children, child)
+ }
+ node.payload = nil
+ }
+ return node, rest, nil
+}
+
+// jsonbValue decodes a JSONB element into the value comparableJSON returns
+// for the same JSON. Every string and number encoding SQLite may store
+// decodes, so two writers only need to store the same value.
+func jsonbValue(node jsonbNode) (any, error) {
+ payload := string(node.payload)
+ switch jsonbTypeNames[node.typ] {
+ case "null":
+ return nil, nil //nolint:nilnil // JSON null
+ case "true":
+ return true, nil
+ case "false":
+ return false, nil
+ case "int", "float":
+ return comparableJSON(json.Number(payload))
+ case "int5":
+ integer, ok := new(big.Int).SetString(strings.TrimPrefix(payload, "+"), 0)
+ if !ok {
+ return nil, fmt.Errorf("invalid JSON5 integer %q", payload)
+ }
+ return comparableJSON(json.Number(integer.String()))
+ case "float5":
+ number := strings.TrimPrefix(payload, "+")
+ number = strings.Replace(number, "-.", "-0.", 1)
+ if strings.HasPrefix(number, ".") {
+ number = "0" + number
+ }
+ number = strings.Replace(strings.Replace(number, ".e", ".0e", 1), ".E", ".0E", 1)
+ number = strings.TrimSuffix(number, ".")
+ return comparableJSON(json.Number(number))
+ case "text", "textraw":
+ return payload, nil
+ case "textj", "text5":
+ return unescapeJSON5(payload)
+ case "array":
+ values := make([]any, len(node.children))
+ for index, child := range node.children {
+ value, err := jsonbValue(child)
+ if err != nil {
+ return nil, err
+ }
+ values[index] = value
+ }
+ return values, nil
+ case "object":
+ if len(node.children)%2 != 0 {
+ return nil, errors.New("JSONB object without a value for its last key")
+ }
+ object := make(map[string]any, len(node.children)/2)
+ for index := 0; index < len(node.children); index += 2 {
+ key, err := jsonbValue(node.children[index])
+ if err != nil {
+ return nil, err
+ }
+ keyText, ok := key.(string)
+ if !ok {
+ return nil, fmt.Errorf("JSONB object key %v isn't a string", key)
+ }
+ value, err := jsonbValue(node.children[index+1])
+ if err != nil {
+ return nil, err
+ }
+ object[keyText] = value
+ }
+ return object, nil
+ }
+ return nil, fmt.Errorf("unexpected JSONB type %d", node.typ)
+}
+
+// unescapeJSON5 decodes the escapes in a JSONB TEXTJ or TEXT5 string
+// payload: JSON's, plus JSON5's `\'`, `\v`, `\0`, `\xHH`, and escaped line
+// breaks.
+func unescapeJSON5(payload string) (string, error) {
+ var output strings.Builder
+ for index := 0; index < len(payload); index++ {
+ if payload[index] != '\\' {
+ output.WriteByte(payload[index])
+ continue
+ }
+ index++
+ if index >= len(payload) {
+ return "", fmt.Errorf("trailing backslash in %q", payload)
+ }
+ switch escape := payload[index]; escape {
+ case '0':
+ output.WriteByte(0)
+ case '\'', '"', '\\', '/':
+ output.WriteByte(escape)
+ case 'b':
+ output.WriteByte('\b')
+ case 'f':
+ output.WriteByte('\f')
+ case 'n':
+ output.WriteByte('\n')
+ case 'r':
+ output.WriteByte('\r')
+ case 't':
+ output.WriteByte('\t')
+ case 'v':
+ output.WriteByte('\v')
+ case '\n':
+ case '\r':
+ if index+1 < len(payload) && payload[index+1] == '\n' {
+ index++
+ }
+ case 'x':
+ if index+2 >= len(payload) {
+ return "", fmt.Errorf("truncated \\x escape in %q", payload)
+ }
+ code, err := strconv.ParseUint(payload[index+1:index+3], 16, 8)
+ if err != nil {
+ return "", fmt.Errorf("invalid \\x escape in %q: %w", payload, err)
+ }
+ output.WriteRune(rune(code))
+ index += 2
+ case 'u':
+ // JSON decodes surrogate pairs, so hand it every consecutive
+ // `\u` escape at once.
+ end := index - 1
+ for end+6 <= len(payload) && payload[end] == '\\' && payload[end+1] == 'u' {
+ end += 6
+ }
+ var decoded string
+ if err := json.Unmarshal([]byte(`"`+payload[index-1:end]+`"`), &decoded); err != nil {
+ return "", fmt.Errorf("invalid \\u escape in %q: %w", payload, err)
+ }
+ output.WriteString(decoded)
+ index = end - 1
+ default:
+ // JSON5 allows escaping any other character as itself; the
+ // U+2028 and U+2029 line continuations are multi-byte, so
+ // they're written whole.
+ if escape >= 0x80 {
+ rest := payload[index:]
+ character := []rune(rest)[0]
+ if character != '
' && character != '
' {
+ output.WriteRune(character)
+ }
+ index += len(string(character)) - 1
+ continue
+ }
+ output.WriteByte(escape)
+ }
+ }
+ return output.String(), nil
+}
+
+// comparableNumber is a JSON number reduced to its exact value, so `1.50`,
+// `1.5`, and `15e-1` compare equal while remaining distinct from a string.
+type comparableNumber string
+
+// comparableJSON returns value, as decoded with json.Number, with numbers
+// replaced by their exact value, so two JSON texts compare equal whenever
+// they hold the same values with the same JSON types.
+func comparableJSON(value any) (any, error) {
+ switch value := value.(type) {
+ case json.Number:
+ exact, ok := new(big.Rat).SetString(string(value))
+ if !ok {
+ return nil, fmt.Errorf("invalid JSON number %q", value)
+ }
+ return comparableNumber(exact.RatString()), nil
+ case []any:
+ for index, element := range value {
+ converted, err := comparableJSON(element)
+ if err != nil {
+ return nil, err
+ }
+ value[index] = converted
+ }
+ return value, nil
+ case map[string]any:
+ for key, element := range value {
+ converted, err := comparableJSON(element)
+ if err != nil {
+ return nil, err
+ }
+ value[key] = converted
+ }
+ return value, nil
+ }
+ return value, nil
+}
+
+// storedJSONValue decodes a SQLite JSON column's text and its stored JSONB
+// bytes, requires the column to be stored as JSONB holding the same value
+// as the text, and returns the value.
+func storedJSONValue(t *testing.T, writer, column string, text, hexBytes *string) any {
+ t.Helper()
+
+ if text == nil {
+ require.Nil(t, hexBytes, "%s's %s is null as JSON but not as JSONB", writer, column)
+ return nil
+ }
+ decoded, err := decodeJSONWithNumbers([]byte(*text))
+ require.NoError(t, err, "%s wrote invalid %s JSON: %s", writer, column, *text)
+ value, err := comparableJSON(decoded)
+ require.NoError(t, err, "%s wrote invalid %s JSON: %s", writer, column, *text)
+
+ require.NotNil(t, hexBytes, "%s stored %s without JSONB bytes", writer, column)
+ data, err := hex.DecodeString(*hexBytes)
+ require.NoError(t, err, "%s wrote %s JSONB that isn't hex", writer, column)
+ node, rest, err := decodeJSONB(data)
+ require.NoError(t, err, "%s didn't store %s as JSONB: %s", writer, column, *hexBytes)
+ require.Empty(t, rest, "%s wrote trailing bytes after %s JSONB: %s", writer, column, *hexBytes)
+ stored, err := jsonbValue(node)
+ require.NoError(t, err, "%s stored undecodable %s JSONB: %s", writer, column, *hexBytes)
+ require.Equal(t, value, stored, "%s's stored %s JSONB and JSON text differ", writer, column)
+ return value
+}
+
+// rowTime is a time stored in a job row. Rows that two writers wrote at
+// different moments compare their times by offset from each row's
+// created_at (see requireEquivalentJobRows).
+type rowTime struct {
+ at time.Time
+}
+
+// normalizeJSONTimes checks every RFC 3339 time in value against Go's
+// `time.Time` JSON format and replaces it with its instant as a rowTime.
+func normalizeJSONTimes(t *testing.T, writer, column string, value any) any {
+ t.Helper()
+
+ switch value := value.(type) {
+ case string:
+ if rfc3339TextPattern.MatchString(value) {
+ require.Regexp(t, goTimeTextPattern, value,
+ "%s wrote a %s time in a non-Go format: %s", writer, column, value)
+ at, err := time.Parse(time.RFC3339Nano, value)
+ require.NoError(t, err, "%s wrote an invalid %s time: %s", writer, column, value)
+ return rowTime{at: at}
+ }
+ case []any:
+ for index, element := range value {
+ value[index] = normalizeJSONTimes(t, writer, column, element)
+ }
+ case map[string]any:
+ for key, element := range value {
+ value[key] = normalizeJSONTimes(t, writer, column, element)
+ }
+ }
+ return value
+}
+
+var (
+ // goTimeTextPattern matches a time the way Go's encoding/json writes a
+ // time.Time, without quotes: RFC 3339 with the shortest fractional
+ // seconds, so a fraction never ends in zero.
+ goTimeTextPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d*[1-9])?(Z|[+-]\d{2}:\d{2})$`)
+
+ // rfc3339TextPattern matches any RFC 3339 time, so times that don't
+ // match goTimeTextPattern can be reported.
+ rfc3339TextPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$`)
+
+ // sqliteTimePattern is Go's SQLite time format, `2006-01-02 15:04:05.000`.
+ // SQLite compares times as text, so every writer must use it.
+ sqliteTimePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$`)
+
+ // uniqueNoncePattern matches the random `river:unique_nonce` Go's SQLite
+ // driver writes as eight lowercase hex bytes into rows it inserts and
+ // returns.
+ uniqueNoncePattern = regexp.MustCompile(`^[0-9a-f]{16}$`)
+)
+
+// jobRowText is text written into every JSON column the row scenarios
+// compare. It holds characters JSON encoders escape differently (Go escapes
+// `<`, `>`, `&`, U+2028, and U+2029), which is fine as long as every writer
+// stores the same string.
+const jobRowText = "a&c
d
é"
+
+// comparableJobRow checks the timestamp formats and JSONB storage in row and
+// returns its columns as comparable values: times, including those in JSON
+// columns, as rowTimes, and unique nonces, which are random, as a
+// placeholder. JSON columns compare as decoded values, so escaping, member
+// order, and number spelling don't matter. Null columns are nil.
+func comparableJobRow(t *testing.T, writer string, row rawJobRow) map[string]any {
+ t.Helper()
+
+ sqliteTime := func(name string, value *string) any {
+ if value == nil {
+ return nil
+ }
+ require.Regexp(t, sqliteTimePattern, *value, "%s wrote %s in a non-Go format", writer, name)
+ at, err := time.Parse("2006-01-02 15:04:05.000", *value)
+ require.NoError(t, err, "%s wrote an invalid %s: %s", writer, name, *value)
+ return rowTime{at: at}
+ }
+ require.NotNil(t, row.JSONB, "%s returned no SQLite JSONB bytes", writer)
+ jsonColumn := func(column string, text, hexBytes *string) any {
+ return normalizeJSONTimes(t, writer, column, storedJSONValue(t, writer, column, text, hexBytes))
+ }
+ metadata := jsonColumn("metadata", &row.Metadata, &row.JSONB.Metadata)
+ if object, ok := metadata.(map[string]any); ok {
+ if nonce, ok := object["river:unique_nonce"]; ok {
+ require.IsType(t, "", nonce, "%s wrote a non-string unique nonce", writer)
+ require.Regexp(t, uniqueNoncePattern, nonce, "%s wrote a unique nonce in a non-Go format", writer)
+ object["river:unique_nonce"] = ""
+ }
+ }
+
+ return map[string]any{
+ "args": jsonColumn("args", &row.Args, &row.JSONB.Args),
+ "attempted_at": sqliteTime("attempted_at", row.AttemptedAt),
+ "attempted_by": jsonColumn("attempted_by", row.AttemptedBy, row.JSONB.AttemptedBy),
+ "created_at": sqliteTime("created_at", &row.CreatedAt),
+ "errors": jsonColumn("errors", row.Errors, row.JSONB.Errors),
+ "finalized_at": sqliteTime("finalized_at", row.FinalizedAt),
+ "metadata": metadata,
+ "scheduled_at": sqliteTime("scheduled_at", &row.ScheduledAt),
+ "tags": jsonColumn("tags", &row.Tags, &row.JSONB.Tags),
+ "unique": uniqueColumns{
+ Key: row.UniqueKey, KeyType: row.UniqueKeyType, States: row.UniqueStates, StatesType: row.UniqueStatesType,
+ },
+ }
+}
+
+// rowTimeTolerance bounds how far apart the same time may be in two rows
+// written by the same steps at different moments, relative to each row's
+// created_at. It absorbs scheduling differences between implementations
+// while catching a time taken at the wrong step or computed with a wrong
+// delay.
+const rowTimeTolerance = 2 * time.Second
+
+// requireEquivalentJobRows requires that two comparable rows written by the
+// same steps hold the same values. Times are equal when they're the same
+// instant, as for a time given in the request, or when their offsets from
+// their own row's created_at differ by at most rowTimeTolerance. Times at
+// the paths in unpinned (like `.finalized_at`) only need to be present in
+// both rows, for steps whose timing depends on tuning only some
+// implementations accept.
+func requireEquivalentJobRows(t *testing.T, operation, referenceName, candidateName string, reference, candidate map[string]any, unpinned ...string) {
+ t.Helper()
+
+ referenceCreated, ok := reference["created_at"].(rowTime)
+ require.True(t, ok, "%s: %s's row has no created_at", operation, referenceName)
+ candidateCreated, ok := candidate["created_at"].(rowTime)
+ require.True(t, ok, "%s: %s's row has no created_at", operation, candidateName)
+ sameTime := func(path string, referenceTime, candidateTime rowTime) bool {
+ if referenceTime.at.Equal(candidateTime.at) || slices.Contains(unpinned, path) {
+ return true
+ }
+ difference := referenceTime.at.Sub(referenceCreated.at) - candidateTime.at.Sub(candidateCreated.at)
+ return difference.Abs() <= rowTimeTolerance
+ }
+ differences := rowValueDifferences("", reference, candidate, sameTime)
+ require.Empty(t, differences, "%s: %s and %s wrote different rows", operation, referenceName, candidateName)
+}
+
+// rowValueDifferences returns a description of every place in which the
+// comparable values reference and candidate differ, comparing rowTimes
+// with sameTime.
+func rowValueDifferences(path string, reference, candidate any, sameTime func(string, rowTime, rowTime) bool) []string {
+ switch reference := reference.(type) {
+ case rowTime:
+ candidate, ok := candidate.(rowTime)
+ if !ok || !sameTime(path, reference, candidate) {
+ return []string{fmt.Sprintf("%s: %v != %v", path, describeRowValue(reference), describeRowValue(candidate))}
+ }
+ return nil
+ case map[string]any:
+ candidate, ok := candidate.(map[string]any)
+ if !ok {
+ return []string{fmt.Sprintf("%s: %v != %v", path, reference, describeRowValue(candidate))}
+ }
+ var differences []string
+ for key := range reference {
+ if _, ok := candidate[key]; !ok {
+ differences = append(differences, fmt.Sprintf("%s.%s: missing", path, key))
+ }
+ }
+ for key := range candidate {
+ if _, ok := reference[key]; !ok {
+ differences = append(differences, fmt.Sprintf("%s.%s: unexpected %v", path, key, describeRowValue(candidate[key])))
+ continue
+ }
+ differences = append(differences, rowValueDifferences(path+"."+key, reference[key], candidate[key], sameTime)...)
+ }
+ slices.Sort(differences)
+ return differences
+ case []any:
+ candidate, ok := candidate.([]any)
+ if !ok || len(candidate) != len(reference) {
+ return []string{fmt.Sprintf("%s: %v != %v", path, describeRowValue(reference), describeRowValue(candidate))}
+ }
+ var differences []string
+ for index := range reference {
+ differences = append(differences, rowValueDifferences(fmt.Sprintf("%s[%d]", path, index), reference[index], candidate[index], sameTime)...)
+ }
+ return differences
+ }
+ if !reflect.DeepEqual(reference, candidate) {
+ return []string{fmt.Sprintf("%s: %v != %v", path, describeRowValue(reference), describeRowValue(candidate))}
+ }
+ return nil
+}
+
+// describeRowValue renders a comparable value for a difference message.
+func describeRowValue(value any) string {
+ switch value := value.(type) {
+ case rowTime:
+ return value.at.Format(time.RFC3339Nano)
+ case *string:
+ if value == nil {
+ return ""
+ }
+ return strconv.Quote(*value)
+ }
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ return fmt.Sprintf("%#v", value)
+ }
+ return string(encoded)
+}
+
+// requireSameJobRows requires that the rows reference and candidate wrote
+// for the same operation hold equivalent values.
+func requireSameJobRows(t *testing.T, operation string, reference, candidate *adapter, referenceID, candidateID int64) {
+ t.Helper()
+
+ var referenceRow, candidateRow rawJobRow
+ reference.call(t, "raw_job_row", map[string]any{"id": referenceID}, &referenceRow)
+ candidate.call(t, "raw_job_row", map[string]any{"id": candidateID}, &candidateRow)
+ requireEquivalentJobRows(t, operation, reference.name, candidate.name,
+ comparableJobRow(t, reference.name, referenceRow),
+ comparableJobRow(t, candidate.name, candidateRow))
+}
+
+// verifySQLiteJobRows has each implementation write the same jobs through
+// insert, batch insert, update, cancel, and retry, then compares every JSON
+// and timestamp column it stored in SQLite with Go's.
+func verifySQLiteJobRows(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ opts := map[string]any{
+ "max_attempts": 7,
+ "metadata": map[string]any{
+ "note": jobRowText,
+ "number": 1.5,
+ "nested": map[string]any{"zeta": jobRowText, "alpha": []any{1, "<&>", nil}},
+ },
+ "priority": 2,
+ "scheduled_at": "2031-02-03T04:05:06.789Z",
+ "tags": []string{"job-rows", "tag_2"},
+ }
+ type writtenJobs struct {
+ batch, cancelled, inserted, retried, updated int64
+ }
+ write := func(writer *adapter) writtenJobs {
+ var written writtenJobs
+ var inserted normalizedJob
+ writer.call(t, "insert", map[string]any{"message": jobRowText, "opts": opts}, &inserted)
+ written.inserted = inserted.ID
+
+ var batch struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ writer.call(t, "insert_many", map[string]any{"jobs": []map[string]any{
+ {"message": jobRowText + " batch", "opts": opts},
+ {"message": jobRowText + " update", "opts": opts},
+ {"message": jobRowText + " cancel", "opts": opts},
+ {"message": jobRowText + " retry", "opts": opts},
+ }}, &batch)
+ require.Len(t, batch.Results, 4)
+ written.batch = batch.Results[0].Job.ID
+ written.updated = batch.Results[1].Job.ID
+ written.cancelled = batch.Results[2].Job.ID
+ written.retried = batch.Results[3].Job.ID
+
+ writer.call(t, "update", map[string]any{
+ "id": written.updated,
+ "output": map[string]any{"text": jobRowText, "values": []any{2.5, "<&>"}},
+ }, nil)
+ writer.call(t, "cancel", map[string]any{"id": written.cancelled}, nil)
+ writer.call(t, "cancel", map[string]any{"id": written.retried}, nil)
+ writer.call(t, "retry", map[string]any{"id": written.retried}, nil)
+ return written
+ }
+
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ reference := write(goAdapter)
+ candidate := write(candidateAdapter)
+ for _, operation := range []struct {
+ name string
+ reference, candidate int64
+ }{
+ {"insert", reference.inserted, candidate.inserted},
+ {"insert_many", reference.batch, candidate.batch},
+ {"update", reference.updated, candidate.updated},
+ {"cancel", reference.cancelled, candidate.cancelled},
+ {"retry", reference.retried, candidate.retried},
+ } {
+ requireSameJobRows(t, operation.name, goAdapter, candidateAdapter, operation.reference, operation.candidate)
+ }
+}
+
+// verifySQLiteWorkedJobRows has each implementation work the same Go
+// inserted jobs to completion, discard, and recorded output, then compares
+// the SQLite columns written by each worker with Go's.
+func verifySQLiteWorkedJobRows(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ const clientID = "sqlite-job-rows-worker"
+ opts := map[string]any{
+ "max_attempts": 1,
+ "metadata": map[string]any{"note": jobRowText},
+ "tags": []string{"job-rows", "tag_2"},
+ }
+ behaviors := []string{"", "error", "output"}
+ insert := func() []int64 {
+ ids := make([]int64, len(behaviors))
+ for index, behavior := range behaviors {
+ var inserted normalizedJob
+ goAdapter.call(t, "insert", map[string]any{
+ "behavior": behavior, "message": jobRowText, "opts": opts,
+ }, &inserted)
+ ids[index] = inserted.ID
+ }
+ return ids
+ }
+
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ referenceIDs := insert()
+ for _, id := range referenceIDs {
+ goAdapter.call(t, "work", map[string]any{"client_id": clientID, "id": id}, nil)
+ }
+ candidateIDs := insert()
+ for _, id := range candidateIDs {
+ candidateAdapter.call(t, "work", map[string]any{"client_id": clientID, "id": id}, nil)
+ }
+ for index, behavior := range behaviors {
+ requireSameJobRows(t, "work "+behavior, goAdapter, candidateAdapter, referenceIDs[index], candidateIDs[index])
+ }
+}
+
+// verifySQLiteRuntimeJobRows compares the SQLite columns, state, and
+// attempt each implementation's client writes when it claims a job, snoozes
+// one, discards a retry that conflicts with a unique job, and rescues an
+// abandoned job. Go sets up the same jobs for both, so every other column
+// matches too.
+func verifySQLiteRuntimeJobRows(t *testing.T, repositoryRoot, databaseURL, profile string, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ operations := []string{"claim", "snooze", "scheduler discard", "rescue"}
+ crashes := 0
+ type runtimeJobRow struct {
+ attempt int
+ raw rawJobRow
+ state string
+ }
+ write := func(actor *adapter) map[string]runtimeJobRow {
+ t.Helper()
+
+ rows := make(map[string]runtimeJobRow, len(operations))
+ read := func(operation string, id int64) {
+ t.Helper()
+
+ var row runtimeJobRow
+ goAdapter.call(t, "raw_job_row", map[string]any{"id": id}, &row.raw)
+ var job normalizedJob
+ goAdapter.call(t, "get", map[string]any{"id": id}, &job)
+ row.attempt, row.state = job.Attempt, job.State
+ rows[operation] = row
+ }
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ opts := map[string]any{"metadata": map[string]any{"note": jobRowText}, "tags": []string{"job-rows"}}
+
+ // Claim and snooze: the actor works jobs Go inserts, one held on a
+ // barrier while running and one snoozed well beyond the scheduler's
+ // threshold so it stays scheduled.
+ const barrier = "job-rows-claim"
+ actor.call(t, "barrier_create", map[string]any{"name": barrier}, nil)
+ actor.call(t, "start", map[string]any{"client_id": "job-rows-runtime", "max_workers": 2}, nil)
+ var claimed, snoozed normalizedJob
+ goAdapter.call(t, "insert", map[string]any{"behavior": "barrier_wait", "message": barrier, "opts": opts}, &claimed)
+ goAdapter.call(t, "wait", map[string]any{"id": claimed.ID, "states": []string{"running"}}, &claimed)
+ read("claim", claimed.ID)
+ actor.call(t, "barrier_release", map[string]any{"name": barrier}, nil)
+ goAdapter.call(t, "insert", map[string]any{
+ "behavior": "snooze_once", "duration_ms": 60_000, "message": jobRowText, "opts": opts,
+ }, &snoozed)
+ goAdapter.call(t, "wait", map[string]any{"id": snoozed.ID, "states": []string{"scheduled"}}, &snoozed)
+ read("snooze", snoozed.ID)
+ goAdapter.call(t, "wait", map[string]any{"id": claimed.ID}, &claimed)
+ actor.call(t, "stop", map[string]any{}, nil)
+
+ // Scheduler discard: a retryable unique job whose unique states
+ // exclude retryable becomes due while another job holds its key, so
+ // the leader's scheduler discards it. The retry delay exceeds Go's
+ // default scheduler interval, so the retry stays retryable until then.
+ uniqueOpts := map[string]any{
+ "max_attempts": 3, "queue": "job_rows_discard",
+ "unique": map[string]any{"by_args": true, "by_state": []string{"available", "pending", "running", "scheduled"}},
+ }
+ goAdapter.call(t, "start", map[string]any{
+ "client_id": "job-rows-setup", "leader_election_disabled": true, "max_workers": 1,
+ "queue": "job_rows_discard", "retry_delay_ms": 5_500,
+ }, nil)
+ var discarded, holder normalizedJob
+ goAdapter.call(t, "insert", map[string]any{"behavior": "error", "message": jobRowText, "opts": uniqueOpts}, &discarded)
+ goAdapter.call(t, "wait", map[string]any{"id": discarded.ID, "states": []string{"retryable"}}, &discarded)
+ goAdapter.call(t, "stop", map[string]any{}, nil)
+ goAdapter.call(t, "insert", map[string]any{"behavior": "error", "message": jobRowText, "opts": uniqueOpts}, &holder)
+ require.NotEqual(t, discarded.ID, holder.ID, "a retryable job outside its unique states blocked insertion")
+ time.Sleep(time.Until(parseTime(t, discarded.ScheduledAt).Add(100 * time.Millisecond)))
+ actor.startWithTuning(t, map[string]any{"client_id": "job-rows-scheduler", "max_workers": 1},
+ map[string]any{"elect_interval_ms": 20, "scheduler_interval_ms": 20})
+ goAdapter.call(t, "wait", map[string]any{"id": discarded.ID, "states": []string{"discarded"}}, &discarded)
+ read("scheduler discard", discarded.ID)
+ actor.call(t, "stop", map[string]any{}, nil)
+
+ // Rescue: a process holding a running attempt dies, and the actor's
+ // leader rescues the abandoned attempt. Its retry delay keeps the
+ // rescued job retryable.
+ crashes++
+ const rescueAfter = time.Second
+ crasher := startReferenceAdapterForProfile(t, repositoryRoot, databaseURL, "sqlite", profile,
+ fmt.Sprintf("go-job-rows-crasher-%d", crashes))
+ crasher.call(t, "start", map[string]any{
+ "client_id": "job-rows-crasher", "leader_election_disabled": true, "max_workers": 1, "queue": "job_rows_rescue",
+ }, nil)
+ var rescued normalizedJob
+ goAdapter.call(t, "insert", map[string]any{
+ "behavior": "sleep", "duration_ms": 60_000, "message": jobRowText,
+ "opts": map[string]any{"max_attempts": 3, "queue": "job_rows_rescue", "tags": []string{"job-rows"}},
+ }, &rescued)
+ goAdapter.call(t, "wait", map[string]any{"id": rescued.ID, "states": []string{"running"}}, &rescued)
+ crasher.kill(t)
+ waitUntilRescuable(t, rescued, rescueAfter)
+ actor.startWithTuning(t, map[string]any{
+ "client_id": "job-rows-rescuer", "job_timeout_ms": rescueAfter.Milliseconds(), "max_workers": 1,
+ "rescue_after_ms": rescueAfter.Milliseconds(), "retry_delay_ms": 60_000,
+ }, map[string]any{"elect_interval_ms": 20, "rescuer_interval_ms": 20})
+ goAdapter.call(t, "wait", map[string]any{"id": rescued.ID, "states": []string{"retryable"}}, &rescued)
+ read("rescue", rescued.ID)
+ actor.call(t, "stop", map[string]any{}, nil)
+ return rows
+ }
+
+ comparableRow := func(writer string, row runtimeJobRow) map[string]any {
+ t.Helper()
+
+ columns := comparableJobRow(t, writer, row.raw)
+ columns["attempt"] = row.attempt
+ columns["state"] = row.state
+ return columns
+ }
+ // A scheduler finalizes a discarded job at its look-ahead time, the
+ // current time plus its interval, which only implementations that accept
+ // `scheduler_interval_ms` shorten.
+ unpinned := map[string][]string{"scheduler discard": {".finalized_at"}}
+ reference := write(goAdapter)
+ candidate := write(candidateAdapter)
+ for _, operation := range operations {
+ requireEquivalentJobRows(t, operation, goAdapter.name, candidateAdapter.name,
+ comparableRow(goAdapter.name, reference[operation]),
+ comparableRow(candidateAdapter.name, candidate[operation]),
+ unpinned[operation]...)
+ }
+}
diff --git a/conformance/harness/kinds_test.go b/conformance/harness/kinds_test.go
new file mode 100644
index 000000000..6603ba5d7
--- /dev/null
+++ b/conformance/harness/kinds_test.go
@@ -0,0 +1,285 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "fmt"
+ "maps"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// Kinds the adapters' `worker_kinds` start parameter registers the built-in
+// worker under. The renamed kind keeps the echo kind as an alias.
+const (
+ echoKind = "conformance_echo"
+ peerKind = "conformance_echo_peer"
+ renamedKind = "conformance_echo_renamed"
+)
+
+// insertJobOfKind inserts a built-in job of kind through actor. The raw
+// insertion doesn't check the kind against the actor's running client,
+// which may not know it.
+func insertJobOfKind(t *testing.T, actor *adapter, kind string, params map[string]any) normalizedJob {
+ t.Helper()
+
+ request := map[string]any{"kind": kind}
+ maps.Copy(request, params)
+ var job normalizedJob
+ actor.call(t, "raw_insert_no_notify", request, &job)
+ require.Equal(t, kind, job.Kind)
+ return job
+}
+
+// requireWorkedOnceBy requires that job completed in a single attempt made
+// by clientID, without errors, and kept kind.
+func requireWorkedOnceBy(t *testing.T, job normalizedJob, kind, clientID string) {
+ t.Helper()
+
+ require.Equal(t, "completed", job.State, "job %d (%s)", job.ID, kind)
+ require.Equal(t, 1, job.Attempt, "job %d (%s)", job.ID, kind)
+ require.Equal(t, []string{clientID}, job.AttemptedBy, "job %d (%s)", job.ID, kind)
+ require.Empty(t, job.Errors, "job %d (%s)", job.ID, kind)
+ require.Equal(t, kind, job.Kind, "job %d", job.ID)
+}
+
+// requireUnclaimed requires that the job with id is still available and that
+// no client has used one of its attempts.
+func requireUnclaimed(t *testing.T, observer *adapter, id int64, kind string) {
+ t.Helper()
+
+ var job normalizedJob
+ observer.call(t, "get", map[string]any{"id": id}, &job)
+ require.Equal(t, "available", job.State, "job %d (%s)", id, kind)
+ require.Zero(t, job.Attempt, "job %d (%s)", id, kind)
+ require.Empty(t, job.AttemptedBy, "job %d (%s)", id, kind)
+ require.Empty(t, job.Errors, "job %d (%s)", id, kind)
+}
+
+// verifyKindAliasRename checks a safe kind rename across implementations,
+// as Go's `JobArgsWithKindAliases` supports it: one implementation inserts
+// jobs under the old kind and the new one, and the other works both with a
+// worker registered under the new kind that keeps the old one as an alias.
+// It does so first with an ordinary client and then with one that fetches
+// only known kinds, whose claim filter must include the alias, while a job
+// of a kind it doesn't know stays untouched.
+func verifyKindAliasRename(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ inserter *adapter
+ worker *adapter
+ }{
+ {inserter: goAdapter, worker: candidateAdapter},
+ {inserter: candidateAdapter, worker: goAdapter},
+ } {
+ for _, fetchOnlyKnownKinds := range []bool{false, true} {
+ pair.inserter.call(t, "reset", map[string]any{}, nil)
+ description := fmt.Sprintf("%s renamed worker (fetch_only_known_kinds %t)", pair.worker.name, fetchOnlyKnownKinds)
+ oldKindJob := insertJobOfKind(t, pair.inserter, echoKind, map[string]any{"message": "kind alias old kind"})
+ newKindJob := insertJobOfKind(t, pair.inserter, renamedKind, map[string]any{"message": "kind alias new kind"})
+ var unknownJob normalizedJob
+ if fetchOnlyKnownKinds {
+ unknownJob = insertJobOfKind(t, pair.inserter, peerKind, map[string]any{"message": "kind alias unknown kind"})
+ }
+
+ clientID := pair.worker.name + "-renamed-worker"
+ pair.worker.call(t, "start", map[string]any{
+ "client_id": clientID, "fetch_only_known_kinds": fetchOnlyKnownKinds, "max_workers": 2,
+ "worker_kinds": []string{renamedKind},
+ }, nil)
+ for _, job := range []normalizedJob{oldKindJob, newKindJob} {
+ worked := waitForJobStateWithin(t, pair.worker, job.ID, []string{"completed", "discarded", "retryable"}, 30*time.Second)
+ require.Equal(t, "completed", worked.State, "%s: %+v", description, worked)
+ requireWorkedOnceBy(t, worked, job.Kind, clientID)
+ }
+ if fetchOnlyKnownKinds {
+ requireUnclaimed(t, pair.inserter, unknownJob.ID, peerKind)
+ }
+ pair.worker.call(t, "stop", map[string]any{}, nil)
+ }
+ }
+}
+
+// verifyHeterogeneousFleet checks clients that share a queue while each
+// knows only its own kind, the deployment Go's `FetchOnlyKnownKinds`
+// exists for. In each direction, the first client starts alone with jobs of
+// the other's kind ahead of its own in claim order, works its own, and must
+// leave the others available with no attempt used. The second then starts
+// and works the rest, and jobs of both kinds inserted while both run are
+// each worked by the client that knows their kind.
+func verifyHeterogeneousFleet(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ const jobsPerKind = 3
+ for _, pair := range []struct {
+ first *adapter
+ second *adapter
+ }{
+ {first: candidateAdapter, second: goAdapter},
+ {first: goAdapter, second: candidateAdapter},
+ } {
+ pair.first.call(t, "reset", map[string]any{}, nil)
+ firstKind, secondKind := peerKind, echoKind
+ clientIDs := map[string]string{
+ firstKind: pair.first.name + "-fleet-" + firstKind,
+ secondKind: pair.second.name + "-fleet-" + secondKind,
+ }
+ jobs := map[string][]normalizedJob{}
+ insert := func(actor *adapter, kind string) {
+ for index := range jobsPerKind {
+ jobs[kind] = append(jobs[kind], insertJobOfKind(t, actor, kind, map[string]any{"message": fmt.Sprintf("fleet %s %d", kind, index)}))
+ }
+ }
+ // Lower IDs claim first, so a client that ignores the kind filter
+ // would claim the other kind's jobs before its own.
+ insert(pair.first, secondKind)
+ insert(pair.second, firstKind)
+
+ pair.first.call(t, "start", map[string]any{
+ "client_id": clientIDs[firstKind], "fetch_only_known_kinds": true, "max_workers": 1,
+ "worker_kinds": []string{firstKind},
+ }, nil)
+ for _, job := range jobs[firstKind] {
+ worked := waitForJobStateWithin(t, pair.first, job.ID, []string{"completed", "discarded", "retryable"}, 30*time.Second)
+ requireWorkedOnceBy(t, worked, firstKind, clientIDs[firstKind])
+ }
+ for _, job := range jobs[secondKind] {
+ requireUnclaimed(t, pair.second, job.ID, secondKind)
+ }
+
+ pair.second.call(t, "start", map[string]any{
+ "client_id": clientIDs[secondKind], "fetch_only_known_kinds": true, "max_workers": 1,
+ "worker_kinds": []string{secondKind},
+ }, nil)
+ insert(pair.first, firstKind)
+ insert(pair.second, secondKind)
+ for kind, kindJobs := range jobs {
+ for _, job := range kindJobs {
+ worked := waitForJobStateWithin(t, pair.second, job.ID, []string{"completed", "discarded", "retryable"}, 30*time.Second)
+ requireWorkedOnceBy(t, worked, kind, clientIDs[kind])
+ }
+ }
+ pair.first.call(t, "stop", map[string]any{}, nil)
+ pair.second.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+// rescueOutcome is what a rescuer did to one abandoned job, without the
+// values that differ between runs.
+type rescueOutcome struct {
+ AttemptedBy []string
+ Attempt int
+ Errors []rescueOutcomeError
+ Finalized bool
+ Kind string
+ MaxAttempts int
+ RescueCount any
+ // RetryDelay is the delay from the rescue to the job's new scheduled_at,
+ // to the second, or zero when the rescue left scheduled_at unchanged.
+ RetryDelay time.Duration
+ State string
+}
+
+type rescueOutcomeError struct {
+ Attempt int
+ Error string
+ Trace string
+}
+
+// verifyRescuerUnknownKind checks how a leader that knows only some kinds
+// rescues jobs abandoned by a client that knew others, as happens when
+// implementations with disjoint workers share a database. A Go process
+// that works both kinds dies holding one job of each, and each
+// implementation in turn leads with a worker for one kind only. Like Go's
+// rescuer, it must retry the job of the kind it knows on its retry policy
+// and discard the one it doesn't, and both must end up as Go leaves them.
+func verifyRescuerUnknownKind(t *testing.T, goAdapter, candidateAdapter *adapter, newCrasher func(t *testing.T, name string) *adapter) {
+ t.Helper()
+
+ const (
+ queue = "rescuer_kinds"
+ rescueAfter = time.Second
+ retryDelay = time.Minute
+ )
+ outcomes := make(map[string]map[string]rescueOutcome)
+ for _, leader := range []*adapter{goAdapter, candidateAdapter} {
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ inserted := make(map[string]normalizedJob)
+ for _, kind := range []string{echoKind, peerKind} {
+ // Go runs no client here, so it inserts either kind.
+ var job normalizedJob
+ goAdapter.call(t, "insert", map[string]any{
+ "behavior": "sleep", "duration_ms": 60_000, "message": "rescuer kinds " + kind,
+ "opts": map[string]any{"max_attempts": 3, "queue": queue},
+ }, &job)
+ if kind != echoKind {
+ goAdapter.call(t, "raw_set_kind", map[string]any{"id": job.ID, "kind": kind}, &job)
+ }
+ inserted[kind] = job
+ }
+
+ const crasherID = "rescuer-kinds-crasher"
+ crasher := newCrasher(t, "go-rescuer-kinds-crasher-"+leader.name)
+ crasher.call(t, "start", map[string]any{
+ "client_id": crasherID, "leader_election_disabled": true, "max_workers": 2, "queue": queue,
+ "worker_kinds": []string{echoKind, peerKind},
+ }, nil)
+ running := make(map[string]normalizedJob)
+ for kind, job := range inserted {
+ running[kind] = waitForJobStateWithin(t, goAdapter, job.ID, []string{"running"}, 30*time.Second)
+ }
+ crasher.kill(t)
+ for _, job := range running {
+ waitUntilRescuable(t, job, rescueAfter)
+ }
+
+ // The leader knows only the peer kind, so the echo kind is unknown
+ // to it.
+ leader.startWithTuning(t, map[string]any{
+ "client_id": "rescuer-kinds-leader", "job_timeout_ms": rescueAfter.Milliseconds(), "max_workers": 1,
+ "rescue_after_ms": rescueAfter.Milliseconds(), "retry_delay_ms": retryDelay.Milliseconds(),
+ "worker_kinds": []string{peerKind},
+ }, map[string]any{"elect_interval_ms": 20, "rescuer_interval_ms": 20, "scheduler_interval_ms": 20})
+ rescued := map[string]normalizedJob{
+ echoKind: waitForJobStateWithin(t, goAdapter, inserted[echoKind].ID, []string{"discarded"}, 30*time.Second),
+ peerKind: waitForJobStateWithin(t, goAdapter, inserted[peerKind].ID, []string{"retryable"}, 30*time.Second),
+ }
+ leader.call(t, "stop", map[string]any{}, nil)
+
+ outcomes[leader.name] = make(map[string]rescueOutcome)
+ for kind, job := range rescued {
+ outcome := rescueOutcome{
+ AttemptedBy: job.AttemptedBy,
+ Attempt: job.Attempt,
+ Finalized: job.FinalizedAt != nil,
+ Kind: job.Kind,
+ MaxAttempts: job.MaxAttempts,
+ RescueCount: job.Metadata["river:rescue_count"],
+ State: job.State,
+ }
+ for _, attemptError := range job.Errors {
+ outcome.Errors = append(outcome.Errors, rescueOutcomeError{
+ Attempt: attemptError.Attempt, Error: attemptError.Error, Trace: attemptError.Trace,
+ })
+ }
+ require.Len(t, job.Errors, 1, "%s rescue of %s", leader.name, kind)
+ if job.ScheduledAt != running[kind].ScheduledAt {
+ outcome.RetryDelay = parseTime(t, job.ScheduledAt).Sub(parseTime(t, job.Errors[0].At)).Round(time.Second)
+ }
+ outcomes[leader.name][kind] = outcome
+ }
+ }
+
+ reference := outcomes[goAdapter.name]
+ require.Equal(t, "discarded", reference[echoKind].State)
+ require.True(t, reference[echoKind].Finalized)
+ require.Zero(t, reference[echoKind].RetryDelay)
+ require.Equal(t, "retryable", reference[peerKind].State)
+ require.False(t, reference[peerKind].Finalized)
+ require.Equal(t, retryDelay, reference[peerKind].RetryDelay)
+ require.Equal(t, reference, outcomes[candidateAdapter.name],
+ "%s's rescuer and Go's left abandoned jobs differently", candidateAdapter.name)
+}
diff --git a/conformance/harness/lifecycle_scenarios_test.go b/conformance/harness/lifecycle_scenarios_test.go
new file mode 100644
index 000000000..0a96b807a
--- /dev/null
+++ b/conformance/harness/lifecycle_scenarios_test.go
@@ -0,0 +1,478 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// rescuedErrorText is the attempt error River records for a rescued job.
+const rescuedErrorText = "Stuck job rescued by JobRescuer"
+
+// maintenanceWait bounds maintenance-driven waits for implementations that
+// run with their default intervals: an election retry, a rescuer run, and a
+// scheduler run can each take several seconds.
+const maintenanceWait = 45 * time.Second
+
+// startDisposable starts a new process of the implementation behind current,
+// suitable for being killed.
+func startDisposable(t *testing.T, root, databaseURL, name string, current *adapter) *adapter {
+ t.Helper()
+
+ if current.spec.Implementation == referenceSpec().Implementation {
+ return startReferenceAdapter(t, root, databaseURL, name)
+ }
+ return startCandidateAdapter(t, root, databaseURL, name, current.spec, current.spec.RestartCommand)
+}
+
+// waitForJobStateWithin polls a job until it reaches one of states or the
+// timeout elapses. Unlike the adapter's own wait, the bound is chosen by the
+// scenario, for transitions driven by default maintenance intervals.
+func waitForJobStateWithin(t *testing.T, observer *adapter, id int64, states []string, timeout time.Duration) normalizedJob {
+ t.Helper()
+
+ deadline := time.Now().Add(timeout)
+ var job normalizedJob
+ for time.Now().Before(deadline) {
+ observer.call(t, "get", map[string]any{"id": id}, &job)
+ if slices.Contains(states, job.State) {
+ return job
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ t.Fatalf("job %d did not reach %v within %s; last state %s: %+v", id, states, timeout, job.State, job)
+ return job
+}
+
+// waitUntilRescuable waits until a running attempt is older than the rescue
+// horizon, so the next rescuer run must rescue it.
+func waitUntilRescuable(t *testing.T, job normalizedJob, rescueAfter time.Duration) {
+ t.Helper()
+
+ require.NotNil(t, job.AttemptedAt)
+ time.Sleep(time.Until(parseTime(t, *job.AttemptedAt).Add(rescueAfter + 100*time.Millisecond)))
+}
+
+// verifyProcessKillCrossEngineRescue kills a process of one implementation
+// while it holds a running attempt and requires the other implementation to
+// take over leadership, rescue the abandoned attempt, and complete it.
+func verifyProcessKillCrossEngineRescue(t *testing.T, root, databaseURL string, crashingKind, recovery *adapter) {
+ t.Helper()
+
+ const rescueAfter = 1_500 * time.Millisecond
+ recovery.call(t, "reset", map[string]any{}, nil)
+ queue := "process_kill_" + crashingKind.spec.Implementation
+ crashingID := crashingKind.spec.Implementation + "-killed-worker"
+ crashing := startDisposable(t, root, databaseURL, crashingID, crashingKind)
+ crashing.call(t, "start", map[string]any{"client_id": crashingID, "max_workers": 1, "queue": queue}, nil)
+
+ var job normalizedJob
+ recovery.call(t, "insert", map[string]any{
+ "behavior": "sleep", "duration_ms": 1_000, "message": "rescue after " + crashingKind.spec.Implementation + " dies",
+ "opts": map[string]any{"queue": queue},
+ }, &job)
+ job = waitForJobStateWithin(t, recovery, job.ID, []string{"running"}, 10*time.Second)
+ require.Equal(t, []string{crashingID}, job.AttemptedBy)
+ crashing.kill(t)
+ // The killed process cannot resign. Expiring its lease stands in for the
+ // lease running out, which would otherwise take the full TTL.
+ recovery.call(t, "fault_expire_leader", map[string]any{}, nil)
+ waitUntilRescuable(t, job, rescueAfter)
+
+ recoveryID := recovery.spec.Implementation + "-rescuer"
+ recovery.startWithTuning(t, map[string]any{
+ "client_id": recoveryID, "job_timeout_ms": rescueAfter.Milliseconds(), "max_workers": 1,
+ "queue": queue, "rescue_after_ms": rescueAfter.Milliseconds(),
+ }, map[string]any{"elect_interval_ms": 20, "rescuer_interval_ms": 20, "scheduler_interval_ms": 20})
+ require.Equal(t, recoveryID, waitForLeader(t, recovery, crashingID))
+ job = waitForJobStateWithin(t, recovery, job.ID, []string{"cancelled", "completed", "discarded"}, maintenanceWait)
+ require.Equal(t, "completed", job.State)
+ require.Equal(t, 2, job.Attempt)
+ require.Equal(t, []string{crashingID, recoveryID}, job.AttemptedBy)
+ require.Len(t, job.Errors, 1)
+ require.Equal(t, rescuedErrorText, job.Errors[0].Error)
+ require.EqualValues(t, 1, job.Metadata["river:rescue_count"])
+ recovery.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyLeaderDeathFailover kills the leading process of one implementation
+// and requires the other implementation to take over. Both run the same
+// run-on-start periodic job with instrumentation, so the enqueued periodic
+// jobs and each engine's periodic-enqueuer starts show that exactly one
+// engine runs leader-only maintenance in each term.
+func verifyLeaderDeathFailover(t *testing.T, root, databaseURL string, leaderKind, follower *adapter) {
+ t.Helper()
+
+ periodicFilter := map[string]any{"metadata": map[string]any{"river:periodic_job_id": "conformance-periodic"}}
+ follower.call(t, "reset", map[string]any{}, nil)
+ leaderID := leaderKind.spec.Implementation + "-dying-leader"
+ leader := startDisposable(t, root, databaseURL, leaderID, leaderKind)
+ leader.call(t, "start", map[string]any{
+ "client_id": leaderID, "instrumented": true, "max_workers": 1, "periodic_run_on_start": true,
+ }, nil)
+ require.Equal(t, leaderID, waitForLeader(t, follower, ""))
+ waitForRuntimeStats(t, leader, func(stats runtimeStats) bool { return stats.PeriodicStarts == 1 })
+ waitForListedJobCount(t, follower, periodicFilter, 1)
+
+ followerID := follower.spec.Implementation + "-surviving-follower"
+ follower.call(t, "start", map[string]any{
+ "client_id": followerID, "instrumented": true, "max_workers": 1, "periodic_run_on_start": true,
+ }, nil)
+ // Completing a job gives a follower that wrongly started leader-only
+ // maintenance time to show it before the checks below.
+ var marker normalizedJob
+ follower.call(t, "insert", map[string]any{"message": "follower running"}, &marker)
+ follower.call(t, "wait", map[string]any{"id": marker.ID}, &marker)
+ stats := waitForRuntimeStats(t, follower, func(runtimeStats) bool { return true })
+ require.Zero(t, stats.PeriodicStarts, "a follower ran the leader-only periodic enqueuer")
+ require.Equal(t, leaderID, readLeader(t, follower).LeaderID)
+ waitForListedJobCount(t, follower, periodicFilter, 1)
+
+ leader.kill(t)
+ // The dead leader cannot resign; expiring its lease stands in for the
+ // TTL running out.
+ follower.call(t, "fault_expire_leader", map[string]any{}, nil)
+ require.Equal(t, followerID, waitForLeader(t, follower, leaderID))
+ waitForRuntimeStats(t, follower, func(stats runtimeStats) bool { return stats.PeriodicStarts == 1 })
+ periodic := waitForListedJobCount(t, follower, periodicFilter, 2)
+ for _, job := range periodic {
+ require.Equal(t, true, job.Metadata["periodic"])
+ }
+ // The count stays at one periodic job per term after later work.
+ follower.call(t, "insert", map[string]any{"message": "after takeover"}, &marker)
+ follower.call(t, "wait", map[string]any{"id": marker.ID}, &marker)
+ waitForListedJobCount(t, follower, periodicFilter, 2)
+ require.Equal(t, followerID, readLeader(t, follower).LeaderID)
+ follower.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyRollingDeployment replaces every engine's process one at a time
+// while both implementations keep inserting and working jobs, then requires
+// every job to complete exactly once. The engines share a protocol revision
+// but run as independently restarted processes, which is the version skew
+// a rolling deployment of mixed implementations produces.
+func verifyRollingDeployment(t *testing.T, root, databaseURL string, pair mixedPair) {
+ t.Helper()
+
+ const jobsPerStep = 20
+ pair.reference.call(t, "reset", map[string]any{}, nil)
+ type deployment struct {
+ adapter *adapter
+ kind *adapter
+ version int
+ }
+ deployments := []*deployment{
+ {adapter: startDisposable(t, root, databaseURL, "go-rolling-0", pair.reference), kind: pair.reference},
+ {adapter: startDisposable(t, root, databaseURL, pair.candidateSpec.Implementation+"-rolling-0", pair.candidate), kind: pair.candidate},
+ }
+ clientID := func(current *deployment) string {
+ return fmt.Sprintf("%s-rolling-%d", current.kind.spec.Implementation, current.version)
+ }
+ for _, current := range deployments {
+ current.adapter.call(t, "start", map[string]any{"client_id": clientID(current), "max_workers": 4}, nil)
+ }
+ var ids []int64
+ insertBatch := func(step string) {
+ for index := range jobsPerStep {
+ inserter := deployments[index%len(deployments)].adapter
+ var job normalizedJob
+ inserter.call(t, "insert", map[string]any{
+ "behavior": "sleep", "duration_ms": 20, "message": fmt.Sprintf("rolling %s %d", step, index),
+ "opts": map[string]any{"tags": []string{"rolling_deployment"}},
+ }, &job)
+ ids = append(ids, job.ID)
+ }
+ }
+ insertBatch("initial")
+ for _, current := range deployments {
+ // Stop the old process gracefully, insert while it is gone, then
+ // bring up a new process of the same implementation.
+ current.adapter.call(t, "stop", map[string]any{}, nil)
+ insertBatch(fmt.Sprintf("without-%s-%d", current.kind.spec.Implementation, current.version))
+ current.version++
+ name := fmt.Sprintf("%s-rolling-%d", current.kind.spec.Implementation, current.version)
+ current.adapter = startDisposable(t, root, databaseURL, name, current.kind)
+ current.adapter.call(t, "start", map[string]any{"client_id": clientID(current), "max_workers": 4}, nil)
+ insertBatch(fmt.Sprintf("with-%s-%d", current.kind.spec.Implementation, current.version))
+ }
+
+ completed := waitForListedJobCountWithin(t, pair.reference, map[string]any{
+ "limit": len(ids), "states": []string{"completed"}, "tags_all": []string{"rolling_deployment"},
+ }, len(ids), 30*time.Second)
+ require.ElementsMatch(t, ids, jobIDs(completed))
+ workers := make(map[string]int)
+ for _, job := range completed {
+ require.Equal(t, 1, job.Attempt, "job %d ran more than once", job.ID)
+ require.Len(t, job.AttemptedBy, 1)
+ require.Empty(t, job.Errors)
+ workers[job.AttemptedBy[0]]++
+ }
+ t.Logf("rolling deployment work split: %v", workers)
+ for _, current := range deployments {
+ require.Positive(t, workers[clientID(current)], "%s did no work after its replacement", clientID(current))
+ }
+ leader := waitForLeader(t, pair.reference, "")
+ require.Contains(t, []string{clientID(deployments[0]), clientID(deployments[1])}, leader,
+ "leadership must end with a replacement process")
+ for _, current := range deployments {
+ current.adapter.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+// verifyClockBoundaries checks scheduling boundaries across implementations:
+// a job scheduled in the future is never attempted before its time, and a
+// snooze no longer than the scheduler interval leaves the job available
+// with a future scheduled_at that the other implementation's fetch honors.
+func verifyClockBoundaries(t *testing.T, inserter, worker *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{"client_id": worker.name + "-clock-boundary", "max_workers": 2}, nil)
+
+ scheduledAt := time.Now().Add(time.Second).UTC()
+ var scheduled normalizedJob
+ inserter.call(t, "insert", map[string]any{
+ "message": "scheduled in the future",
+ "opts": map[string]any{"scheduled_at": scheduledAt.Format(time.RFC3339Nano)},
+ }, &scheduled)
+ require.Equal(t, "scheduled", scheduled.State)
+ scheduled = waitForJobStateWithin(t, worker, scheduled.ID, []string{"completed"}, maintenanceWait)
+ require.NotNil(t, scheduled.AttemptedAt)
+ require.False(t, parseTime(t, *scheduled.AttemptedAt).Before(parseTime(t, scheduled.ScheduledAt)),
+ "attempted at %s before scheduled at %s", *scheduled.AttemptedAt, scheduled.ScheduledAt)
+
+ // A two-second snooze is inside both implementations' default
+ // five-second scheduler interval, so the snoozed job stays available
+ // with a future scheduled_at.
+ const snooze = 2 * time.Second
+ var snoozed normalizedJob
+ inserter.call(t, "insert", map[string]any{
+ "behavior": "snooze_once", "duration_ms": snooze.Milliseconds(), "message": "short snooze boundary",
+ }, &snoozed)
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ inserter.call(t, "get", map[string]any{"id": snoozed.ID}, &snoozed)
+ if snoozed.Metadata["snoozes"] != nil {
+ break
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ require.EqualValues(t, 1, snoozed.Metadata["snoozes"])
+ if snoozed.State != "completed" && snoozed.State != "running" {
+ require.Equal(t, "available", snoozed.State, "a snooze within the scheduler interval stays available")
+ }
+ snoozedUntil := snoozed.ScheduledAt
+ snoozed = waitForJobStateWithin(t, worker, snoozed.ID, []string{"completed"}, 15*time.Second)
+ require.NotNil(t, snoozed.AttemptedAt)
+ require.False(t, parseTime(t, *snoozed.AttemptedAt).Before(parseTime(t, snoozedUntil)),
+ "snoozed job attempted at %s before its snooze ended at %s", *snoozed.AttemptedAt, snoozedUntil)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyDefaultRetrySchedule runs failing jobs under an implementation's
+// production default retry policy. The first retry delay (about one second)
+// is inside the scheduler interval, so the job stays available and is
+// retried at its scheduled time; the second (about sixteen seconds) is not,
+// so the job waits as retryable. Both delays must fall within the bounds
+// generated from River's Go retry policy.
+func verifyDefaultRetrySchedule(t *testing.T, repositoryRoot string, worker, observer *adapter) {
+ t.Helper()
+
+ var fixture struct {
+ RetryCases []struct {
+ ErrorCount int `json:"error_count"`
+ MaxDelayNS int64 `json:"max_delay_ns"`
+ MinDelayNS int64 `json:"min_delay_ns"`
+ } `json:"retry_cases"`
+ }
+ contents, err := os.ReadFile(filepath.Join(repositoryRoot, "conformance/fixtures/protocol_values.json"))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(contents, &fixture))
+ bounds := func(errorCount int) (time.Duration, time.Duration) {
+ for _, retryCase := range fixture.RetryCases {
+ if retryCase.ErrorCount == errorCount {
+ return time.Duration(retryCase.MinDelayNS), time.Duration(retryCase.MaxDelayNS)
+ }
+ }
+ t.Fatalf("no retry bounds for error count %d", errorCount)
+ return 0, 0
+ }
+ // Timestamps come from the worker's clock and the database; allow for
+ // the time between recording the error and scheduling the retry.
+ const slack = 250 * time.Millisecond
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{"client_id": worker.name + "-default-retry", "max_workers": 1}, nil)
+ var job normalizedJob
+ observer.call(t, "insert", map[string]any{
+ "behavior": "error", "message": "default retry policy", "opts": map[string]any{"max_attempts": 5},
+ }, &job)
+ job = waitForJobStateWithin(t, observer, job.ID, []string{"retryable"}, 15*time.Second)
+ require.Equal(t, 2, job.Attempt)
+ require.Len(t, job.Errors, 2)
+ firstMin, firstMax := bounds(1)
+ require.NotNil(t, job.AttemptedAt)
+ firstDelay := parseTime(t, *job.AttemptedAt).Sub(parseTime(t, job.Errors[0].At))
+ require.GreaterOrEqual(t, firstDelay, firstMin-slack, "second attempt started before the first retry delay")
+ require.Less(t, firstDelay, firstMax+5*time.Second, "second attempt started long after the first retry delay")
+ secondMin, secondMax := bounds(2)
+ secondDelay := parseTime(t, job.ScheduledAt).Sub(parseTime(t, job.Errors[1].At))
+ require.GreaterOrEqual(t, secondDelay, secondMin-slack)
+ require.LessOrEqual(t, secondDelay, secondMax+slack)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyStuckJobDetection runs a worker that ignores its timeout's
+// cancellation in a disposable process and requires the runtime to report
+// the job stuck once the timeout and stuck threshold pass. What happens to
+// the stuck attempt afterwards is implementation-specific (Go cannot stop a
+// goroutine; other runtimes may abort the task), so only the detection is
+// asserted. The process is killed afterwards because Go's worker never
+// returns.
+func verifyStuckJobDetection(t *testing.T, root, databaseURL string, kind *adapter) {
+ t.Helper()
+
+ name := kind.spec.Implementation + "-stuck-detection"
+ stuck := startDisposable(t, root, databaseURL, name, kind)
+ stuck.call(t, "reset", map[string]any{}, nil)
+ stuck.call(t, "start", map[string]any{
+ "client_id": name, "job_stuck_threshold_ms": 100, "job_timeout_ms": 50, "max_workers": 1,
+ }, nil)
+ var job normalizedJob
+ stuck.call(t, "insert", map[string]any{"behavior": "ignored_cancel", "message": "stuck detection"}, &job)
+ job = waitForJobStateWithin(t, stuck, job.ID, []string{"running"}, 10*time.Second)
+ stats := waitForRuntimeStats(t, stuck, func(stats runtimeStats) bool { return stats.StuckJobs > 0 })
+ require.Equal(t, 1, stats.StuckJobs)
+ require.NotNil(t, job.AttemptedAt)
+ stuck.kill(t)
+}
+
+// verifyPoolPressure runs far more workers than either implementation's
+// database pool holds and requires every job to complete exactly once while
+// each adapter's connection count stays bounded throughout.
+func verifyPoolPressure(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ const (
+ jobCount = 600
+ maxConnections = 20
+ )
+ adapters := []*adapter{goAdapter, candidateAdapter}
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ for _, current := range adapters {
+ current.call(t, "start", map[string]any{"client_id": current.name + "-pool-pressure", "max_workers": 100}, nil)
+ }
+ for _, inserter := range adapters {
+ jobs := make([]map[string]any, jobCount/2)
+ for index := range jobs {
+ jobs[index] = map[string]any{
+ "behavior": "sleep", "duration_ms": 10, "message": fmt.Sprintf("pool pressure %d", index),
+ "opts": map[string]any{"tags": []string{"pool_pressure"}},
+ }
+ }
+ inserter.call(t, "insert_many", map[string]any{"jobs": jobs}, nil)
+ }
+ deadline := time.Now().Add(60 * time.Second)
+ peak := make(map[string]int)
+ var completed []normalizedJob
+ for time.Now().Before(deadline) {
+ for _, current := range adapters {
+ var connections struct {
+ Count int `json:"count"`
+ }
+ current.call(t, "connection_count", map[string]any{}, &connections)
+ peak[current.name] = max(peak[current.name], connections.Count)
+ require.LessOrEqual(t, connections.Count, maxConnections, "%s connections grew under pool pressure", current.name)
+ }
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ goAdapter.call(t, "list", map[string]any{
+ "limit": jobCount, "states": []string{"completed"}, "tags_all": []string{"pool_pressure"},
+ }, &listed)
+ if completed = listed.Jobs; len(completed) == jobCount {
+ break
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ require.Len(t, completed, jobCount)
+ for _, job := range completed {
+ require.Equal(t, 1, job.Attempt)
+ require.Empty(t, job.Errors)
+ }
+ t.Logf("peak connections under pool pressure: %v", peak)
+ for _, current := range adapters {
+ current.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+// verifyReservedMetadata has one implementation write each runtime-owned
+// reserved metadata key and the other read it back with its canonical name
+// and type. Every key an implementation writes must be in the reserved set
+// generated from Go, and user metadata must survive alongside it.
+func verifyReservedMetadata(t *testing.T, repositoryRoot string, worker, controller *adapter) {
+ t.Helper()
+
+ var fixture struct {
+ ReservedMetadataKeys []struct {
+ Key string `json:"key"`
+ } `json:"reserved_metadata_keys"`
+ }
+ contents, err := os.ReadFile(filepath.Join(repositoryRoot, "conformance/fixtures/protocol_values.json"))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(contents, &fixture))
+ reserved := make([]string, 0, len(fixture.ReservedMetadataKeys))
+ for _, key := range fixture.ReservedMetadataKeys {
+ reserved = append(reserved, key.Key)
+ }
+ require.NotEmpty(t, reserved)
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{"client_id": worker.name + "-reserved-metadata", "max_workers": 2}, nil)
+ // `river:log` stands in for metadata written by an extension of one
+ // implementation, such as Go's log middleware, which the other must
+ // carry through unchanged.
+ riverLog := []any{map[string]any{"attempt": float64(1), "log": "logged by an earlier attempt"}}
+ userMetadata := map[string]any{"river:log": riverLog, "user": "kept"}
+ var output, snoozed, cancelled normalizedJob
+ controller.call(t, "insert", map[string]any{
+ "behavior": "output", "message": "reserved output", "opts": map[string]any{"metadata": userMetadata},
+ }, &output)
+ controller.call(t, "insert", map[string]any{
+ "behavior": "snooze_once", "duration_ms": 5, "message": "reserved snooze", "opts": map[string]any{"metadata": userMetadata},
+ }, &snoozed)
+ controller.call(t, "insert", map[string]any{
+ "behavior": "cooperative_cancel", "message": "reserved cancel", "opts": map[string]any{"metadata": userMetadata},
+ }, &cancelled)
+ controller.call(t, "wait", map[string]any{"id": cancelled.ID, "states": []string{"running"}}, &cancelled)
+ controller.call(t, "cancel", map[string]any{"id": cancelled.ID}, &cancelled)
+ for _, job := range []*normalizedJob{&output, &snoozed, &cancelled} {
+ controller.call(t, "wait", map[string]any{"id": job.ID}, job)
+ require.Equal(t, "kept", job.Metadata["user"], "user metadata lost on job %d", job.ID)
+ require.Equal(t, riverLog, job.Metadata["river:log"], "river:log changed on job %d", job.ID)
+ for key := range job.Metadata {
+ if key != "user" {
+ require.Contains(t, reserved, key, "job %d carries metadata key %q outside the reserved set", job.ID, key)
+ }
+ }
+ }
+ require.Equal(t, "completed", output.State)
+ require.Equal(t, map[string]any{"message": "reserved output"}, output.Metadata["output"])
+ require.Equal(t, "completed", snoozed.State)
+ require.EqualValues(t, 1, snoozed.Metadata["snoozes"])
+ require.Equal(t, "cancelled", cancelled.State)
+ cancelAttemptedAt, ok := cancelled.Metadata["cancel_attempted_at"].(string)
+ require.True(t, ok, "cancel_attempted_at must be a timestamp string")
+ require.False(t, strings.HasSuffix(cancelAttemptedAt, " "))
+ parseTime(t, cancelAttemptedAt)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
diff --git a/conformance/harness/main_test.go b/conformance/harness/main_test.go
new file mode 100644
index 000000000..627a8b3f5
--- /dev/null
+++ b/conformance/harness/main_test.go
@@ -0,0 +1,62 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// referenceBuild holds the Go reference adapter binary, built once per test
+// process. Running the binary directly rather than through `go run` lets
+// chaos scenarios kill the adapter process itself instead of the go tool.
+var referenceBuild struct { //nolint:gochecknoglobals // one build per process
+ directory string
+ err error
+ once sync.Once
+}
+
+func TestMain(m *testing.M) {
+ code := m.Run()
+ if referenceBuild.directory != "" {
+ _ = os.RemoveAll(referenceBuild.directory)
+ }
+ // `go test -run` exits successfully when a pattern matches nothing. A
+ // required CI run must execute at least one conformance test.
+ if code == 0 && conformanceRequired() && conformanceTestsStarted.Load() == 0 {
+ fmt.Fprintln(os.Stderr, "RIVER_CONFORMANCE_REQUIRED=1: no conformance test ran; check the -run pattern")
+ code = 1
+ }
+ os.Exit(code)
+}
+
+// referenceAdapterCommand returns the command that starts the Go reference
+// adapter, building it on first use.
+func referenceAdapterCommand(t *testing.T, root string) []string {
+ t.Helper()
+
+ referenceBuild.once.Do(func() {
+ // The binary outlives any single test, so TestMain removes it.
+ directory, err := os.MkdirTemp("", "river-conformance-reference-") //nolint:usetesting // shared by every test in the process
+ if err != nil {
+ referenceBuild.err = err
+ return
+ }
+ referenceBuild.directory = directory
+ //nolint:gosec // Fixed arguments; only the temporary output path varies.
+ command := exec.CommandContext(context.Background(), "go", "build", "-o", filepath.Join(directory, "riverconformanceadapter"), "./internal/cmd/riverconformanceadapter")
+ command.Dir = root
+ if output, err := command.CombinedOutput(); err != nil {
+ referenceBuild.err = fmt.Errorf("build Go reference adapter: %w\n%s", err, output)
+ }
+ })
+ require.NoError(t, referenceBuild.err)
+ return []string{filepath.Join(referenceBuild.directory, "riverconformanceadapter")}
+}
diff --git a/conformance/harness/maintenance_test.go b/conformance/harness/maintenance_test.go
new file mode 100644
index 000000000..06aaa4df8
--- /dev/null
+++ b/conformance/harness/maintenance_test.go
@@ -0,0 +1,668 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "maps"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/stretchr/testify/require"
+)
+
+// maintenanceImplementation is one engine whose leader-owned maintenance is
+// checked. Every scenario runs against the Go reference first, which
+// validates the scenario itself, and then against the candidate.
+type maintenanceImplementation struct {
+ adapter *adapter
+ name string
+}
+
+// maintenanceTuning shortens intervals River Go doesn't expose. Only
+// implementations whose descriptor lists an option receive it; the others,
+// like River Go, run each service as soon as they gain leadership.
+func maintenanceTuning() map[string]any {
+ return map[string]any{
+ "elect_interval_ms": 50,
+ "rescuer_interval_ms": 50,
+ "scheduler_interval_ms": 50,
+ }
+}
+
+func startParams(schema, clientID string, extra map[string]any) map[string]any {
+ params := map[string]any{
+ "client_id": clientID,
+ "job_cleaner_interval_ms": 50,
+ "max_workers": 1,
+ "queue_cleaner_interval_ms": 50,
+ "schema": schema,
+ }
+ maps.Copy(params, extra)
+ return params
+}
+
+// maintenanceHarness provides direct database access for arranging rows and
+// observing server state that no adapter method exposes, such as lock waits.
+type maintenanceHarness struct {
+ pool *pgxpool.Pool
+ t *testing.T
+}
+
+// schema creates and migrates a fresh schema through the Go reference
+// migrator and drops it when the test finishes.
+func (harness *maintenanceHarness) schema(migrator *adapter, name string) string {
+ harness.t.Helper()
+
+ schema := fmt.Sprintf("%s_%x", name, time.Now().UnixNano()&0xffffff)
+ migrator.call(harness.t, "migrate", map[string]any{"schema": schema}, nil)
+ harness.t.Cleanup(func() {
+ _, err := harness.pool.Exec(context.Background(), "DROP SCHEMA IF EXISTS "+pgx.Identifier{schema}.Sanitize()+" CASCADE")
+ require.NoError(harness.t, err)
+ })
+ return schema
+}
+
+func (harness *maintenanceHarness) exec(sql string) {
+ harness.t.Helper()
+
+ _, err := harness.pool.Exec(context.Background(), sql)
+ require.NoError(harness.t, err)
+}
+
+func (harness *maintenanceHarness) queryInt(sql string, args ...any) int64 {
+ harness.t.Helper()
+
+ var value int64
+ require.NoError(harness.t, harness.pool.QueryRow(context.Background(), sql, args...).Scan(&value))
+ return value
+}
+
+func (harness *maintenanceHarness) waitFor(description string, timeout time.Duration, condition func() bool) {
+ harness.t.Helper()
+
+ deadline := time.Now().Add(timeout)
+ for !condition() {
+ require.True(harness.t, time.Now().Before(deadline), "timed out waiting for %s", description)
+ time.Sleep(20 * time.Millisecond)
+ }
+}
+
+// lockWaiters counts an implementation's statements blocked on a lock.
+func (harness *maintenanceHarness) lockWaiters(applicationName string) int64 {
+ harness.t.Helper()
+
+ return harness.queryInt(`
+ SELECT count(*) FROM pg_stat_activity
+ WHERE datname = current_database() AND application_name = $1
+ AND state = 'active' AND wait_event_type = 'Lock'`, applicationName)
+}
+
+// conformanceArgsJSON is a complete `conformance_echo` argument object, so
+// every implementation's worker can decode rows the harness inserts.
+const conformanceArgsJSON = `{"behavior":"","duration_ms":0,"message":"maintenance"}`
+
+func table(schema, name string) string {
+ return pgx.Identifier{schema, name}.Sanitize()
+}
+
+func TestMaintenanceConformance(t *testing.T) { //nolint:paralleltest // Owns the shared PostgreSQL database.
+ databaseURL := requireEnv(t, "RIVER_CONFORMANCE_DATABASE_URL")
+ scenarios := newScenarioTracker(t, scenarioOwnerMaintenance)
+ repositoryRoot := repoRoot(t)
+ goAdapter := startReferenceAdapter(t, repositoryRoot, databaseURL, "go")
+ candidateSpec := conformanceCandidateSpec(t, repositoryRoot, false)
+ candidateAdapter := startCandidateAdapter(t, repositoryRoot, databaseURL, candidateSpec.Implementation, candidateSpec, candidateSpec.Command)
+ scenarios.attach(goAdapter, candidateAdapter)
+ implementations := []maintenanceImplementation{
+ {adapter: goAdapter, name: "go"},
+ {adapter: candidateAdapter, name: candidateSpec.Implementation},
+ }
+
+ pool, err := pgxpool.New(context.Background(), databaseURL)
+ require.NoError(t, err)
+ t.Cleanup(pool.Close)
+ harness := &maintenanceHarness{pool: pool, t: t}
+ goAdapter.call(t, "migrate", map[string]any{}, nil)
+
+ t.Run("cron_schedule_goldens", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ verifyCronScheduleGoldens(t, repositoryRoot, goAdapter, candidateAdapter)
+ })
+
+ t.Run("queue_names_and_unknown_queue_control", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ // River Go doesn't validate names passed to queue control: a name
+ // that could never be a valid queue simply has no record, so control
+ // reports not found rather than a validation error.
+ missingNames := []string{
+ "maintenance_missing_queue",
+ "maintenance missing queue",
+ strings.Repeat("q", 129),
+ }
+ for _, implementation := range implementations {
+ for _, name := range missingNames {
+ implementation.adapter.requireCallError(t, "queue_pause", map[string]any{"name": name}, "not_found")
+ implementation.adapter.requireCallError(t, "queue_resume", map[string]any{"name": name}, "not_found")
+ implementation.adapter.requireCallError(t, "queue_update", map[string]any{
+ "metadata": map[string]any{"owner": "conformance"}, "name": name,
+ }, "not_found")
+ }
+ implementation.adapter.call(t, "queue_pause", map[string]any{"name": "*"}, nil)
+ implementation.adapter.call(t, "queue_resume", map[string]any{"name": "*"}, nil)
+
+ var inserted normalizedJob
+ implementation.adapter.call(t, "insert", map[string]any{
+ "message": "pipe queue", "opts": map[string]any{"queue": "tenant|emails"},
+ }, &inserted)
+ require.Equal(t, "tenant|emails", inserted.Queue, implementation.name)
+ implementation.adapter.call(t, "delete", map[string]any{"id": inserted.ID}, nil)
+ }
+ })
+
+ t.Run("migration_mixed_case_schema", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ // Go quotes the schema; a migrated mixed-case schema must be seen as
+ // migrated rather than folded to lowercase.
+ schema := harness.schema(goAdapter, "MaintMixedCase")
+ var result struct {
+ Existing []int `json:"existing"`
+ Versions []int `json:"versions"`
+ }
+ candidateAdapter.call(t, "migrate", map[string]any{"schema": schema}, &result)
+ require.Empty(t, result.Versions)
+ require.NotEmpty(t, result.Existing)
+ })
+
+ t.Run("maintenance_job_cleaner_retention", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ for _, implementation := range implementations {
+ verifyJobCleanerRetention(t, harness, goAdapter, implementation)
+ }
+ })
+
+ t.Run("maintenance_queue_cleaner_keeps_active_queues", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ for _, implementation := range implementations {
+ verifyQueueCleaner(t, harness, goAdapter, implementation)
+ }
+ })
+
+ t.Run("maintenance_reindexer_skips_artifacts", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ for _, implementation := range implementations {
+ verifyReindexer(t, harness, goAdapter, implementation)
+ }
+ })
+
+ t.Run("maintenance_rescuer_full_batch_of_unexpired_jobs", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ for _, implementation := range implementations {
+ verifyRescuerFullBatch(t, harness, goAdapter, implementation)
+ }
+ })
+
+ t.Run("maintenance_rescuer_stale_selection", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ for _, implementation := range implementations {
+ verifyRescuerStaleSelection(t, harness, goAdapter, implementation)
+ }
+ })
+
+ t.Run("leadership_same_client_id_term_replacement", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ for _, implementation := range implementations {
+ verifySameClientIDTermReplacement(t, harness, goAdapter, implementation)
+ }
+ })
+
+ t.Run("leadership_renewal_under_slow_maintenance", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ for _, implementation := range implementations {
+ verifyRenewalUnderSlowMaintenance(t, harness, goAdapter, implementation)
+ }
+ })
+
+ t.Run("periodic_due_job_available", func(t *testing.T) { //nolint:paralleltest // Shares adapters.
+ defer scenarios.record(t)
+
+ for _, implementation := range implementations {
+ verifyPeriodicDueJobAvailable(t, harness, goAdapter, implementation)
+ }
+ })
+}
+
+func verifyCronScheduleGoldens(t *testing.T, repositoryRoot string, adapters ...*adapter) {
+ t.Helper()
+
+ var fixture struct {
+ CronCases []struct {
+ Expression string `json:"expression"`
+ From time.Time `json:"from"`
+ Name string `json:"name"`
+ Next []time.Time `json:"next"`
+ } `json:"cron_cases"`
+ CronInvalid []string `json:"cron_invalid"`
+ CronNamedZoneCases []struct {
+ Expression string `json:"expression"`
+ From time.Time `json:"from"`
+ Name string `json:"name"`
+ Next []time.Time `json:"next"`
+ } `json:"cron_named_zone_cases"`
+ }
+ contents, err := os.ReadFile(filepath.Join(repositoryRoot, "conformance/fixtures/maintenance_values.json"))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(contents, &fixture))
+ require.NotEmpty(t, fixture.CronCases)
+ require.NotEmpty(t, fixture.CronNamedZoneCases)
+
+ for _, testCase := range fixture.CronCases {
+ for _, adapter := range adapters {
+ var result struct {
+ Next []time.Time `json:"next"`
+ }
+ adapter.call(t, "cron_next", map[string]any{
+ "count": 5,
+ "expression": testCase.Expression,
+ "from": testCase.From.Format(time.RFC3339Nano),
+ }, &result)
+ require.Len(t, result.Next, len(testCase.Next), "%s adapter case %s", adapter.name, testCase.Name)
+ for index, expected := range testCase.Next {
+ actual := result.Next[index]
+ require.True(t, expected.Equal(actual), "%s adapter case %s occurrence %d: %s != %s",
+ adapter.name, testCase.Name, index, actual, expected)
+ _, expectedOffset := expected.Zone()
+ _, actualOffset := actual.Zone()
+ require.Equal(t, expectedOffset, actualOffset, "%s adapter case %s offset", adapter.name, testCase.Name)
+ }
+ }
+ }
+ // Named `CRON_TZ=` zones, including across daylight saving transitions,
+ // must yield the same instants as Go. The fixture records them in UTC,
+ // and implementations may render them in the schedule's zone.
+ for _, testCase := range fixture.CronNamedZoneCases {
+ for _, adapter := range adapters {
+ var result struct {
+ Next []time.Time `json:"next"`
+ }
+ adapter.call(t, "cron_next", map[string]any{
+ "count": len(testCase.Next),
+ "expression": testCase.Expression,
+ "from": testCase.From.Format(time.RFC3339Nano),
+ }, &result)
+ require.Len(t, result.Next, len(testCase.Next), "%s adapter case %s", adapter.name, testCase.Name)
+ for index, expected := range testCase.Next {
+ require.True(t, expected.Equal(result.Next[index]), "%s adapter case %s occurrence %d: %s != %s",
+ adapter.name, testCase.Name, index, result.Next[index], expected)
+ }
+ }
+ }
+ for _, expression := range fixture.CronInvalid {
+ for _, adapter := range adapters {
+ adapter.requireCallError(t, "cron_next", map[string]any{
+ "count": 1, "expression": expression, "from": "2026-01-02T03:04:05Z",
+ }, "rejected")
+ }
+ }
+}
+
+func insertRawJob(harness *maintenanceHarness, schema, kind, state string, attemptedAgo, finalizedAgo *time.Duration) int64 {
+ harness.t.Helper()
+
+ var attemptedAt, finalizedAt *time.Time
+ if attemptedAgo != nil {
+ value := time.Now().Add(-*attemptedAgo)
+ attemptedAt = &value
+ }
+ if finalizedAgo != nil {
+ value := time.Now().Add(-*finalizedAgo)
+ finalizedAt = &value
+ }
+ attempt := 0
+ if state == "running" {
+ attempt = 1
+ }
+ return harness.queryInt(fmt.Sprintf(`
+ INSERT INTO %s (args, attempt, attempted_at, attempted_by, finalized_at, kind, max_attempts, state)
+ VALUES ('`+conformanceArgsJSON+`', $1, $2, CASE WHEN $2::timestamptz IS NULL THEN NULL ELSE ARRAY['dead-client'] END, $3, $4, 25, $5::text::%s)
+ RETURNING id`, table(schema, "river_job"), pgx.Identifier{schema, "river_job_state"}.Sanitize()),
+ attempt, attemptedAt, finalizedAt, kind, state)
+}
+
+func jobExists(harness *maintenanceHarness, schema string, id int64) bool {
+ harness.t.Helper()
+
+ return harness.queryInt("SELECT count(*) FROM "+table(schema, "river_job")+" WHERE id = $1", id) == 1
+}
+
+func verifyJobCleanerRetention(t *testing.T, harness *maintenanceHarness, migrator *adapter, implementation maintenanceImplementation) {
+ t.Helper()
+
+ schema := harness.schema(migrator, "maint_job_cleaner")
+ expiredCancelled := insertRawJob(harness, schema, "conformance_echo", "cancelled", nil, new(2*time.Hour))
+ expiredCompleted := insertRawJob(harness, schema, "conformance_echo", "completed", nil, new(2*time.Hour))
+ expiredDiscarded := insertRawJob(harness, schema, "conformance_echo", "discarded", nil, new(2*time.Hour))
+ recentCancelled := insertRawJob(harness, schema, "conformance_echo", "cancelled", nil, new(time.Minute))
+ running := insertRawJob(harness, schema, "conformance_echo", "running", new(time.Second), nil)
+
+ // Completed jobs are retained forever (-1); the other finalized states
+ // expire after one hour.
+ implementation.adapter.startWithTuning(t, startParams(schema, implementation.name+"-job-cleaner", map[string]any{
+ "cancelled_job_retention_ms": 3_600_000,
+ "completed_job_retention_ms": -1,
+ "discarded_job_retention_ms": 3_600_000,
+ "queue": "maintenance_idle",
+ }), maintenanceTuning())
+ // Both expired rows are removed by one cleaner statement, so observing
+ // their deletion proves a complete pass ran.
+ harness.waitFor(implementation.name+" job cleaner", 30*time.Second, func() bool {
+ return !jobExists(harness, schema, expiredCancelled) && !jobExists(harness, schema, expiredDiscarded)
+ })
+ implementation.adapter.call(t, "stop", map[string]any{}, nil)
+
+ require.True(t, jobExists(harness, schema, expiredCompleted), "%s deleted a retained state", implementation.name)
+ require.True(t, jobExists(harness, schema, recentCancelled), "%s deleted a job before its retention", implementation.name)
+ require.True(t, jobExists(harness, schema, running), "%s deleted a running job", implementation.name)
+}
+
+func verifyQueueCleaner(t *testing.T, harness *maintenanceHarness, migrator *adapter, implementation maintenanceImplementation) {
+ t.Helper()
+
+ schema := harness.schema(migrator, "maint_queue_cleaner")
+ queues := table(schema, "river_queue")
+ harness.exec("INSERT INTO " + queues + " (name, created_at, metadata, updated_at) VALUES ('stale', now(), '{}', now() - interval '25 hours')")
+ harness.exec("INSERT INTO " + queues + " (name, created_at, metadata, updated_at) VALUES ('recent', now(), '{}', now() - interval '1 hour')")
+
+ implementation.adapter.startWithTuning(t, startParams(schema, implementation.name+"-queue-cleaner", map[string]any{
+ "queue": "maintenance_active",
+ }), maintenanceTuning())
+ queueExists := func(name string) bool {
+ return harness.queryInt("SELECT count(*) FROM "+queues+" WHERE name = $1", name) == 1
+ }
+ harness.waitFor(implementation.name+" queue cleaner", 30*time.Second, func() bool {
+ return !queueExists("stale") && queueExists("maintenance_active")
+ })
+ implementation.adapter.call(t, "stop", map[string]any{}, nil)
+
+ require.True(t, queueExists("recent"), "%s deleted a queue within retention", implementation.name)
+ require.True(t, queueExists("maintenance_active"), "%s deleted its active queue", implementation.name)
+}
+
+func indexFilenode(harness *maintenanceHarness, schema, index string) int64 {
+ harness.t.Helper()
+
+ return harness.queryInt(`
+ SELECT c.relfilenode::bigint FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = $1 AND c.relname = $2`, schema, index)
+}
+
+func verifyReindexer(t *testing.T, harness *maintenanceHarness, migrator *adapter, implementation maintenanceImplementation) {
+ t.Helper()
+
+ schema := harness.schema(migrator, "maint_reindexer")
+ jobs := table(schema, "river_job")
+ harness.exec("CREATE INDEX maint_artifact_idx ON " + jobs + " (kind)")
+ harness.exec("CREATE INDEX maint_artifact_idx_ccnew1 ON " + jobs + " (kind)")
+ harness.exec("CREATE INDEX maint_rebuilt_idx ON " + jobs + " (kind)")
+ artifactFilenode := indexFilenode(harness, schema, "maint_artifact_idx")
+ rebuiltFilenode := indexFilenode(harness, schema, "maint_rebuilt_idx")
+
+ // Indexes are processed in order, so once the last one is rebuilt the
+ // missing index and the one with a leftover artifact were already skipped.
+ implementation.adapter.startWithTuning(t, startParams(schema, implementation.name+"-reindexer", map[string]any{
+ "queue": "maintenance_idle",
+ "reindexer_index_names": []string{"maint_missing_idx", "maint_artifact_idx", "maint_rebuilt_idx"},
+ "reindexer_interval_ms": 200,
+ }), maintenanceTuning())
+ harness.waitFor(implementation.name+" reindex", 30*time.Second, func() bool {
+ return indexFilenode(harness, schema, "maint_rebuilt_idx") != rebuiltFilenode
+ })
+ implementation.adapter.call(t, "stop", map[string]any{}, nil)
+
+ require.Equal(t, artifactFilenode, indexFilenode(harness, schema, "maint_artifact_idx"),
+ "%s rebuilt an index with a leftover concurrent artifact", implementation.name)
+ require.Positive(t, indexFilenode(harness, schema, "maint_artifact_idx_ccnew1"))
+}
+
+func verifyRescuerFullBatch(t *testing.T, harness *maintenanceHarness, migrator *adapter, implementation maintenanceImplementation) {
+ t.Helper()
+
+ schema := harness.schema(migrator, "maint_rescue_batch")
+ jobs := table(schema, "river_job")
+ // A full default batch (10,000) of stuck jobs whose timeout is disabled
+ // precedes one eligible job. Without paging past the ignored batch, a
+ // rescuer re-selects the same rows forever.
+ harness.exec(fmt.Sprintf(`
+ INSERT INTO %s (args, attempt, attempted_at, attempted_by, kind, max_attempts, state)
+ SELECT '`+conformanceArgsJSON+`', 1, now() - interval '2 hours', ARRAY['dead-client'], 'conformance_echo', 25, 'running'
+ FROM generate_series(1, 10000)`, jobs))
+ eligible := insertRawJob(harness, schema, "maintenance_unregistered_kind", "running", new(2*time.Hour), nil)
+
+ implementation.adapter.startWithTuning(t, startParams(schema, implementation.name+"-rescue-batch", map[string]any{
+ "job_timeout_disabled": true,
+ "queue": "maintenance_idle",
+ "rescue_after_ms": 60_000,
+ }), maintenanceTuning())
+ harness.waitFor(implementation.name+" rescue past a full batch", 60*time.Second, func() bool {
+ return harness.queryInt("SELECT count(*) FROM "+jobs+" WHERE id = $1 AND state = 'discarded'", eligible) == 1
+ })
+ implementation.adapter.call(t, "stop", map[string]any{}, nil)
+
+ require.Equal(t, int64(10_000), harness.queryInt(
+ "SELECT count(*) FROM "+jobs+" WHERE kind = 'conformance_echo' AND state = 'running' AND errors IS NULL"),
+ "%s rescued jobs whose timeout is disabled", implementation.name)
+}
+
+func verifyRescuerStaleSelection(t *testing.T, harness *maintenanceHarness, migrator *adapter, implementation maintenanceImplementation) {
+ t.Helper()
+
+ ctx := context.Background()
+ schema := harness.schema(migrator, "maint_rescue_stale")
+ jobs := table(schema, "river_job")
+ completed := insertRawJob(harness, schema, "maintenance_unregistered_kind", "running", new(2*time.Hour), nil)
+ reclaimed := insertRawJob(harness, schema, "maintenance_unregistered_kind", "running", new(2*time.Hour), nil)
+ eligible := insertRawJob(harness, schema, "maintenance_unregistered_kind", "running", new(2*time.Hour), nil)
+
+ // Hold two stuck rows so the rescuer's update waits on them after it has
+ // already selected them.
+ tx, err := harness.pool.Begin(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback(ctx) }()
+ _, err = tx.Exec(ctx, "SELECT id FROM "+jobs+" WHERE id = ANY($1) FOR UPDATE", []int64{completed, reclaimed})
+ require.NoError(t, err)
+
+ implementation.adapter.startWithTuning(t, startParams(schema, implementation.name+"-rescue-stale", map[string]any{
+ "queue": "maintenance_idle",
+ "rescue_after_ms": 60_000,
+ }), maintenanceTuning())
+ // Implementations that select without row locks (like Go) block here
+ // until the harness commits; ones that skip locked rows rescue the
+ // eligible job first. Either way the held jobs must end up untouched.
+ eligibleRescued := func() bool {
+ return harness.queryInt("SELECT count(*) FROM "+jobs+" WHERE id = $1 AND state = 'discarded'", eligible) == 1
+ }
+ harness.waitFor(implementation.name+" rescue pass reaching the held rows", 30*time.Second, func() bool {
+ return harness.lockWaiters(implementation.adapter.applicationName) > 0 || eligibleRescued()
+ })
+
+ // A worker finishes one job and another client re-claims the other before
+ // the stale rescue proceeds.
+ _, err = tx.Exec(ctx, "UPDATE "+jobs+" SET state = 'completed', finalized_at = now() WHERE id = $1", completed)
+ require.NoError(t, err)
+ _, err = tx.Exec(ctx, "UPDATE "+jobs+" SET attempt = attempt + 1, attempted_at = now() WHERE id = $1", reclaimed)
+ require.NoError(t, err)
+ require.NoError(t, tx.Commit(ctx))
+
+ harness.waitFor(implementation.name+" rescue of the eligible job", 30*time.Second, eligibleRescued)
+ implementation.adapter.call(t, "stop", map[string]any{}, nil)
+
+ require.Equal(t, int64(1), harness.queryInt(`
+ SELECT count(*) FROM `+jobs+` WHERE id = $1 AND state = 'completed' AND errors IS NULL
+ AND NOT metadata ? 'river:rescue_count'`, completed), "%s rescued a completed job", implementation.name)
+ require.Equal(t, int64(1), harness.queryInt(`
+ SELECT count(*) FROM `+jobs+` WHERE id = $1 AND state = 'running' AND attempt = 2 AND errors IS NULL`,
+ reclaimed), "%s rescued a re-claimed job", implementation.name)
+}
+
+type leaseRow struct {
+ electedAt time.Time
+ expiresAt time.Time
+ leaderID string
+}
+
+func readLease(harness *maintenanceHarness, schema string) (leaseRow, bool) {
+ harness.t.Helper()
+
+ var lease leaseRow
+ err := harness.pool.QueryRow(context.Background(),
+ "SELECT elected_at, expires_at, leader_id FROM "+table(schema, "river_leader")).
+ Scan(&lease.electedAt, &lease.expiresAt, &lease.leaderID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return leaseRow{}, false
+ }
+ require.NoError(harness.t, err)
+ return lease, true
+}
+
+func verifySameClientIDTermReplacement(t *testing.T, harness *maintenanceHarness, migrator *adapter, implementation maintenanceImplementation) {
+ t.Helper()
+
+ schema := harness.schema(migrator, "maint_term_replace")
+ clientID := implementation.name + "-shared-identity"
+ periodicCount := func() int64 {
+ return harness.queryInt("SELECT count(*) FROM " + table(schema, "river_job") +
+ " WHERE metadata ->> 'river:periodic_job_id' = 'conformance-periodic'")
+ }
+ implementation.adapter.startWithTuning(t, startParams(schema, clientID, map[string]any{
+ "periodic_run_on_start": true,
+ "queue": "maintenance_idle",
+ }), maintenanceTuning())
+ var first leaseRow
+ harness.waitFor(implementation.name+" first term", 30*time.Second, func() bool {
+ lease, ok := readLease(harness, schema)
+ first = lease
+ return ok && lease.leaderID == clientID && periodicCount() == 1
+ })
+
+ // Another process with the same client ID takes over with a newer term.
+ // The original client must lose leadership instead of renewing that
+ // term, and win a fresh term only after the replacement expires.
+ var replacementElectedAt time.Time
+ require.NoError(t, harness.pool.QueryRow(context.Background(), fmt.Sprintf(`
+ WITH removed AS (DELETE FROM %[1]s RETURNING leader_id, elected_at)
+ INSERT INTO %[1]s (leader_id, elected_at, expires_at)
+ SELECT leader_id, elected_at + interval '1 second', now() + interval '3 seconds' FROM removed
+ RETURNING elected_at`, table(schema, "river_leader"))).Scan(&replacementElectedAt))
+ harness.waitFor(implementation.name+" fresh term after replacement", 45*time.Second, func() bool {
+ lease, ok := readLease(harness, schema)
+ return ok && lease.leaderID == clientID &&
+ !lease.electedAt.Equal(first.electedAt) && !lease.electedAt.Equal(replacementElectedAt)
+ })
+ // Run-on-start periodic jobs are inserted once per gained term.
+ harness.waitFor(implementation.name+" second run-on-start job", 30*time.Second, func() bool {
+ return periodicCount() == 2
+ })
+ implementation.adapter.call(t, "stop", map[string]any{}, nil)
+}
+
+func verifyRenewalUnderSlowMaintenance(t *testing.T, harness *maintenanceHarness, migrator *adapter, implementation maintenanceImplementation) {
+ t.Helper()
+
+ ctx := context.Background()
+ schema := harness.schema(migrator, "maint_slow_renewal")
+ jobs := table(schema, "river_job")
+ expired := insertRawJob(harness, schema, "conformance_echo", "completed", nil, new(48*time.Hour))
+
+ // Holding the expired row blocks the job cleaner's delete.
+ tx, err := harness.pool.Begin(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback(ctx) }()
+ _, err = tx.Exec(ctx, "SELECT id FROM "+jobs+" WHERE id = $1 FOR UPDATE", expired)
+ require.NoError(t, err)
+
+ implementation.adapter.startWithTuning(t, startParams(schema, implementation.name+"-slow-renewal", map[string]any{
+ "queue": "maintenance_idle",
+ }), maintenanceTuning())
+ harness.waitFor(implementation.name+" blocked job cleaner", 30*time.Second, func() bool {
+ return harness.lockWaiters(implementation.adapter.applicationName) > 0
+ })
+
+ // The leader keeps renewing the same term while its maintenance is stuck:
+ // the first renewal is observed while the cleaner is still blocked, and
+ // the lease keeps advancing afterwards.
+ initial, ok := readLease(harness, schema)
+ require.True(t, ok)
+ expiresAt := initial.expiresAt
+ for renewal := range 2 {
+ harness.waitFor(implementation.name+" renewal during blocked maintenance", 30*time.Second, func() bool {
+ lease, ok := readLease(harness, schema)
+ require.True(t, ok)
+ require.True(t, lease.electedAt.Equal(initial.electedAt), "%s lost its term while maintenance was blocked", implementation.name)
+ if lease.expiresAt.After(expiresAt) {
+ expiresAt = lease.expiresAt
+ return true
+ }
+ return false
+ })
+ if renewal == 0 {
+ require.Positive(t, harness.lockWaiters(implementation.adapter.applicationName),
+ "%s maintenance stopped waiting before the lease was renewed", implementation.name)
+ }
+ }
+ require.NoError(t, tx.Rollback(ctx))
+ implementation.adapter.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyPeriodicDueJobAvailable checks that a periodic job whose constructor
+// leaves the schedule unset is inserted available at its target time, as Go's
+// periodic job enqueuer does, rather than scheduled behind the job scheduler.
+// A trigger records each row's state as inserted, because the scheduler would
+// otherwise promote a scheduled row before the harness could observe it.
+func verifyPeriodicDueJobAvailable(t *testing.T, harness *maintenanceHarness, migrator *adapter, implementation maintenanceImplementation) {
+ t.Helper()
+
+ schema := harness.schema(migrator, "maint_periodic_due")
+ insertedStates := table(schema, "conformance_inserted_state")
+ recordFunction := table(schema, "conformance_record_inserted_state")
+ harness.exec("CREATE TABLE " + insertedStates + " (id bigint PRIMARY KEY, state text NOT NULL)")
+ harness.exec("CREATE FUNCTION " + recordFunction + "() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN " +
+ "INSERT INTO " + insertedStates + " (id, state) VALUES (NEW.id, NEW.state::text); RETURN NEW; END $$")
+ harness.exec("CREATE TRIGGER conformance_record_inserted_state AFTER INSERT ON " + table(schema, "river_job") +
+ " FOR EACH ROW EXECUTE FUNCTION " + recordFunction + "()")
+
+ implementation.adapter.startWithTuning(t, startParams(schema, implementation.name+"-periodic-due", map[string]any{
+ "periodic_run_on_start": true,
+ }), maintenanceTuning())
+ periodicJobs := func() int64 {
+ return harness.queryInt("SELECT count(*) FROM " + table(schema, "river_job") +
+ " WHERE metadata->>'river:periodic_job_id' = 'conformance-periodic'")
+ }
+ harness.waitFor(implementation.name+" periodic run on start", 30*time.Second, func() bool {
+ return periodicJobs() == 1
+ })
+ implementation.adapter.call(t, "stop", map[string]any{}, nil)
+
+ var state string
+ require.NoError(t, harness.pool.QueryRow(context.Background(), "SELECT inserted.state FROM "+insertedStates+" inserted "+
+ "JOIN "+table(schema, "river_job")+" job USING (id) "+
+ "WHERE job.metadata->>'river:periodic_job_id' = 'conformance-periodic'").Scan(&state))
+ require.Equal(t, "available", state, "%s must insert a due periodic job available at its target time", implementation.name)
+}
diff --git a/conformance/harness/mixed_test.go b/conformance/harness/mixed_test.go
new file mode 100644
index 000000000..82d2e693b
--- /dev/null
+++ b/conformance/harness/mixed_test.go
@@ -0,0 +1,743 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// referenceApplicationName is the PostgreSQL application_name of the Go
+// reference adapter.
+const referenceApplicationName = "river-conformance-go"
+
+// TestMixedConformance runs every PostgreSQL scenario between the Go reference
+// and the configured candidate. Each registered scenario is its own subtest
+// and is credited only by its own assertions.
+//
+//nolint:paralleltest // Scenarios share one database and adapter processes, so they run sequentially.
+func TestMixedConformance(t *testing.T) {
+ // The adapters intentionally share one externally supplied disposable
+ // database, so this integration test cannot run in parallel with other
+ // conformance tiers.
+ databaseURL := requireEnv(t, "RIVER_CONFORMANCE_DATABASE_URL")
+ scenarios := newScenarioTracker(t, scenarioOwnerMixed)
+ repositoryRoot := repoRoot(t)
+ observer := newPostgresObserver(t, databaseURL)
+ goAdapter := startReferenceAdapter(t, repositoryRoot, databaseURL, "go")
+ candidateSpec := conformanceCandidateSpec(t, repositoryRoot, false)
+ candidateSpec.requireProfile(t, profilePostgresFull)
+ candidateAdapter := startCandidateAdapter(t, repositoryRoot, databaseURL, candidateSpec.Implementation, candidateSpec, candidateSpec.Command)
+ scenarios.attach(goAdapter, candidateAdapter)
+ pair := mixedPair{candidate: candidateAdapter, candidateSpec: candidateSpec, reference: goAdapter}
+
+ t.Run("adapter_handshake_and_capabilities", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyPostgresHandshakes(t, repositoryRoot, candidateSpec, goAdapter, candidateAdapter)
+ })
+ t.Run("deterministic_retry_clock_rng", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyDeterministicControls(t, repositoryRoot, goAdapter, candidateAdapter)
+ })
+ t.Run("unique_hash_goldens", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUniqueKeyGoldens(t, repositoryRoot, goAdapter, candidateAdapter)
+ })
+ t.Run("historical_migration_down_up", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyHistoricalMigrations(t, readManifest(t, repositoryRoot).Migration.Latest, goAdapter, candidateAdapter)
+ })
+
+ // Every following scenario uses the default schema. Scenarios reset River
+ // tables themselves, so they do not depend on each other's data.
+ goAdapter.call(t, "migrate", map[string]any{}, nil)
+
+ t.Run("reference_migrator_candidate_runtime", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyMigratorRuntime(t, goAdapter, candidateAdapter)
+ })
+ t.Run("candidate_migrator_reference_runtime", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyMigratorRuntime(t, candidateAdapter, goAdapter)
+ })
+ t.Run("reference_insert_candidate_work", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyInsertThenWork(t, goAdapter, candidateAdapter)
+ })
+ t.Run("candidate_insert_reference_work", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyInsertThenWork(t, candidateAdapter, goAdapter)
+ })
+ t.Run("custom_schema_reference_migrate_candidate_work", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyCustomSchema(t, "river_conformance_go_migrated", goAdapter, candidateAdapter)
+ })
+ t.Run("custom_schema_candidate_migrate_reference_work", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyCustomSchema(t, "river_conformance_candidate_migrated", candidateAdapter, goAdapter)
+ })
+ t.Run("cross_language_unique_conflict", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyConcurrentUniqueConflicts(t, observer, goAdapter, candidateAdapter)
+ })
+ t.Run("unique_skip_keeps_existing_kind", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUniqueSkipKeepsExistingKind(t, goAdapter, candidateAdapter)
+ })
+ t.Run("cross_language_cancel_retry_race", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyConcurrentCancelRetryRace(t, observer, goAdapter, candidateAdapter)
+ })
+ t.Run("unique_column_bytes", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUniqueColumnBytes(t, goAdapter, candidateAdapter)
+ })
+ t.Run("typed_batch_insertion", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyBatchInsertion(t, goAdapter, candidateAdapter)
+ verifyLargeBatchInsertion(t, goAdapter, candidateAdapter)
+ })
+ t.Run("transactional_batch_insertion", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(actor, observer *adapter) {
+ verifyTransactionalBatchInsertion(t, actor, observer)
+ })
+ })
+ t.Run("differential_job_crud", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyDifferentialJobCRUD(t, goAdapter, candidateAdapter)
+ })
+ t.Run("bulk_delete_safety", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyBulkDeleteSafety(t, goAdapter, candidateAdapter)
+ })
+ t.Run("job_cleaner_queue_filters", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyJobCleanerQueueFilters(t, goAdapter, candidateAdapter)
+ })
+ t.Run("differential_job_list_filters_and_cursors", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyDifferentialListCursors(t, goAdapter, candidateAdapter, true)
+ })
+ t.Run("job_list_cursor_interchange", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyJobListCursorInterchange(t, goAdapter, candidateAdapter)
+ })
+ t.Run("differential_queue_crud", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyDifferentialQueueCRUD(t, observer, goAdapter, candidateAdapter)
+ })
+ t.Run("job_row_round_trip_all_fields", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyJobRowRoundTrip(t, goAdapter, candidateAdapter)
+ verifyLargeMetadataRoundTrip(t, goAdapter, candidateAdapter)
+ })
+ t.Run("unsafe_int64_job_ids_rpc_list_cursors", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUnsafeInt64JobIDs(t, goAdapter, candidateAdapter)
+ })
+ t.Run("claim_order", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyClaimOrder(t, goAdapter, candidateAdapter)
+ })
+ t.Run("scheduler_unique_conflict_discard", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySchedulerUniqueConflictDiscard(t, goAdapter, candidateAdapter)
+ })
+ t.Run("exhausted_job_retry", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyExhaustedJobRetry(t, goAdapter, candidateAdapter)
+ })
+ t.Run("kind_alias_rename", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyKindAliasRename(t, goAdapter, candidateAdapter)
+ })
+ t.Run("heterogeneous_fleet_known_kinds", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyHeterogeneousFleet(t, goAdapter, candidateAdapter)
+ })
+ t.Run("rescuer_unknown_kind_discard", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyRescuerUnknownKind(t, goAdapter, candidateAdapter, func(t *testing.T, name string) *adapter {
+ t.Helper()
+
+ return startReferenceAdapter(t, repositoryRoot, databaseURL, name)
+ })
+ })
+ t.Run("mixed_unknown_kind_error", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUnknownKind(t, goAdapter, candidateAdapter)
+ })
+ t.Run("transactional_crud_commit_rollback", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyTransactionalJobCRUD(t, goAdapter, candidateAdapter)
+ })
+ t.Run("transactional_queue_operations", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyTransactionalQueueOperations(t, goAdapter, candidateAdapter)
+ })
+ t.Run("transaction_commit_visibility", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyTransactionCommitVisibility(t, goAdapter, candidateAdapter)
+ })
+ t.Run("transaction_rollback_visibility", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyTransactionRollbackVisibility(t, goAdapter, candidateAdapter)
+ })
+ t.Run("transactional_cross_language_cancel", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyTransactionalCrossLanguageCancel(t, goAdapter, candidateAdapter)
+ })
+ t.Run("transaction_abort_rollback_visibility", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyTransactionAbortRollback(t, goAdapter, candidateAdapter)
+ })
+ t.Run("barrier_wait_and_release", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyBarrierWaitAndRelease(t, current) })
+ })
+ t.Run("single_implementation_worker_outcomes", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyWorkerOutcomes(t, current) })
+ })
+ t.Run("panic_attempt_trace", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(worker, observer *adapter) { verifyPanicAttemptTrace(t, worker, observer) })
+ })
+ t.Run("transactional_completion", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyTransactionalCompletion(t, current) })
+ })
+ t.Run("snooze_once_metadata_transition", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(worker, observer *adapter) { verifySnoozeTransition(t, worker, observer) })
+ })
+ t.Run("external_terminal_completion_race", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyExternalTerminalCompletionRace(t, goAdapter, candidateAdapter)
+ verifyExternalTerminalCompletionRace(t, candidateAdapter, goAdapter)
+ })
+ t.Run("extension_hook_middleware_order", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyExtensionOrder(t, current) })
+ })
+ t.Run("resumable_retry", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyResumableRetry(t, current) })
+ })
+ t.Run("dynamic_queue_add_reconfigure_remove", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyDynamicQueues(t, current) })
+ })
+ t.Run("periodic_run_on_start", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyPeriodicRunOnStart(t, current) })
+ })
+ t.Run("periodic_unique_cross_engine", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUniquePeriodicJob(t, goAdapter, candidateAdapter)
+ })
+ t.Run("error_handler_cancel_override", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyErrorHandlerCancel(t, current) })
+ })
+ t.Run("resumable_validation", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyResumableValidation(t, current) })
+ })
+ t.Run("resumable_cross_engine_cursor", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyResumableInteroperability(t, goAdapter, candidateAdapter)
+ })
+ t.Run("notification_only_wakeups", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(controller, worker *adapter) { verifyInsertNotificationWakeup(t, controller, worker) })
+ })
+ t.Run("pause_resume_notification", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(controller, worker *adapter) { verifyPauseResumeNotification(t, controller, worker) })
+ })
+ t.Run("remote_cancel_notification", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(controller, worker *adapter) { verifyRemoteCancelNotification(t, controller, worker) })
+ })
+ t.Run("poll_only_remote_cancellation", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(controller, worker *adapter) { verifyPollOnlyRemoteCancellation(t, controller, worker) })
+ })
+ t.Run("simulated_yugabyte_polling", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySimulatedYugabyte(t, observer, repositoryRoot, databaseURL, candidateSpec)
+ })
+ t.Run("cooperative_remote_cancellation", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(controller, worker *adapter) { verifyCooperativeRemoteCancellation(t, controller, worker) })
+ })
+ t.Run("claim_time_cancellation", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(canceller, claimer *adapter) { verifyClaimTimeCancellation(t, canceller, claimer, true) })
+ })
+ t.Run("notification_payloads", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyNotificationPayloads(t, goAdapter, candidateAdapter, func(*adapter) notificationCapture {
+ return newPostgresNotificationCapture(t, observer)
+ })
+ })
+ t.Run("remote_queue_subscription_events", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyRemoteQueueSubscriptionEvents(t, goAdapter, candidateAdapter)
+ })
+ t.Run("transactional_insert_notification_commit_only", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyTransactionalNotificationWakeups(t, observer, goAdapter, candidateAdapter)
+ })
+ t.Run("refetched_attempt_cancellation", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyRefetchedAttemptCancellation(t, candidateAdapter, goAdapter)
+ verifyRefetchedAttemptCancellation(t, goAdapter, candidateAdapter)
+ })
+ t.Run("timeout_cancellation", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(worker, observer *adapter) { verifyTimeoutCancellation(t, worker, observer) })
+ })
+ t.Run("completion_batching", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyCompletionBatching(t, observer, current) })
+ })
+ t.Run("mixed_request_resign_terms", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyLeadershipRequestLifecycle(t, observer, goAdapter, candidateAdapter)
+ })
+ t.Run("mixed_leader_failover_both_directions", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyGracefulLeaderFailover(t, pair)
+ })
+ t.Run("leader_election_disabled_both_directions", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(disabled, eligible *adapter) { verifyLeaderElectionDisabled(t, disabled, eligible) })
+ })
+ t.Run("listener_backend_disconnect_reconnect", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyListenerReconnect(t, pair)
+ })
+ t.Run("lost_notification_poll_recovery", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(inserter, worker *adapter) { verifyLostNotificationPollRecovery(t, inserter, worker) })
+ })
+ t.Run("mixed_skip_locked_competition", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySkipLockedCompetition(t, goAdapter, candidateAdapter)
+ })
+ t.Run("ignored_cancellation_hard_abort", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyIgnoredCancellationHardAbort(t, repositoryRoot, databaseURL, pair)
+ })
+ t.Run("candidate_process_kill_reference_rescue", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyProcessKillCrossEngineRescue(t, repositoryRoot, databaseURL, candidateAdapter, goAdapter)
+ })
+ t.Run("reference_process_kill_candidate_rescue", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyProcessKillCrossEngineRescue(t, repositoryRoot, databaseURL, goAdapter, candidateAdapter)
+ })
+ t.Run("mixed_leader_death_failover_both_directions", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(leaderKind, follower *adapter) {
+ verifyLeaderDeathFailover(t, repositoryRoot, databaseURL, leaderKind, follower)
+ })
+ })
+ t.Run("rolling_deployment_same_protocol", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyRollingDeployment(t, repositoryRoot, databaseURL, pair)
+ })
+ t.Run("clock_boundary_scheduling", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(inserter, worker *adapter) { verifyClockBoundaries(t, inserter, worker) })
+ })
+ t.Run("default_retry_policy_schedule", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(worker, observer *adapter) { verifyDefaultRetrySchedule(t, repositoryRoot, worker, observer) })
+ })
+ t.Run("stuck_job_detection", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(kind *adapter) { verifyStuckJobDetection(t, repositoryRoot, databaseURL, kind) })
+ })
+ t.Run("pool_pressure_completion", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyPoolPressure(t, goAdapter, candidateAdapter)
+ })
+ t.Run("reserved_metadata_cross_engine", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(worker, controller *adapter) { verifyReservedMetadata(t, repositoryRoot, worker, controller) })
+ })
+ t.Run("process_kill_restart_and_rescue", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyProcessKillRestartAndRescue(t, repositoryRoot, databaseURL, pair)
+ })
+}
+
+// mixedPair is the reference adapter and one candidate sharing a database.
+type mixedPair struct {
+ candidate *adapter
+ candidateSpec adapterSpec
+ reference *adapter
+}
+
+// eachAdapter runs a single-implementation check against both adapters.
+func (pair mixedPair) eachAdapter(check func(current *adapter)) {
+ check(pair.reference)
+ check(pair.candidate)
+}
+
+// eachDirection runs a two-party check with the reference first and then the
+// candidate in the first role.
+func (pair mixedPair) eachDirection(check func(first, second *adapter)) {
+ check(pair.reference, pair.candidate)
+ check(pair.candidate, pair.reference)
+}
+
+type conformanceManifest struct {
+ Capabilities map[string]string `json:"capabilities"`
+ Implementations map[string]struct {
+ Version string `json:"version"`
+ } `json:"implementations"`
+ Migration struct {
+ Latest int `json:"latest"`
+ Line string `json:"line"`
+ } `json:"migration"`
+ ProtocolRevision int `json:"protocol_revision"`
+}
+
+func readManifest(t *testing.T, repositoryRoot string) conformanceManifest {
+ t.Helper()
+
+ var manifest conformanceManifest
+ contents, err := os.ReadFile(filepath.Join(repositoryRoot, "conformance/manifest.json"))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(contents, &manifest))
+ return manifest
+}
+
+func verifyPostgresHandshakes(t *testing.T, repositoryRoot string, candidateSpec adapterSpec, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ var goHandshake, candidateHandshake adapterHandshake
+ goAdapter.call(t, "handshake", map[string]any{}, &goHandshake)
+ candidateAdapter.call(t, "handshake", map[string]any{}, &candidateHandshake)
+ manifest := readManifest(t, repositoryRoot)
+ expectedCapabilities := make([]string, 0, len(manifest.Capabilities))
+ for capability, status := range manifest.Capabilities {
+ if status == "complete" {
+ expectedCapabilities = append(expectedCapabilities, capability)
+ }
+ }
+
+ require.Equal(t, "go", goHandshake.Implementation)
+ require.Equal(t, candidateSpec.Implementation, candidateHandshake.Implementation)
+ require.Equal(t, "postgres", goHandshake.Backend)
+ require.Equal(t, goHandshake.Backend, candidateHandshake.Backend)
+ require.Equal(t, "postgres-full-v1", goHandshake.Profile)
+ require.Equal(t, goHandshake.Profile, candidateHandshake.Profile)
+ require.Positive(t, goHandshake.AdapterVersion)
+ require.Equal(t, goHandshake.AdapterVersion, candidateHandshake.AdapterVersion)
+ require.Equal(t, manifest.Implementations[goHandshake.Implementation].Version,
+ goHandshake.ImplementationVersion)
+ if candidateSpec.Version != "" {
+ require.Equal(t, candidateSpec.Version, candidateHandshake.ImplementationVersion)
+ }
+ require.Equal(t, manifest.Implementations[candidateHandshake.Implementation].Version,
+ candidateHandshake.ImplementationVersion)
+ require.Equal(t, manifest.ProtocolRevision, goHandshake.ProtocolRevision)
+ require.Equal(t, goHandshake.ProtocolRevision, candidateHandshake.ProtocolRevision)
+ require.Equal(t, map[string]int{manifest.Migration.Line: manifest.Migration.Latest}, goHandshake.MigrationLines)
+ require.Equal(t, goHandshake.MigrationLines, candidateHandshake.MigrationLines)
+ require.ElementsMatch(t, expectedCapabilities, goHandshake.Capabilities)
+ require.ElementsMatch(t, goHandshake.Capabilities, candidateHandshake.Capabilities)
+ var adapterContract struct {
+ AdapterVersion int `json:"adapter_version"`
+ Methods []struct {
+ Name string `json:"name"`
+ } `json:"methods"`
+ ProtocolRevision int `json:"protocol_revision"`
+ }
+ contractBytes, err := os.ReadFile(filepath.Join(repositoryRoot, "conformance/adapter/contract.json"))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(contractBytes, &adapterContract))
+ expectedMethods := make([]string, len(adapterContract.Methods))
+ for index, method := range adapterContract.Methods {
+ expectedMethods[index] = method.Name
+ }
+ require.Equal(t, adapterContract.AdapterVersion, goHandshake.AdapterVersion)
+ require.Equal(t, adapterContract.ProtocolRevision, goHandshake.ProtocolRevision)
+ require.Equal(t, expectedMethods, goHandshake.Methods)
+ require.Equal(t, goHandshake.Methods, candidateHandshake.Methods)
+ verifyRequestStrictness(t, goAdapter, candidateAdapter)
+}
+
+// verifyRequestStrictness requires adapters to reject unknown methods and
+// params with contract error codes instead of ignoring them, and to report
+// optional start tuning they do not declare as unsupported.
+func verifyRequestStrictness(t *testing.T, adapters ...*adapter) {
+ t.Helper()
+
+ for _, current := range adapters {
+ current.requireUnvalidatedCallError(t, "not_a_contract_method", map[string]any{}, "method_not_found")
+ current.requireUnvalidatedCallError(t, "handshake", map[string]any{"unexpected": true}, "invalid_params")
+ current.requireUnvalidatedCallError(t, "insert", map[string]any{
+ "message": "unknown option", "opts": map[string]any{"not_an_option": true},
+ }, "invalid_params")
+ var handshake adapterHandshake
+ current.call(t, "handshake", map[string]any{}, &handshake)
+ if !slices.Contains(handshake.Methods, "start") {
+ continue
+ }
+ for _, option := range []string{"elect_interval_ms", "rescuer_interval_ms", "scheduler_interval_ms"} {
+ if !current.spec.supportsStartOption(option) {
+ current.requireCallError(t, "start", map[string]any{
+ "client_id": current.name + "-unsupported-option", option: 20,
+ }, "unsupported")
+ }
+ }
+ }
+}
+
+// verifyMigratorRuntime rebuilds the default schema with one implementation's
+// migrator and then runs the other implementation's worker runtime on it.
+func verifyMigratorRuntime(t *testing.T, migrator, runtime *adapter) {
+ t.Helper()
+
+ type migrationResult struct {
+ Existing []int `json:"existing"`
+ Valid bool `json:"valid"`
+ }
+ var result migrationResult
+ migrator.call(t, "migrate", map[string]any{"direction": "down", "target_version": -1}, &result)
+ require.Empty(t, result.Existing)
+ migrator.call(t, "migrate", map[string]any{}, &result)
+ require.True(t, result.Valid)
+ runtime.call(t, "reset", map[string]any{}, nil)
+
+ clientID := runtime.name + "-runtime-on-" + migrator.name + "-schema"
+ var inserted, worked normalizedJob
+ runtime.call(t, "insert", map[string]any{"message": "runtime on " + migrator.name + " migrations"}, &inserted)
+ runtime.call(t, "work", map[string]any{"client_id": clientID, "id": inserted.ID}, &worked)
+ require.Equal(t, "completed", worked.State)
+ require.Equal(t, []string{clientID}, worked.AttemptedBy)
+}
+
+// verifyInsertThenWork inserts with one implementation and works the job
+// with the other, comparing every normalized field in between.
+func verifyInsertThenWork(t *testing.T, inserter, worker *adapter) {
+ t.Helper()
+
+ inserter.call(t, "reset", map[string]any{}, nil)
+ var inserted, observed normalizedJob
+ inserter.call(t, "insert", map[string]any{"message": inserter.name + " to " + worker.name}, &inserted)
+ require.Equal(t, "available", inserted.State)
+ require.Equal(t, "conformance_echo", inserted.Kind)
+ require.Equal(t, 0, inserted.Attempt)
+ require.Empty(t, inserted.AttemptedBy)
+ // River's client default, persisted in the row that other
+ // implementations work.
+ require.Equal(t, 25, inserted.MaxAttempts)
+ worker.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.Equal(t, inserted, observed)
+
+ clientID := worker.name + "-conformance-adapter"
+ worker.call(t, "work", map[string]any{"client_id": clientID, "id": inserted.ID}, &observed)
+ require.Equal(t, "completed", observed.State)
+ require.Equal(t, 1, observed.Attempt)
+ require.Equal(t, []string{clientID}, observed.AttemptedBy)
+ require.NotNil(t, observed.AttemptedAt)
+ require.NotNil(t, observed.FinalizedAt)
+
+ var fromInserter normalizedJob
+ inserter.call(t, "get", map[string]any{"id": inserted.ID}, &fromInserter)
+ require.Equal(t, observed, fromInserter)
+}
+
+func verifyTransactionCommitVisibility(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct{ actor, observer *adapter }{
+ {actor: goAdapter, observer: candidateAdapter},
+ {actor: candidateAdapter, observer: goAdapter},
+ } {
+ pair.actor.call(t, "reset", map[string]any{}, nil)
+ handle := pair.actor.name + "-commit"
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var inserted, observed normalizedJob
+ pair.actor.call(t, "tx_insert", map[string]any{
+ "handle": handle,
+ "job": map[string]any{"message": "transaction commit"},
+ }, &inserted)
+ pair.actor.call(t, "tx_get", map[string]any{"handle": handle, "id": inserted.ID}, &observed)
+ require.Equal(t, inserted, observed)
+ requireJobNotFound(t, pair.observer, inserted.ID)
+ pair.actor.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ pair.observer.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.Equal(t, inserted, observed)
+ }
+}
+
+func verifyTransactionRollbackVisibility(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct{ actor, observer *adapter }{
+ {actor: goAdapter, observer: candidateAdapter},
+ {actor: candidateAdapter, observer: goAdapter},
+ } {
+ pair.actor.call(t, "reset", map[string]any{}, nil)
+ handle := pair.actor.name + "-rollback"
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var inserted, observed normalizedJob
+ pair.actor.call(t, "tx_insert", map[string]any{
+ "handle": handle,
+ "job": map[string]any{"message": "transaction rollback"},
+ }, &inserted)
+ pair.actor.call(t, "tx_get", map[string]any{"handle": handle, "id": inserted.ID}, &observed)
+ require.Equal(t, inserted, observed)
+ pair.actor.call(t, "tx_rollback", map[string]any{"handle": handle}, nil)
+ requireJobNotFound(t, pair.observer, inserted.ID)
+ requireJobNotFound(t, pair.actor, inserted.ID)
+ }
+}
+
+func verifyTransactionalCrossLanguageCancel(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct{ canceller, inserter *adapter }{
+ {canceller: candidateAdapter, inserter: goAdapter},
+ {canceller: goAdapter, inserter: candidateAdapter},
+ } {
+ pair.inserter.call(t, "reset", map[string]any{}, nil)
+ var cancellable, observed normalizedJob
+ pair.inserter.call(t, "insert", map[string]any{"message": "transactional cancellation"}, &cancellable)
+ handle := pair.canceller.name + "-cancel"
+ pair.canceller.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ pair.canceller.call(t, "tx_cancel", map[string]any{"handle": handle, "id": cancellable.ID}, &observed)
+ require.Equal(t, "cancelled", observed.State)
+ require.NotNil(t, observed.FinalizedAt)
+ pair.inserter.call(t, "get", map[string]any{"id": cancellable.ID}, &observed)
+ require.Equal(t, "available", observed.State)
+ pair.canceller.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ pair.inserter.call(t, "get", map[string]any{"id": cancellable.ID}, &observed)
+ require.Equal(t, "cancelled", observed.State)
+ require.NotNil(t, observed.FinalizedAt)
+ }
+}
+
+// verifyTransactionAbortRollback aborts PostgreSQL transaction state and
+// proves the work done before the failure is never visible. PostgreSQL rolls
+// an aborted transaction back on COMMIT; drivers disagree about whether that
+// COMMIT reports an error, so only visibility is portable.
+func verifyTransactionAbortRollback(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ for _, transactionAdapter := range []*adapter{goAdapter, candidateAdapter} {
+ handle := transactionAdapter.name + "-failed-transaction"
+ transactionAdapter.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var failedTxJob normalizedJob
+ transactionAdapter.call(t, "tx_insert", map[string]any{
+ "handle": handle,
+ "job": map[string]any{"message": "must roll back after SQL failure"},
+ }, &failedTxJob)
+ transactionAdapter.requireCallError(t, "tx_fail", map[string]any{"handle": handle}, "database_error")
+ _ = transactionAdapter.callResponse(t, "tx_commit", map[string]any{"handle": handle})
+ requireJobNotFound(t, goAdapter, failedTxJob.ID)
+ requireJobNotFound(t, candidateAdapter, failedTxJob.ID)
+ }
+}
+
+func requireJobNotFound(t *testing.T, observer *adapter, id int64) {
+ t.Helper()
+
+ observer.requireCallError(t, "get", map[string]any{"id": id}, "not_found")
+}
diff --git a/conformance/harness/multi_engine_test.go b/conformance/harness/multi_engine_test.go
new file mode 100644
index 000000000..45f1f06ed
--- /dev/null
+++ b/conformance/harness/multi_engine_test.go
@@ -0,0 +1,536 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// engine is one implementation participating in a multi-engine tier.
+type engine struct {
+ adapter *adapter
+ clientID string
+ spec adapterSpec
+}
+
+// multiEngineSpecs returns the reference and every configured candidate:
+// the ordinary candidate descriptor plus one or more peer descriptors. At
+// least two distinct candidates are required so the tier cannot degrade into
+// a duplicated pairwise test.
+func multiEngineSpecs(t *testing.T, root string, release bool) []adapterSpec {
+ t.Helper()
+
+ peers := conformancePeerSpecs(t, root, release)
+ specs := make([]adapterSpec, 0, 2+len(peers))
+ specs = append(specs, referenceSpec(), conformanceCandidateSpec(t, root, release))
+ specs = append(specs, peers...)
+ implementations := make(map[string]bool, len(specs))
+ applicationNames := make(map[string]bool, len(specs))
+ for _, spec := range specs {
+ require.False(t, implementations[spec.Implementation],
+ "multi-engine tiers need distinct implementations; %q appears twice (set RIVER_CONFORMANCE_PEER or RIVER_CONFORMANCE_PEER_FILE)", spec.Implementation)
+ require.False(t, applicationNames[spec.ApplicationName],
+ "multi-engine tiers need distinct application names; %q appears twice", spec.ApplicationName)
+ implementations[spec.Implementation] = true
+ applicationNames[spec.ApplicationName] = true
+ }
+ require.GreaterOrEqual(t, len(specs), 3, "multi-engine tiers need the reference and at least two candidates")
+ return specs
+}
+
+func startEngines(t *testing.T, root, databaseURL, suffix string, specs []adapterSpec) []engine {
+ t.Helper()
+
+ engines := make([]engine, len(specs))
+ for index, spec := range specs {
+ name := spec.Implementation + "-" + suffix
+ var started *adapter
+ if index == 0 {
+ started = startReferenceAdapter(t, root, databaseURL, name)
+ } else {
+ started = startCandidateAdapter(t, root, databaseURL, name, spec, spec.Command)
+ }
+ engines[index] = engine{adapter: started, clientID: name, spec: spec}
+ }
+ return engines
+}
+
+// candidatePairs returns every ordered pair of distinct non-reference engines.
+func candidatePairs(engines []engine) [][2]engine {
+ var pairs [][2]engine
+ for _, first := range engines[1:] {
+ for _, second := range engines[1:] {
+ if first.spec.Implementation != second.spec.Implementation {
+ pairs = append(pairs, [2]engine{first, second})
+ }
+ }
+ }
+ return pairs
+}
+
+//nolint:paralleltest // Scenarios share one database and adapter processes, so they run sequentially.
+func TestMultiEngineConformance(t *testing.T) {
+ // Every engine competes in one externally supplied disposable database,
+ // so this test cannot run in parallel with other tiers.
+ databaseURL := requireEnv(t, "RIVER_CONFORMANCE_DATABASE_URL")
+ scenarios := newScenarioTracker(t, scenarioOwnerMultiEngine)
+ root := repoRoot(t)
+ engines := startEngines(t, root, databaseURL, "multi-engine", multiEngineSpecs(t, root, false))
+ reference := engines[0].adapter
+ adapters := make([]*adapter, len(engines))
+ clientIDs := make([]string, len(engines))
+ adapterByClientID := make(map[string]*adapter, len(engines))
+ for index, current := range engines {
+ adapters[index] = current.adapter
+ clientIDs[index] = current.clientID
+ adapterByClientID[current.clientID] = current.adapter
+ var handshake adapterHandshake
+ current.adapter.call(t, "handshake", map[string]any{}, &handshake)
+ require.Equal(t, current.spec.Implementation, handshake.Implementation)
+ require.Equal(t, profilePostgresFull, handshake.Profile)
+ }
+ scenarios.attach(adapters...)
+ reference.call(t, "migrate", map[string]any{}, nil)
+ startAll := func(t *testing.T) {
+ t.Helper()
+
+ reference.call(t, "reset", map[string]any{}, nil)
+ for _, current := range engines {
+ current.adapter.call(t, "start", map[string]any{
+ "client_id": current.clientID, "max_workers": 1,
+ }, nil)
+ }
+ }
+ stopAll := func(t *testing.T) {
+ t.Helper()
+
+ for _, current := range adapters {
+ current.call(t, "stop", map[string]any{}, nil)
+ }
+ }
+
+ t.Run("multi_engine_competition", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ startAll(t)
+ jobs := make([]normalizedJob, len(adapters))
+ for index, inserter := range adapters {
+ inserter.call(t, "insert", map[string]any{
+ "behavior": "sleep", "duration_ms": 1_000,
+ "message": fmt.Sprintf("multi-engine competition %d", index),
+ }, &jobs[index])
+ }
+ workersSeen := make(map[string]bool)
+ for _, job := range jobs {
+ var running normalizedJob
+ reference.call(t, "wait", map[string]any{
+ "id": job.ID, "states": []string{"running"},
+ }, &running)
+ require.Len(t, running.AttemptedBy, 1)
+ workersSeen[running.AttemptedBy[0]] = true
+ }
+ require.ElementsMatch(t, clientIDs, mapKeys(workersSeen), "every engine must claim one blocked job")
+ for _, job := range jobs {
+ var completed normalizedJob
+ reference.call(t, "wait", map[string]any{"id": job.ID}, &completed)
+ require.Equal(t, "completed", completed.State)
+ require.Equal(t, 1, completed.Attempt)
+ }
+ stopAll(t)
+ })
+ t.Run("multi_engine_job_list_cursor_interchange", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ for _, pair := range candidatePairs(engines) {
+ if pair[0].spec.Implementation < pair[1].spec.Implementation {
+ verifyJobListCursorInterchange(t, pair[0].adapter, pair[1].adapter)
+ }
+ }
+ })
+ t.Run("multi_engine_leader_election_disabled", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ // Candidate pairs come in both orders, so every candidate runs with
+ // leader election disabled next to every other candidate.
+ for _, pair := range candidatePairs(engines) {
+ verifyLeaderElectionDisabled(t, pair[0].adapter, pair[1].adapter)
+ }
+ })
+ t.Run("multi_engine_leader_failover", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ startAll(t)
+ stopped := make([]string, 0, len(engines)-1)
+ leader := waitForLeader(t, reference, "")
+ for range len(engines) - 1 {
+ require.NotContains(t, stopped, leader, "a stopped engine is still the leader")
+ adapterByClientID[leader].call(t, "stop", map[string]any{}, nil)
+ stopped = append(stopped, leader)
+ leader = waitForLeader(t, reference, leader)
+ }
+ require.NotContains(t, stopped, leader)
+ for _, stoppedID := range stopped {
+ adapterByClientID[stoppedID].call(t, "start", map[string]any{
+ "client_id": stoppedID, "max_workers": 1,
+ }, nil)
+ }
+ for _, current := range adapters {
+ require.Equal(t, leader, readLeader(t, current).LeaderID, "%s disagrees about the leader", current.name)
+ }
+ stopAll(t)
+ })
+ t.Run("multi_engine_fault_recovery", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ startAll(t)
+ for _, target := range engines {
+ waitForListener(t, target.adapter)
+ var disconnected struct {
+ Count int `json:"count"`
+ }
+ reference.call(t, "fault_disconnect_application", map[string]any{
+ "application_name": target.adapter.applicationName,
+ }, &disconnected)
+ require.Positive(t, disconnected.Count)
+ waitForListener(t, target.adapter)
+ }
+ for index, inserter := range adapters {
+ var inserted, completed normalizedJob
+ inserter.call(t, "insert", map[string]any{
+ "message": fmt.Sprintf("multi-engine fault recovery %d", index),
+ }, &inserted)
+ reference.call(t, "wait", map[string]any{"id": inserted.ID}, &completed)
+ require.Equal(t, "completed", completed.State)
+ require.Equal(t, 1, completed.Attempt)
+ }
+ stopAll(t)
+ })
+ t.Run("multi_engine_resource_bound", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ startAll(t)
+ assertMultiEngineConnectionBounds(t, adapters)
+ stopAll(t)
+ })
+ t.Run("multi_engine_directed_candidate_work_notification_cancellation", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ for _, pair := range candidatePairs(engines) {
+ verifyDirectedCandidateWork(t, reference, pair[0], pair[1])
+ }
+ })
+ t.Run("multi_engine_resumable_cursor", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ for _, pair := range candidatePairs(engines) {
+ if pair[0].spec.Implementation < pair[1].spec.Implementation {
+ verifyResumableInteroperability(t, pair[0].adapter, pair[1].adapter)
+ }
+ }
+ })
+ t.Run("multi_engine_process_kill_rescue_failover", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ for _, pair := range candidatePairs(engines) {
+ verifyCrossEngineProcessKillRescue(t, root, databaseURL, reference, pair[0].spec, pair[1].spec)
+ }
+ })
+}
+
+// verifyDirectedCandidateWork has one candidate insert and cancel work that
+// another candidate executes, with the reference only observing. The worker
+// polls once a minute, so prompt execution proves the candidates exchange
+// notifications directly.
+func verifyDirectedCandidateWork(t *testing.T, reference *adapter, controller, worker engine) {
+ t.Helper()
+
+ reference.call(t, "reset", map[string]any{}, nil)
+ workerID := worker.spec.Implementation + "-directed-worker"
+ worker.adapter.call(t, "start", map[string]any{
+ "client_id": workerID, "fetch_poll_interval_ms": 60_000, "max_workers": 1,
+ }, nil)
+ waitForListener(t, worker.adapter)
+
+ startedAt := time.Now()
+ var worked normalizedJob
+ controller.adapter.call(t, "insert", map[string]any{
+ "message": controller.spec.Implementation + " notification to " + worker.spec.Implementation,
+ }, &worked)
+ reference.call(t, "wait", map[string]any{"id": worked.ID}, &worked)
+ require.Equal(t, "completed", worked.State)
+ require.Equal(t, []string{workerID}, worked.AttemptedBy)
+ require.Less(t, time.Since(startedAt), 5*time.Second,
+ "%s did not wake %s through the cross-engine notification path",
+ controller.spec.Implementation, worker.spec.Implementation)
+
+ var cancelled normalizedJob
+ controller.adapter.call(t, "insert", map[string]any{
+ "behavior": "cooperative_cancel",
+ "message": controller.spec.Implementation + " cancellation to " + worker.spec.Implementation,
+ }, &cancelled)
+ reference.call(t, "wait", map[string]any{
+ "id": cancelled.ID, "states": []string{"running"},
+ }, &cancelled)
+ require.Equal(t, []string{workerID}, cancelled.AttemptedBy)
+ controller.adapter.call(t, "cancel", map[string]any{"id": cancelled.ID}, &cancelled)
+ reference.call(t, "wait", map[string]any{"id": cancelled.ID}, &cancelled)
+ require.Equal(t, "cancelled", cancelled.State)
+ require.Len(t, cancelled.Errors, 1)
+ require.Equal(t, "JobCancelError: job cancelled remotely", cancelled.Errors[0].Error)
+
+ worker.adapter.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyCrossEngineProcessKillRescue kills a disposable crashing process that
+// leads and holds a running attempt, then requires a process of another
+// implementation to take over leadership, rescue the abandoned attempt, and
+// complete it.
+func verifyCrossEngineProcessKillRescue(t *testing.T, root, databaseURL string, reference *adapter, crashingSpec, recoverySpec adapterSpec) {
+ t.Helper()
+
+ reference.call(t, "reset", map[string]any{}, nil)
+ queue := "process_kill_" + crashingSpec.Implementation
+ crashingID := crashingSpec.Implementation + "-process-kill"
+ crashing := startCandidateAdapter(t, root, databaseURL, crashingID, crashingSpec, crashingSpec.RestartCommand)
+ crashing.startWithTuning(t, map[string]any{
+ "client_id": crashingID, "max_workers": 1, "queue": queue,
+ }, map[string]any{"elect_interval_ms": 20})
+ require.Equal(t, crashingID, waitForLeader(t, reference, ""))
+
+ var job normalizedJob
+ reference.call(t, "insert", map[string]any{
+ "behavior": "sleep", "duration_ms": 1_000,
+ "message": "process-kill rescue from " + crashingSpec.Implementation + " to " + recoverySpec.Implementation,
+ "opts": map[string]any{"queue": queue},
+ }, &job)
+ reference.call(t, "wait", map[string]any{
+ "id": job.ID, "states": []string{"running"},
+ }, &job)
+ require.Equal(t, []string{crashingID}, job.AttemptedBy)
+ crashing.kill(t)
+ reference.call(t, "fault_expire_leader", map[string]any{}, nil)
+
+ recoveryID := recoverySpec.Implementation + "-process-recovery"
+ recovery := startCandidateAdapter(t, root, databaseURL, recoveryID, recoverySpec, recoverySpec.RestartCommand)
+ recovery.startWithTuning(t, map[string]any{
+ "client_id": recoveryID, "job_timeout_ms": 1_500, "max_workers": 1,
+ "queue": queue, "rescue_after_ms": 1_500,
+ }, map[string]any{
+ "elect_interval_ms": 20, "rescuer_interval_ms": 20, "scheduler_interval_ms": 20,
+ })
+ require.Equal(t, recoveryID, waitForLeader(t, reference, crashingID))
+ reference.call(t, "wait", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, "completed", job.State)
+ require.Equal(t, 2, job.Attempt)
+ require.Equal(t, []string{crashingID, recoveryID}, job.AttemptedBy)
+ recovery.call(t, "stop", map[string]any{}, nil)
+}
+
+// TestMultiEngineSQLiteConformance runs the SQLite storage and runtime
+// cross-language checks between every pair of configured candidates, without
+// the reference, against one shared WAL database per pair.
+//
+//nolint:paralleltest // Scenarios share one database and adapter processes, so they run sequentially.
+func TestMultiEngineSQLiteConformance(t *testing.T) {
+ scenarios := newScenarioTracker(t, scenarioOwnerMultiEngineSQLite)
+ root := repoRoot(t)
+ specs := multiEngineSpecs(t, root, false)
+
+ t.Run("multi_engine_sqlite_candidate_pairs", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ for _, first := range specs[1:] {
+ for _, second := range specs[1:] {
+ if first.Implementation >= second.Implementation {
+ continue
+ }
+ require.True(t, first.servesProfile(profileSQLiteRuntime) && second.servesProfile(profileSQLiteRuntime),
+ "%s and %s must both declare %s", first.Implementation, second.Implementation, profileSQLiteRuntime)
+ databaseURL := filepath.Join(t.TempDir(), first.Implementation+"-"+second.Implementation+".sqlite")
+ firstAdapter := startAdapterCommandForProfile(t, root, databaseURL, "sqlite", profileSQLiteRuntime, first.Implementation, first, first.Command)
+ secondAdapter := startAdapterCommandForProfile(t, root, databaseURL, "sqlite", profileSQLiteRuntime, second.Implementation, second, second.Command)
+ scenarios.attach(firstAdapter, secondAdapter)
+ verifySQLiteCandidatePair(t, firstAdapter, secondAdapter)
+ }
+ }
+ })
+}
+
+// verifySQLiteCandidatePair runs the reference-independent SQLite checks
+// with two candidates in both roles.
+func verifySQLiteCandidatePair(t *testing.T, first, second *adapter) {
+ t.Helper()
+
+ pair := mixedPair{candidate: second, reference: first}
+ first.call(t, "migrate", map[string]any{}, nil)
+ verifySQLiteCrossLanguageInsertion(t, first, second)
+ verifyBatchInsertion(t, first, second)
+ verifyDifferentialJobCRUD(t, first, second)
+ verifyDifferentialListCursors(t, first, second, false)
+ verifyJobListCursorInterchange(t, first, second)
+ verifySQLiteTransactions(t, first, second)
+ verifySQLiteTimestampEncoding(t, first, second)
+ verifySQLiteCrossLanguageWork(t, first, second)
+ verifyUnknownKind(t, first, second)
+ verifySQLiteCompetingWorkers(t, first, second)
+ pair.eachDirection(func(controller, worker *adapter) {
+ verifyInsertNotificationWakeup(t, controller, worker)
+ verifyPauseResumeNotification(t, controller, worker)
+ verifyRemoteCancelNotification(t, controller, worker)
+ })
+ verifySQLiteLeadershipFailover(t, first, second)
+ pair.eachDirection(func(disabled, eligible *adapter) { verifyLeaderElectionDisabled(t, disabled, eligible) })
+ verifyResumableInteroperability(t, first, second)
+}
+
+func TestMultiEnginePerformanceGate(t *testing.T) { //nolint:paralleltest // Owns the shared PostgreSQL database.
+ // Every release-built engine shares one externally supplied database, so
+ // this test cannot run in parallel.
+ requireOptIn(t, "RIVER_CONFORMANCE_MULTI_ENGINE_PERFORMANCE")
+ databaseURL := requireEnv(t, "RIVER_CONFORMANCE_DATABASE_URL")
+ scenarios := newScenarioTracker(t, scenarioOwnerMultiEnginePerformance)
+ jobs := performanceJobs(t)
+ root := repoRoot(t)
+ engines := startEngines(t, root, databaseURL, "multi-engine-performance", multiEngineSpecs(t, root, true))
+ engines[0].adapter.call(t, "migrate", map[string]any{}, nil)
+ for _, mode := range []string{"enqueue", "worker", "mixed"} {
+ for _, current := range engines {
+ _ = runAdapterBenchmark(t, current.adapter, mode, max(20, jobs/10))
+ }
+ // Each candidate is compared with the slowest of the reference and
+ // the other candidates, so the gate catches a candidate that is out
+ // of line with the group without requiring every runtime to match
+ // the fastest one.
+ gateModeWithRetries(t, mode, func() []benchmarkMetrics {
+ metrics := make([]benchmarkMetrics, len(engines))
+ for index, current := range engines {
+ metrics[index] = medianBenchmark(t, current.adapter, mode, jobs)
+ }
+ return metrics
+ }, func(metrics []benchmarkMetrics) []string {
+ violations := make([]string, 0, 2*(len(engines)-1))
+ for index, candidate := range engines[1:] {
+ references := make([]benchmarkMetrics, 0, len(metrics)-1)
+ for otherIndex, other := range metrics {
+ if otherIndex != index+1 {
+ references = append(references, other)
+ }
+ }
+ violations = append(violations, benchmarkViolations(mode, candidate.spec, metrics[index+1], slowestMetrics(references))...)
+ }
+ return violations
+ }, func(metrics []benchmarkMetrics) {
+ for index, current := range engines {
+ t.Logf("%s %s: %.1f jobs/s p95=%s", mode, current.spec.Implementation, metrics[index].throughput, metrics[index].p95)
+ }
+ })
+ }
+ scenarios.pass("multi_engine_release_performance")
+}
+
+func TestMultiEngineSoak(t *testing.T) { //nolint:paralleltest // Owns the shared PostgreSQL database.
+ // Every engine shares one externally supplied database, so this test
+ // cannot run in parallel.
+ duration, err := time.ParseDuration(requireEnv(t, "RIVER_CONFORMANCE_MULTI_ENGINE_SOAK_DURATION"))
+ require.NoError(t, err)
+ require.Positive(t, duration)
+ databaseURL := requireEnv(t, "RIVER_CONFORMANCE_DATABASE_URL")
+ scenarios := newScenarioTracker(t, scenarioOwnerMultiEngineSoak)
+ root := repoRoot(t)
+ engines := startEngines(t, root, databaseURL, "multi-engine-soak", multiEngineSpecs(t, root, false))
+ adapters := make([]*adapter, len(engines))
+ clientIDs := make([]string, len(engines))
+ adapterByClientID := make(map[string]*adapter, len(engines))
+ for index, current := range engines {
+ adapters[index] = current.adapter
+ clientIDs[index] = current.clientID
+ adapterByClientID[current.clientID] = current.adapter
+ }
+ reference := adapters[0]
+ reference.call(t, "migrate", map[string]any{}, nil)
+ reference.call(t, "reset", map[string]any{}, nil)
+ for index, current := range adapters {
+ current.call(t, "start", map[string]any{"client_id": clientIDs[index], "max_workers": 8}, nil)
+ }
+
+ // Checked after the engines are built and started, so the budget
+ // accounts for that setup.
+ requireSoakBudget(t, "RIVER_CONFORMANCE_MULTI_ENGINE_SOAK_DURATION", duration)
+ deadline := time.Now().Add(duration)
+ jobsCompleted := 0
+ batch := 0
+ workersSeen := make(map[string]bool)
+ for time.Now().Before(deadline) {
+ batchSize := 10 * len(adapters)
+ ids := make([]int64, 0, batchSize)
+ for index := range batchSize {
+ var job normalizedJob
+ adapters[index%len(adapters)].call(t, "insert", map[string]any{
+ "behavior": "sleep", "duration_ms": 5,
+ "message": fmt.Sprintf("multi-engine-soak-%d", jobsCompleted+index),
+ }, &job)
+ ids = append(ids, job.ID)
+ }
+ for _, id := range ids {
+ var job normalizedJob
+ reference.call(t, "wait", map[string]any{"id": id}, &job)
+ require.Equal(t, "completed", job.State)
+ require.Equal(t, 1, job.Attempt)
+ require.Len(t, job.AttemptedBy, 1)
+ workersSeen[job.AttemptedBy[0]] = true
+ }
+ jobsCompleted += len(ids)
+ batch++
+ assertMultiEngineConnectionBounds(t, adapters)
+ if batch%10 == 0 {
+ leaderID := waitForLeader(t, reference, "")
+ leader := adapterByClientID[leaderID]
+ require.NotNil(t, leader)
+ leader.call(t, "stop", map[string]any{}, nil)
+ _ = waitForLeader(t, reference, leaderID)
+ leader.call(t, "start", map[string]any{"client_id": leaderID, "max_workers": 8}, nil)
+ }
+ }
+ for _, current := range adapters {
+ current.call(t, "stop", map[string]any{}, nil)
+ }
+ require.ElementsMatch(t, clientIDs, mapKeys(workersSeen), "every engine must work soak jobs")
+ t.Logf("completed %d multi-engine jobs over %s", jobsCompleted, duration)
+ scenarios.pass("multi_engine_soak")
+}
+
+func assertMultiEngineConnectionBounds(t *testing.T, adapters []*adapter) {
+ t.Helper()
+
+ total := 0
+ for _, current := range adapters {
+ var connections struct {
+ Count int `json:"count"`
+ }
+ current.call(t, "connection_count", map[string]any{}, &connections)
+ require.LessOrEqual(t, connections.Count, 20,
+ "%s database connections grew without bound", current.name)
+ total += connections.Count
+ }
+ require.LessOrEqual(t, total, 20*len(adapters), "multi-engine database connections grew without bound")
+}
+
+func performanceJobs(t *testing.T) int {
+ t.Helper()
+
+ jobs := 200
+ if value := os.Getenv("RIVER_CONFORMANCE_PERFORMANCE_JOBS"); value != "" {
+ parsed, err := strconv.Atoi(value)
+ require.NoError(t, err)
+ jobs = parsed
+ }
+ require.GreaterOrEqual(t, jobs, 20)
+ return jobs
+}
diff --git a/conformance/harness/performance_test.go b/conformance/harness/performance_test.go
new file mode 100644
index 000000000..92de52532
--- /dev/null
+++ b/conformance/harness/performance_test.go
@@ -0,0 +1,321 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "fmt"
+ "math"
+ "os"
+ "slices"
+ "sort"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+type benchmarkMetrics struct {
+ p95 time.Duration
+ throughput float64
+}
+
+// TestPerformanceGate compares release builds of the reference and the
+// candidate on the same host. Each attempt takes the median of three runs
+// per implementation, and a mode passes when any of up to three attempts
+// meets the candidate's declared bounds, so one noisy sample on a shared
+// runner cannot fail the gate while a sustained regression still does.
+func TestPerformanceGate(t *testing.T) { //nolint:paralleltest // Owns the shared PostgreSQL database.
+ // This opt-in release gate owns the shared conformance database for the
+ // duration of all same-host comparison runs.
+ requireOptIn(t, "RIVER_CONFORMANCE_PERFORMANCE")
+ databaseURL := requireEnv(t, "RIVER_CONFORMANCE_DATABASE_URL")
+ scenarios := newScenarioTracker(t, scenarioOwnerPerformance)
+ jobs := performanceJobs(t)
+
+ root := repoRoot(t)
+ goAdapter := startReferenceAdapter(t, root, databaseURL, "go-performance")
+ candidateSpec := conformanceCandidateSpec(t, root, true)
+ candidateAdapter := startCandidateAdapter(t, root, databaseURL, candidateSpec.Implementation+"-performance", candidateSpec, candidateSpec.Command)
+ goAdapter.call(t, "migrate", map[string]any{}, nil)
+
+ for _, mode := range []string{"enqueue", "worker", "mixed"} { //nolint:paralleltest // Modes share the conformance database.
+ t.Run("release_"+mode+"_performance", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ _ = runAdapterBenchmark(t, goAdapter, mode, max(20, jobs/10))
+ _ = runAdapterBenchmark(t, candidateAdapter, mode, max(20, jobs/10))
+ gateModeWithRetries(t, mode, func() []benchmarkMetrics {
+ return []benchmarkMetrics{
+ medianBenchmark(t, goAdapter, mode, jobs),
+ medianBenchmark(t, candidateAdapter, mode, jobs),
+ }
+ }, func(metrics []benchmarkMetrics) []string {
+ return benchmarkViolations(mode, candidateSpec, metrics[1], metrics[0])
+ }, func(metrics []benchmarkMetrics) {
+ t.Logf("%s: Go %.1f jobs/s p95=%s; %s %.1f jobs/s p95=%s",
+ mode, metrics[0].throughput, metrics[0].p95,
+ candidateSpec.Implementation, metrics[1].throughput, metrics[1].p95)
+ })
+ })
+ }
+}
+
+// gateModeWithRetries measures a benchmark mode up to
+// RIVER_CONFORMANCE_PERFORMANCE_ATTEMPTS times (default three) and fails the
+// test only if every attempt violates a bound.
+func gateModeWithRetries(
+ t *testing.T,
+ mode string,
+ measure func() []benchmarkMetrics,
+ violationsFunc func([]benchmarkMetrics) []string,
+ logFunc func([]benchmarkMetrics),
+) {
+ t.Helper()
+
+ attempts := 3
+ if value := os.Getenv("RIVER_CONFORMANCE_PERFORMANCE_ATTEMPTS"); value != "" {
+ parsed, err := strconv.Atoi(value)
+ require.NoError(t, err)
+ require.Positive(t, parsed)
+ attempts = parsed
+ }
+ var violations []string
+ for attempt := 1; attempt <= attempts; attempt++ {
+ metrics := measure()
+ logFunc(metrics)
+ violations = violationsFunc(metrics)
+ if len(violations) == 0 {
+ return
+ }
+ t.Logf("%s attempt %d/%d outside bounds: %v", mode, attempt, attempts, violations)
+ }
+ require.Empty(t, violations, "%s stayed outside its performance bounds in %d attempts", mode, attempts)
+}
+
+// benchmarkViolations compares a candidate's metrics with a reference using
+// the bounds its descriptor declares.
+func benchmarkViolations(mode string, spec adapterSpec, candidate, reference benchmarkMetrics) []string {
+ bound := spec.performanceBound(mode)
+ var violations []string
+ if minimum := reference.throughput * bound.MinThroughputRatio; candidate.throughput < minimum {
+ violations = append(violations, fmt.Sprintf("%s throughput %.1f jobs/s is below %.0f%% of %.1f jobs/s",
+ spec.Implementation, candidate.throughput, bound.MinThroughputRatio*100, reference.throughput))
+ }
+ if maximum := time.Duration(float64(reference.p95) * bound.MaxP95Ratio); candidate.p95 > maximum {
+ violations = append(violations, fmt.Sprintf("%s p95 %s exceeds %.2fx of %s",
+ spec.Implementation, candidate.p95, bound.MaxP95Ratio, reference.p95))
+ }
+ return violations
+}
+
+func medianBenchmark(t *testing.T, current *adapter, mode string, jobs int) benchmarkMetrics {
+ t.Helper()
+
+ runs := make([]benchmarkMetrics, 0, 3)
+ for range 3 {
+ runs = append(runs, runAdapterBenchmark(t, current, mode, jobs))
+ }
+ return medianMetrics(runs)
+}
+
+// slowestMetrics combines the lowest throughput and highest p95 of a group.
+func slowestMetrics(metrics []benchmarkMetrics) benchmarkMetrics {
+ slowest := metrics[0]
+ for _, current := range metrics[1:] {
+ slowest.throughput = min(slowest.throughput, current.throughput)
+ slowest.p95 = max(slowest.p95, current.p95)
+ }
+ return slowest
+}
+
+// requireSoakBudget fails a soak immediately when running for duration and
+// then finishing would outlast `go test`'s -timeout, instead of letting the
+// run panic on the timeout hours later.
+func requireSoakBudget(t *testing.T, variable string, duration time.Duration) {
+ t.Helper()
+
+ deadline, ok := t.Deadline()
+ if !ok {
+ return
+ }
+ if err := soakBudgetError(variable, duration, time.Until(deadline)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestMixedSoak(t *testing.T) { //nolint:paralleltest // Owns the shared PostgreSQL database.
+ // This opt-in soak owns the shared conformance database. CI sets 10m,
+ // release candidates use 1h, and the scheduled job uses 6h.
+ duration, err := time.ParseDuration(requireEnv(t, "RIVER_CONFORMANCE_SOAK_DURATION"))
+ require.NoError(t, err)
+ require.Positive(t, duration)
+ databaseURL := requireEnv(t, "RIVER_CONFORMANCE_DATABASE_URL")
+ scenarios := newScenarioTracker(t, scenarioOwnerSoak)
+
+ root := repoRoot(t)
+ goAdapter := startReferenceAdapter(t, root, databaseURL, "go-soak")
+ candidateSpec := conformanceCandidateSpec(t, root, false)
+ candidateAdapter := startCandidateAdapter(t, root, databaseURL, candidateSpec.Implementation+"-soak", candidateSpec, candidateSpec.Command)
+ goAdapter.call(t, "migrate", map[string]any{}, nil)
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ goAdapter.call(t, "start", map[string]any{"client_id": "go-soak", "max_workers": 8}, nil)
+ candidateAdapter.call(t, "start", map[string]any{
+ "client_id": candidateSpec.Implementation + "-soak", "max_workers": 8,
+ }, nil)
+
+ // Checked after the adapters are built and started, so the budget
+ // accounts for that setup.
+ requireSoakBudget(t, "RIVER_CONFORMANCE_SOAK_DURATION", duration)
+
+ // The soak samples each adapter's connection count after every round;
+ // the pool bound scenario judges the samples once the soak is over.
+ maxConnections := make(map[string]int)
+ t.Run("mixed_soak", func(t *testing.T) { //nolint:paralleltest // Shares the conformance database.
+ defer scenarios.record(t)
+
+ runMixedSoak(t, goAdapter, candidateAdapter, duration, maxConnections)
+ })
+
+ t.Run("mixed_connection_pool_bound", func(t *testing.T) { //nolint:paralleltest // Shares the conformance database.
+ defer scenarios.record(t)
+
+ for _, adapter := range []*adapter{goAdapter, candidateAdapter} {
+ count, ok := maxConnections[adapter.name]
+ require.True(t, ok, "mixed_soak must sample %s connections first", adapter.name)
+ require.LessOrEqual(t, count, 20, "%s database connections grew without bound", adapter.name)
+ }
+ })
+}
+
+// runMixedSoak inserts from both adapters and works on the candidate until
+// duration elapses, recording each adapter's largest connection count.
+func runMixedSoak(t *testing.T, goAdapter, candidateAdapter *adapter, duration time.Duration, maxConnections map[string]int) {
+ t.Helper()
+
+ deadline := time.Now().Add(duration)
+ jobsCompleted := 0
+ for time.Now().Before(deadline) {
+ ids := make([]int64, 0, 20)
+ for index := range 20 {
+ inserter := goAdapter
+ if index%2 == 1 {
+ inserter = candidateAdapter
+ }
+ var job normalizedJob
+ inserter.call(t, "insert", map[string]any{"message": fmt.Sprintf("soak-%d", jobsCompleted+index)}, &job)
+ ids = append(ids, job.ID)
+ }
+ for _, id := range ids {
+ var job normalizedJob
+ candidateAdapter.call(t, "wait", map[string]any{"id": id}, &job)
+ require.Equal(t, "completed", job.State)
+ require.Equal(t, 1, job.Attempt)
+ require.Len(t, job.AttemptedBy, 1)
+ }
+ jobsCompleted += len(ids)
+ for _, adapter := range []*adapter{goAdapter, candidateAdapter} {
+ var connections struct {
+ Count int `json:"count"`
+ }
+ adapter.call(t, "connection_count", map[string]any{}, &connections)
+ maxConnections[adapter.name] = max(maxConnections[adapter.name], connections.Count)
+ }
+ }
+ goAdapter.call(t, "stop", map[string]any{}, nil)
+ candidateAdapter.call(t, "stop", map[string]any{}, nil)
+ t.Logf("completed %d mixed jobs over %s", jobsCompleted, duration)
+}
+
+func medianMetrics(runs []benchmarkMetrics) benchmarkMetrics {
+ throughputs := make([]float64, len(runs))
+ p95s := make([]time.Duration, len(runs))
+ for index, run := range runs {
+ throughputs[index] = run.throughput
+ p95s[index] = run.p95
+ }
+ sort.Float64s(throughputs)
+ slices.Sort(p95s)
+ return benchmarkMetrics{p95: p95s[len(p95s)/2], throughput: throughputs[len(throughputs)/2]}
+}
+
+func runAdapterBenchmark(t *testing.T, adapter *adapter, mode string, jobs int) benchmarkMetrics {
+ t.Helper()
+
+ // A small deterministic work interval keeps worker and mixed p95 focused on
+ // the full execution pipeline without making a sub-millisecond no-op
+ // baseline (and host scheduler jitter) determine the release result.
+ const workDuration = 10 * time.Millisecond
+
+ adapter.call(t, "reset", map[string]any{}, nil)
+ if mode == "enqueue" {
+ var result struct {
+ DurationNS int64 `json:"duration_ns"`
+ P95NS int64 `json:"p95_ns"`
+ }
+ adapter.call(t, "benchmark_enqueue", map[string]any{"jobs": jobs}, &result)
+ duration := time.Duration(result.DurationNS)
+ return benchmarkMetrics{
+ p95: time.Duration(result.P95NS),
+ throughput: float64(jobs) / duration.Seconds(),
+ }
+ }
+ ids := make([]int64, 0, jobs)
+ latencies := make([]time.Duration, 0, jobs)
+ if mode == "worker" {
+ for index := range jobs {
+ var job normalizedJob
+ adapter.call(t, "insert", map[string]any{
+ "behavior": "sleep",
+ "duration_ms": workDuration.Milliseconds(),
+ "message": fmt.Sprintf("worker-%d", index),
+ }, &job)
+ ids = append(ids, job.ID)
+ }
+ }
+ maxWorkers := 32
+ if mode == "mixed" {
+ // Keep the producer/worker overlap from turning p95 into a queue-depth
+ // comparison; throughput still includes all concurrent insertion and
+ // execution work.
+ maxWorkers = 128
+ }
+ adapter.call(t, "start", map[string]any{
+ "client_id": adapter.name + "-benchmark", "max_workers": maxWorkers,
+ }, nil)
+ startedAt := time.Now()
+ if mode == "mixed" {
+ for index := range jobs {
+ var job normalizedJob
+ adapter.call(t, "insert", map[string]any{
+ "behavior": "sleep",
+ "duration_ms": workDuration.Milliseconds(),
+ "message": fmt.Sprintf("%s-%d", mode, index),
+ }, &job)
+ ids = append(ids, job.ID)
+ }
+ }
+ for _, id := range ids {
+ var job normalizedJob
+ adapter.call(t, "wait", map[string]any{"id": id}, &job)
+ startField := job.CreatedAt
+ if mode == "worker" {
+ require.NotNil(t, job.AttemptedAt)
+ startField = *job.AttemptedAt
+ }
+ require.NotNil(t, job.FinalizedAt)
+ startTime, err := time.Parse(time.RFC3339Nano, startField)
+ require.NoError(t, err)
+ finalizedAt, err := time.Parse(time.RFC3339Nano, *job.FinalizedAt)
+ require.NoError(t, err)
+ latencies = append(latencies, finalizedAt.Sub(startTime))
+ }
+ adapter.call(t, "stop", map[string]any{}, nil)
+ elapsed := time.Since(startedAt)
+ slices.Sort(latencies)
+ p95Index := max(0, int(math.Ceil(float64(len(latencies))*0.95))-1)
+ return benchmarkMetrics{
+ p95: latencies[p95Index],
+ throughput: float64(jobs) / elapsed.Seconds(),
+ }
+}
diff --git a/conformance/harness/postgres_observer_test.go b/conformance/harness/postgres_observer_test.go
new file mode 100644
index 000000000..0f6b51ea8
--- /dev/null
+++ b/conformance/harness/postgres_observer_test.go
@@ -0,0 +1,133 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/stretchr/testify/require"
+)
+
+// harnessApplicationName identifies the harness's own observation
+// connections so fault injection that targets adapters never disconnects them.
+const harnessApplicationName = "river-conformance-harness"
+
+// postgresObserver makes observations that one adapter cannot make about
+// another through the protocol: lock waits, transaction ID consumption, and
+// raw notification delivery. It never writes River tables.
+type postgresObserver struct {
+ databaseURL string
+ pool *pgxpool.Pool
+}
+
+func newPostgresObserver(t *testing.T, databaseURL string) *postgresObserver {
+ t.Helper()
+
+ config, err := pgxpool.ParseConfig(databaseURL)
+ require.NoError(t, err)
+ config.ConnConfig.RuntimeParams["application_name"] = harnessApplicationName
+ config.MaxConns = 2
+ pool, err := pgxpool.NewWithConfig(context.Background(), config)
+ require.NoError(t, err)
+ t.Cleanup(pool.Close)
+ return &postgresObserver{databaseURL: databaseURL, pool: pool}
+}
+
+// currentSchema returns the schema River uses when no schema is configured.
+// Notification channels are prefixed with it.
+func (observer *postgresObserver) currentSchema(t *testing.T) string {
+ t.Helper()
+
+ var schema string
+ require.NoError(t, observer.pool.QueryRow(context.Background(), "SELECT current_schema()").Scan(&schema))
+ return schema
+}
+
+// nextTransactionID returns the next transaction ID PostgreSQL will assign.
+// Read-only statements do not consume transaction IDs, so the difference
+// between two readings counts write transactions in between.
+func (observer *postgresObserver) nextTransactionID(t *testing.T) int64 {
+ t.Helper()
+
+ var next int64
+ require.NoError(t, observer.pool.QueryRow(context.Background(),
+ "SELECT pg_snapshot_xmax(pg_current_snapshot())::text::bigint",
+ ).Scan(&next))
+ return next
+}
+
+// waitForLockWait waits until a backend of the given application is blocked
+// on a heavyweight lock, which proves a request is waiting for another
+// transaction rather than merely being slow.
+func (observer *postgresObserver) waitForLockWait(t *testing.T, applicationName string) {
+ t.Helper()
+
+ require.NotEmpty(t, applicationName)
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ var waiting int
+ require.NoError(t, observer.pool.QueryRow(context.Background(), `
+ SELECT count(*)
+ FROM pg_stat_activity
+ WHERE datname = current_database()
+ AND application_name = $1
+ AND state = 'active'
+ AND wait_event_type = 'Lock'`,
+ applicationName,
+ ).Scan(&waiting))
+ if waiting > 0 {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatalf("no %s backend blocked on a lock", applicationName)
+}
+
+// postgresNotificationListener receives raw notifications for one channel on
+// a dedicated harness connection.
+type postgresNotificationListener struct {
+ channel string
+ conn *pgx.Conn
+}
+
+// listen subscribes to a raw notification channel such as
+// "public.river_insert".
+func (observer *postgresObserver) listen(t *testing.T, channel string) *postgresNotificationListener {
+ t.Helper()
+
+ config, err := pgx.ParseConfig(observer.databaseURL)
+ require.NoError(t, err)
+ config.RuntimeParams["application_name"] = harnessApplicationName
+ conn, err := pgx.ConnectConfig(context.Background(), config)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = conn.Close(context.Background()) })
+ _, err = conn.Exec(context.Background(), "LISTEN "+pgx.Identifier{channel}.Sanitize())
+ require.NoError(t, err)
+ return &postgresNotificationListener{channel: channel, conn: conn}
+}
+
+// receiveUntilMarker sends a marker notification on the listener's channel
+// and returns every payload delivered before it. PostgreSQL delivers
+// notifications in commit order, so any notification committed before the
+// marker is guaranteed to be returned.
+func (listener *postgresNotificationListener) receiveUntilMarker(t *testing.T, observer *postgresObserver, marker string) []string {
+ t.Helper()
+
+ _, err := observer.pool.Exec(context.Background(), "SELECT pg_notify($1, $2)", listener.channel, marker)
+ require.NoError(t, err)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ var payloads []string
+ for {
+ notification, err := listener.conn.WaitForNotification(ctx)
+ require.NoError(t, err, "marker notification %q was not delivered", marker)
+ if notification.Payload == marker {
+ return payloads
+ }
+ payloads = append(payloads, notification.Payload)
+ }
+}
diff --git a/conformance/harness/process_test.go b/conformance/harness/process_test.go
new file mode 100644
index 000000000..a359bd198
--- /dev/null
+++ b/conformance/harness/process_test.go
@@ -0,0 +1,468 @@
+package harness_test
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ // adapterExitTimeout bounds how long an adapter may take to exit after
+ // the harness closes its stdin. Adapters stop a running client on the
+ // way out, which the reference bounds at ten seconds.
+ adapterExitTimeout = 30 * time.Second
+
+ // adapterKillTimeout bounds how long a killed adapter may take to be
+ // reaped, including adapterPipeCloseDelay.
+ adapterKillTimeout = 15 * time.Second
+
+ // adapterRequestTimeout bounds how long an adapter may take to answer
+ // one request. Every adapter-side wait is bounded well below it (the
+ // reference's `wait` gives up after ten seconds), so it only fires for
+ // an adapter that has stopped making progress, well before `go test`'s
+ // own timeout would abort the whole run without naming it.
+ adapterRequestTimeout = 2 * time.Minute
+
+ // maxApplicationNameLength is PostgreSQL's application_name limit
+ // (NAMEDATALEN - 1). The server silently truncates longer names.
+ maxApplicationNameLength = 63
+
+ // adapterPipeCloseDelay bounds how long the harness waits for an exited
+ // adapter's output pipes to close. A descendant process that inherited
+ // them, such as the adapter under a wrapper command, would otherwise keep
+ // the wait open indefinitely.
+ adapterPipeCloseDelay = 5 * time.Second
+)
+
+var (
+ // errAdapterExitTimeout reports an adapter that had to be killed because
+ // it didn't exit within its time bound.
+ errAdapterExitTimeout = errors.New("adapter did not exit")
+
+ // errAdapterStopped reports an adapter whose output ended while the
+ // harness waited for a response.
+ errAdapterStopped = errors.New("adapter stopped")
+
+ // errAdapterUnresponsive reports an adapter that didn't answer a request
+ // within adapterRequestTimeout.
+ errAdapterUnresponsive = errors.New("adapter did not answer")
+)
+
+// adapterProcessSequence numbers the adapter processes this harness process
+// starts, so each gets its own application_name.
+var adapterProcessSequence atomic.Int64 //nolint:gochecknoglobals // shared by every test in the process
+
+// adapterProcess is one running adapter child process and its protocol
+// pipes. A goroutine reads output lines so a response can be awaited with a
+// bound. The exit status is collected at most once, by whichever of kill or
+// shutdown first waits for it.
+type adapterProcess struct {
+ command *exec.Cmd
+ exitErr error
+ exited chan struct{}
+ input io.WriteCloser
+ lines chan []byte
+ output *os.File
+ // readErr is the output read error, if any, once lines is closed.
+ readErr error
+ released chan struct{}
+ releaseOnce sync.Once
+ stderr lockedBuffer
+ // unresponsive is set once a request times out. The next line of output
+ // may answer the abandoned request, so no later exchange can be trusted.
+ unresponsive error
+ waitOnce sync.Once
+}
+
+// startAdapterProcess starts command with its stdin and stdout connected to
+// the harness and its stderr captured.
+func startAdapterProcess(command *exec.Cmd) (*adapterProcess, error) {
+ input, err := command.StdinPipe()
+ if err != nil {
+ return nil, err
+ }
+ // The harness owns the stdout pipe rather than using StdoutPipe, which
+ // Wait closes as soon as the process exits, possibly before its last
+ // output has been read.
+ output, outputWriter, err := os.Pipe()
+ if err != nil {
+ return nil, err
+ }
+ process := &adapterProcess{
+ command: command,
+ exited: make(chan struct{}),
+ input: input,
+ lines: make(chan []byte),
+ output: output,
+ released: make(chan struct{}),
+ }
+ command.Stdout = outputWriter
+ command.Stderr = &process.stderr
+ command.WaitDelay = adapterPipeCloseDelay
+ err = command.Start()
+ // The child holds its own copy of the write end; closing the harness's
+ // copy lets the output reach EOF once the child is gone.
+ _ = outputWriter.Close()
+ if err != nil {
+ _ = output.Close()
+ return nil, err
+ }
+ go process.readLines()
+ return process, nil
+}
+
+// exchange writes one request line and waits up to timeout for the next
+// line of output. Once a request times out, the process is out of step with
+// the protocol, so every later exchange fails with the same error.
+func (process *adapterProcess) exchange(request []byte, timeout time.Duration) ([]byte, error) {
+ if process.unresponsive != nil {
+ return nil, process.unresponsive
+ }
+ if _, err := process.input.Write(append(request, '\n')); err != nil {
+ return nil, fmt.Errorf("write request: %w", err)
+ }
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+ select {
+ case line, ok := <-process.lines:
+ if !ok {
+ return nil, errors.Join(errAdapterStopped, process.readErr)
+ }
+ return line, nil
+ case <-timer.C:
+ process.unresponsive = fmt.Errorf("%w within %s", errAdapterUnresponsive, timeout)
+ return nil, process.unresponsive
+ }
+}
+
+// kill kills the process and waits up to timeout for it to be reaped.
+func (process *adapterProcess) kill(timeout time.Duration) error {
+ if err := process.command.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+ return fmt.Errorf("kill adapter: %w", err)
+ }
+ if !process.waitForExit(timeout) {
+ return fmt.Errorf("%w within %s of being killed", errAdapterExitTimeout, timeout)
+ }
+ return nil
+}
+
+// readLines delivers each line of output to exchange until the output ends
+// or the process is released.
+func (process *adapterProcess) readLines() {
+ defer close(process.lines)
+ scanner := bufio.NewScanner(process.output)
+ scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
+ for scanner.Scan() {
+ select {
+ case process.lines <- bytes.Clone(scanner.Bytes()):
+ case <-process.released:
+ return
+ }
+ }
+ process.readErr = scanner.Err()
+}
+
+// release stops reading output from a process that has exited. A descendant
+// that inherited stdout could otherwise keep the reader open.
+func (process *adapterProcess) release() {
+ process.releaseOnce.Do(func() {
+ close(process.released)
+ _ = process.output.Close()
+ })
+}
+
+// shutdown closes the process's stdin, which asks an adapter to exit, and
+// waits up to exitTimeout for it to do so. An adapter still running after
+// that is killed and reported with errAdapterExitTimeout, so one wedged
+// adapter fails its test instead of hanging the whole run. Otherwise
+// shutdown returns any error closing stdin joined with the exit error.
+func (process *adapterProcess) shutdown(exitTimeout time.Duration) error {
+ defer process.release()
+
+ closeErr := process.input.Close()
+ if !process.waitForExit(exitTimeout) {
+ return errors.Join(
+ fmt.Errorf("%w within %s of closing its stdin and was killed", errAdapterExitTimeout, exitTimeout),
+ process.kill(adapterKillTimeout),
+ )
+ }
+ return errors.Join(closeErr, process.exitErr)
+}
+
+// waitForExit waits up to timeout for the process to exit and its stderr to
+// close, and reports whether it did. The exit status is recorded in exitErr.
+func (process *adapterProcess) waitForExit(timeout time.Duration) bool {
+ process.waitOnce.Do(func() {
+ go func() {
+ process.exitErr = process.command.Wait()
+ close(process.exited)
+ }()
+ })
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+ select {
+ case <-process.exited:
+ return true
+ case <-timer.C:
+ return false
+ }
+}
+
+// lockedBuffer collects an adapter's stderr, which the process writes while
+// the harness reads it for failure messages.
+type lockedBuffer struct {
+ buffer bytes.Buffer
+ mu sync.Mutex
+}
+
+func (buffer *lockedBuffer) String() string {
+ buffer.mu.Lock()
+ defer buffer.mu.Unlock()
+
+ return buffer.buffer.String()
+}
+
+func (buffer *lockedBuffer) Write(data []byte) (int, error) {
+ buffer.mu.Lock()
+ defer buffer.mu.Unlock()
+
+ return buffer.buffer.Write(data)
+}
+
+// processApplicationName returns a PostgreSQL application_name for one new
+// adapter process: the descriptor's base name followed by this harness
+// process's ID and a sequence number, so it names no other adapter attached
+// to the database.
+func processApplicationName(base string) (string, error) {
+ if base == "" {
+ return "", errors.New("adapter has no base application_name")
+ }
+ name := fmt.Sprintf("%s-%d-%d", base, os.Getpid(), adapterProcessSequence.Add(1))
+ if len(name) > maxApplicationNameLength {
+ return "", fmt.Errorf("per-process application_name %q is longer than PostgreSQL's %d byte limit; shorten the descriptor's application_name",
+ name, maxApplicationNameLength)
+ }
+ return name, nil
+}
+
+// resolveApplicationName returns the application_name identifying an
+// adapter process's connections, given the name the harness requested and
+// the one its handshake reported. An adapter that reports no name keeps the
+// descriptor's shared fallback; one that reports a different name than
+// requested is misconfigured.
+func resolveApplicationName(requested, fallback, reported string) (string, error) {
+ switch reported {
+ case "":
+ return fallback, nil
+ case requested:
+ return requested, nil
+ default:
+ return "", fmt.Errorf("handshake reported application_name %q, but the harness requested %q", reported, requested)
+ }
+}
+
+func TestAdapterProcess(t *testing.T) {
+ t.Parallel()
+
+ // start runs this test binary as a fake adapter with the given
+ // behavior; see TestAdapterProcessFake.
+ start := func(t *testing.T, behavior string) *adapterProcess {
+ t.Helper()
+
+ //nolint:gosec // Reruns this test binary with fixed arguments.
+ command := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestAdapterProcessFake$")
+ command.Env = append(os.Environ(), "RIVER_CONFORMANCE_FAKE_ADAPTER="+behavior)
+ process, err := startAdapterProcess(command)
+ require.NoError(t, err)
+ return process
+ }
+
+ t.Run("ExchangeReportsStoppedAdapter", func(t *testing.T) {
+ t.Parallel()
+
+ process := start(t, "exit_on_request")
+
+ _, err := process.exchange([]byte("request"), adapterRequestTimeout)
+ require.ErrorIs(t, err, errAdapterStopped)
+ require.NoError(t, process.shutdown(adapterExitTimeout))
+ })
+
+ t.Run("ExchangeReturnsResponse", func(t *testing.T) {
+ t.Parallel()
+
+ process := start(t, "echo")
+
+ for _, request := range []string{"first", "second"} {
+ response, err := process.exchange([]byte(request), adapterRequestTimeout)
+ require.NoError(t, err)
+ require.Equal(t, request, string(response))
+ }
+ require.NoError(t, process.shutdown(adapterExitTimeout))
+ })
+
+ t.Run("ExchangeTimesOutUnresponsiveAdapter", func(t *testing.T) {
+ t.Parallel()
+
+ process := start(t, "ignore_requests")
+
+ _, err := process.exchange([]byte("first"), 100*time.Millisecond)
+ require.ErrorIs(t, err, errAdapterUnresponsive)
+ require.EqualError(t, err, "adapter did not answer within 100ms")
+
+ // A later request would be matched with the abandoned one's answer,
+ // so it fails immediately without being sent.
+ startedAt := time.Now()
+ _, err = process.exchange([]byte("second"), adapterRequestTimeout)
+ require.ErrorIs(t, err, errAdapterUnresponsive)
+ require.Less(t, time.Since(startedAt), time.Second)
+ require.NoError(t, process.shutdown(adapterExitTimeout))
+ })
+
+ t.Run("KillReapsProcess", func(t *testing.T) {
+ t.Parallel()
+
+ process := start(t, "ignore_eof")
+
+ require.NoError(t, process.kill(adapterKillTimeout))
+ require.NotNil(t, process.command.ProcessState)
+ require.False(t, process.command.ProcessState.Success())
+ })
+
+ t.Run("ShutdownKillsWedgedAdapter", func(t *testing.T) {
+ t.Parallel()
+
+ process := start(t, "ignore_eof")
+
+ err := process.shutdown(100 * time.Millisecond)
+ require.ErrorIs(t, err, errAdapterExitTimeout)
+ require.ErrorContains(t, err, "within 100ms of closing its stdin and was killed")
+ require.NotNil(t, process.command.ProcessState, "shutdown must reap the killed adapter")
+ })
+
+ t.Run("ShutdownReportsExitError", func(t *testing.T) {
+ t.Parallel()
+
+ process := start(t, "fail_on_eof")
+
+ err := process.shutdown(adapterExitTimeout)
+ require.Error(t, err)
+ require.NotErrorIs(t, err, errAdapterExitTimeout)
+ var exitErr *exec.ExitError
+ require.ErrorAs(t, err, &exitErr)
+ require.Equal(t, 3, exitErr.ExitCode())
+ })
+
+ t.Run("ShutdownWaitsForGracefulExit", func(t *testing.T) {
+ t.Parallel()
+
+ process := start(t, "exit_on_eof")
+
+ require.NoError(t, process.shutdown(adapterExitTimeout))
+ require.True(t, process.command.ProcessState.Success())
+ })
+}
+
+// TestAdapterProcessFake is not a test on its own. TestAdapterProcess runs
+// the test binary with RIVER_CONFORMANCE_FAKE_ADAPTER set to make this
+// function behave like an adapter that answers, ignores, or stops on
+// requests, and that exits, fails, or wedges once its stdin closes.
+func TestAdapterProcessFake(t *testing.T) {
+ t.Parallel()
+
+ behavior := os.Getenv("RIVER_CONFORMANCE_FAKE_ADAPTER")
+ if behavior == "" {
+ return
+ }
+ input := bufio.NewScanner(os.Stdin)
+ for input.Scan() {
+ switch behavior {
+ case "echo":
+ fmt.Println(input.Text())
+ case "exit_on_request":
+ os.Exit(0)
+ }
+ }
+ switch behavior {
+ case "echo", "exit_on_eof", "ignore_requests":
+ os.Exit(0)
+ case "fail_on_eof":
+ os.Exit(3)
+ case "ignore_eof":
+ time.Sleep(time.Minute)
+ }
+ os.Exit(2)
+}
+
+func TestProcessApplicationName(t *testing.T) {
+ t.Parallel()
+
+ t.Run("DistinctPerProcess", func(t *testing.T) {
+ t.Parallel()
+
+ first, err := processApplicationName("river-conformance-rust")
+ require.NoError(t, err)
+ second, err := processApplicationName("river-conformance-rust")
+ require.NoError(t, err)
+
+ require.NotEqual(t, first, second)
+ require.True(t, strings.HasPrefix(first, "river-conformance-rust-"), first)
+ require.True(t, strings.HasPrefix(second, "river-conformance-rust-"), second)
+ })
+
+ t.Run("RejectsEmptyBase", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := processApplicationName("")
+ require.EqualError(t, err, "adapter has no base application_name")
+ })
+
+ t.Run("RejectsNamesPostgreSQLWouldTruncate", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := processApplicationName("river-conformance-" + strings.Repeat("x", 40))
+ require.ErrorContains(t, err, "longer than PostgreSQL's 63 byte limit")
+ })
+}
+
+func TestResolveApplicationName(t *testing.T) {
+ t.Parallel()
+
+ const (
+ fallback = "river-conformance-rust"
+ requested = "river-conformance-rust-100-1"
+ )
+
+ t.Run("FallsBackWhenNotReported", func(t *testing.T) {
+ t.Parallel()
+
+ name, err := resolveApplicationName(requested, fallback, "")
+ require.NoError(t, err)
+ require.Equal(t, fallback, name)
+ })
+
+ t.Run("RejectsMismatch", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := resolveApplicationName(requested, fallback, fallback)
+ require.EqualError(t, err, `handshake reported application_name "river-conformance-rust", but the harness requested "river-conformance-rust-100-1"`)
+ })
+
+ t.Run("UsesReportedName", func(t *testing.T) {
+ t.Parallel()
+
+ name, err := resolveApplicationName(requested, fallback, requested)
+ require.NoError(t, err)
+ require.Equal(t, requested, name)
+ })
+}
diff --git a/conformance/harness/resilience_test.go b/conformance/harness/resilience_test.go
new file mode 100644
index 000000000..d3fb46049
--- /dev/null
+++ b/conformance/harness/resilience_test.go
@@ -0,0 +1,704 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "context"
+ "io"
+ "net"
+ "net/url"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/stretchr/testify/require"
+)
+
+// TestResilienceConformance checks that each implementation keeps working
+// through database faults and reaches Go's job states on non-happy paths:
+// an unavailable database, transient completion errors, row locks, hard
+// shutdown, and rows another implementation may consider malformed. Faults
+// are injected by the harness itself (a TCP proxy and direct SQL) rather than
+// through adapter methods, so every implementation runs the same scenarios.
+func TestResilienceConformance(t *testing.T) { //nolint:paralleltest // Owns the shared PostgreSQL database.
+ databaseURL := requireEnv(t, "RIVER_CONFORMANCE_DATABASE_URL")
+ scenarios := newScenarioTracker(t, scenarioOwnerResilience)
+ ctx := context.Background()
+ repositoryRoot := repoRoot(t)
+ candidateSpec := conformanceCandidateSpec(t, repositoryRoot, false)
+
+ database, err := pgx.Connect(ctx, databaseURL)
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, database.Close(context.Background())) })
+ observer, err := pgx.Connect(ctx, databaseURL)
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, observer.Close(context.Background())) })
+
+ // The reference adapter always reaches the database directly. Every
+ // worker under test reaches it through its own fault proxy.
+ reference := startReferenceAdapter(t, repositoryRoot, databaseURL, "go")
+ reference.call(t, "migrate", map[string]any{}, nil)
+ goProxy := startFaultProxy(ctx, t, databaseURL)
+ candidateProxy := startFaultProxy(ctx, t, databaseURL)
+ workers := []resilienceWorker{
+ {
+ adapter: startReferenceAdapter(t, repositoryRoot, goProxy.url, "go-proxied"),
+ name: "go",
+ proxy: goProxy,
+ },
+ {
+ adapter: startCandidateAdapter(t, repositoryRoot, candidateProxy.url, candidateSpec.Implementation, candidateSpec, candidateSpec.Command),
+ name: candidateSpec.Implementation,
+ proxy: candidateProxy,
+ },
+ }
+
+ scenarios.attach(reference, workers[0].adapter, workers[1].adapter)
+
+ // Subtests share one database and run in order, so none are parallel.
+ t.Run("database_unavailable_reconnect", func(t *testing.T) { //nolint:paralleltest // Shares the conformance database.
+ defer scenarios.record(t)
+
+ for _, worker := range workers {
+ reference.call(t, "reset", map[string]any{}, nil)
+ worker.adapter.call(t, "start", map[string]any{"client_id": worker.name + "-outage"}, nil)
+ barrier := worker.name + "-outage"
+ worker.adapter.call(t, "barrier_create", map[string]any{"name": barrier}, nil)
+ var inFlight, during, observed normalizedJob
+ reference.call(t, "insert", map[string]any{"behavior": "barrier_wait", "message": barrier}, &inFlight)
+ reference.call(t, "wait", map[string]any{"id": inFlight.ID, "states": []string{"running"}}, &observed)
+
+ // The database becomes unreachable for the worker: established
+ // connections reset and new ones are refused. The in-flight job
+ // finishes while its completion cannot be written, and new work
+ // arrives while the worker cannot see it.
+ worker.proxy.takeDown()
+ worker.adapter.call(t, "barrier_release", map[string]any{"name": barrier}, nil)
+ reference.call(t, "insert", map[string]any{"message": "inserted during outage"}, &during)
+ worker.proxy.waitForRejections(t, 3)
+ worker.proxy.restore()
+
+ for _, id := range []int64{inFlight.ID, during.ID} {
+ job := waitForReferenceCompleted(t, reference, id, time.Minute)
+ require.Equal(t, 1, job.Attempt, "%s job %d was rescued or retried", worker.name, id)
+ require.Empty(t, job.Errors, "%s job %d", worker.name, id)
+ }
+ worker.adapter.call(t, "stop", map[string]any{}, nil)
+ }
+ })
+
+ t.Run("completion_transient_failure_retry", func(t *testing.T) { //nolint:paralleltest // Shares the conformance database.
+ defer scenarios.record(t)
+
+ for _, worker := range workers {
+ reference.call(t, "reset", map[string]any{}, nil)
+ // Fail the first running-to-completed transition with a
+ // serialization failure. The sequence advances outside the
+ // aborted statement, so exactly one attempt fails.
+ execSQL(ctx, t, database, `
+ CREATE SEQUENCE river_resilience_completion_fault;
+ CREATE FUNCTION river_resilience_fail_completion_once() RETURNS trigger
+ LANGUAGE plpgsql AS $$ BEGIN
+ IF OLD.state = 'running' AND NEW.state = 'completed'
+ AND nextval('river_resilience_completion_fault') = 1 THEN
+ RAISE EXCEPTION 'injected completion failure' USING ERRCODE = '40001';
+ END IF;
+ RETURN NEW;
+ END $$;
+ CREATE TRIGGER river_resilience_fail_completion_once BEFORE UPDATE ON river_job
+ FOR EACH ROW EXECUTE FUNCTION river_resilience_fail_completion_once()`)
+ t.Cleanup(func() {
+ execSQL(ctx, t, database, `
+ DROP TRIGGER IF EXISTS river_resilience_fail_completion_once ON river_job;
+ DROP FUNCTION IF EXISTS river_resilience_fail_completion_once();
+ DROP SEQUENCE IF EXISTS river_resilience_completion_fault`)
+ })
+
+ worker.adapter.call(t, "start", map[string]any{"client_id": worker.name + "-completion-retry"}, nil)
+ var inserted normalizedJob
+ reference.call(t, "insert", map[string]any{"message": "transient completion failure"}, &inserted)
+ job := waitForReferenceCompleted(t, reference, inserted.ID, 30*time.Second)
+ require.Equal(t, 1, job.Attempt, worker.name)
+ require.Empty(t, job.Errors, worker.name)
+ var injected int64
+ require.NoError(t, database.QueryRow(ctx,
+ "SELECT last_value FROM river_resilience_completion_fault").Scan(&injected))
+ require.GreaterOrEqual(t, injected, int64(2), "%s: the injected failure never fired", worker.name)
+ worker.adapter.call(t, "stop", map[string]any{}, nil)
+ execSQL(ctx, t, database, `
+ DROP TRIGGER river_resilience_fail_completion_once ON river_job;
+ DROP FUNCTION river_resilience_fail_completion_once();
+ DROP SEQUENCE river_resilience_completion_fault`)
+ }
+ })
+
+ t.Run("completion_row_lock_wait", func(t *testing.T) { //nolint:paralleltest // Shares the conformance database.
+ defer scenarios.record(t)
+
+ for _, worker := range workers {
+ reference.call(t, "reset", map[string]any{}, nil)
+ worker.adapter.call(t, "start", map[string]any{"client_id": worker.name + "-row-lock"}, nil)
+ barrier := worker.name + "-row-lock"
+ worker.adapter.call(t, "barrier_create", map[string]any{"name": barrier}, nil)
+ var inserted, observed normalizedJob
+ reference.call(t, "insert", map[string]any{"behavior": "barrier_wait", "message": barrier}, &inserted)
+ reference.call(t, "wait", map[string]any{"id": inserted.ID, "states": []string{"running"}}, &observed)
+
+ locker, err := database.Begin(ctx)
+ require.NoError(t, err)
+ _, err = locker.Exec(ctx, "SELECT 1 FROM river_job WHERE id = $1 FOR UPDATE", inserted.ID)
+ require.NoError(t, err)
+ worker.adapter.call(t, "barrier_release", map[string]any{"name": barrier}, nil)
+ pollUntil(t, 30*time.Second, worker.name+" completion waiting on the row lock", func() bool {
+ var waiting int
+ require.NoError(t, observer.QueryRow(ctx,
+ "SELECT count(*) FROM pg_locks WHERE NOT granted AND locktype = 'transactionid'").Scan(&waiting))
+ return waiting > 0
+ })
+ require.NoError(t, locker.Commit(ctx))
+
+ job := waitForReferenceCompleted(t, reference, inserted.ID, 30*time.Second)
+ require.Equal(t, 1, job.Attempt, worker.name)
+ worker.adapter.call(t, "stop", map[string]any{}, nil)
+ }
+ })
+
+ // The hard shutdown also stops a job whose cancellation never reached
+ // the worker; the next scenario checks that job.
+ cancelAttemptedAfterShutdown := make(map[string]normalizedJob)
+ t.Run("hard_shutdown_soft_stop_classification", func(t *testing.T) { //nolint:paralleltest // Shares the conformance database.
+ defer scenarios.record(t)
+
+ for _, worker := range workers {
+ reference.call(t, "reset", map[string]any{}, nil)
+ worker.adapter.call(t, "start", map[string]any{
+ "client_id": worker.name + "-hard-shutdown", "max_workers": 4,
+ }, nil)
+ jobs := make(map[string]normalizedJob)
+ for _, behavior := range []string{"cooperative_cancel", "cancel_attempted", "cancel_error", "cancel_panic"} {
+ insertBehavior := behavior
+ if behavior == "cancel_attempted" {
+ insertBehavior = "cooperative_cancel"
+ }
+ var inserted, observed normalizedJob
+ reference.call(t, "insert", map[string]any{"behavior": insertBehavior, "message": behavior}, &inserted)
+ reference.call(t, "wait", map[string]any{"id": inserted.ID, "states": []string{"running"}}, &observed)
+ jobs[behavior] = inserted
+ }
+ // A cancellation whose notification never reached the worker.
+ execSQL(ctx, t, database, `UPDATE river_job
+ SET metadata = jsonb_set(metadata, '{cancel_attempted_at}', to_jsonb('2026-01-02T03:04:05Z'::text))
+ WHERE id = `+strconv.FormatInt(jobs["cancel_attempted"].ID, 10))
+ worker.adapter.call(t, "stop", map[string]any{"cancel": true}, nil)
+
+ var job normalizedJob
+ reference.call(t, "get", map[string]any{"id": jobs["cooperative_cancel"].ID}, &job)
+ require.Equal(t, "available", job.State, worker.name)
+ require.Equal(t, 0, job.Attempt, worker.name)
+ require.NotNil(t, job.AttemptedAt, "%s: an interrupted job keeps attempted_at", worker.name)
+ require.Empty(t, job.Errors, worker.name)
+
+ reference.call(t, "get", map[string]any{"id": jobs["cancel_attempted"].ID}, &job)
+ cancelAttemptedAfterShutdown[worker.name] = job
+
+ for _, behavior := range []string{"cancel_error", "cancel_panic"} {
+ reference.call(t, "get", map[string]any{"id": jobs[behavior].ID}, &job)
+ require.Contains(t, []string{"available", "retryable"}, job.State, "%s %s", worker.name, behavior)
+ require.Equal(t, 1, job.Attempt, "%s %s: a genuine failure consumes its attempt", worker.name, behavior)
+ require.Len(t, job.Errors, 1, "%s %s", worker.name, behavior)
+ }
+ }
+ })
+
+ t.Run("shutdown_after_cancel_attempt", func(t *testing.T) { //nolint:paralleltest // Shares the conformance database.
+ defer scenarios.record(t)
+
+ require.Len(t, cancelAttemptedAfterShutdown, len(workers),
+ "hard_shutdown_soft_stop_classification must run first")
+ for name, job := range cancelAttemptedAfterShutdown {
+ require.Equal(t, "cancelled", job.State, name)
+ require.NotNil(t, job.FinalizedAt, name)
+ }
+ })
+
+ // A claimed row that an implementation can't decode must not strand the
+ // rows claimed with it. Like River Go, an implementation fails the
+ // undecodable row's attempt without working it: the error handler sees the
+ // partially decoded row, the attempt error starts with
+ // `job row couldn't be decoded: `, the job is retried with the client's
+ // retry policy or discarded at its maximum attempts, and the undecodable
+ // value is left as it was. Array metadata is valid for Go but can't be
+ // decoded by every implementation, so each implementation either works
+ // such a row or fails it this way.
+ t.Run("claimed_row_decode_isolation", func(t *testing.T) { //nolint:paralleltest // Shares the conformance database.
+ defer scenarios.record(t)
+
+ const retryDelay = time.Hour
+ setArrayMetadata := func(t *testing.T, id int64) {
+ t.Helper()
+ execSQL(ctx, t, database, `UPDATE river_job SET metadata = '[1]'::jsonb
+ WHERE id = `+strconv.FormatInt(id, 10))
+ }
+
+ for _, worker := range workers {
+ reference.call(t, "reset", map[string]any{}, nil)
+ var ordinary, sparseErrors, oddErrors, retried, discarded normalizedJob
+ reference.call(t, "insert", map[string]any{"message": "ordinary"}, &ordinary)
+ reference.call(t, "insert", map[string]any{"message": "sparse errors"}, &sparseErrors)
+ // Go decodes attempt errors with encoding/json, which tolerates
+ // missing and unknown fields.
+ execSQL(ctx, t, database, `UPDATE river_job
+ SET errors = ARRAY['{"error": "sparse", "extra": true}'::jsonb]
+ WHERE id = `+strconv.FormatInt(sparseErrors.ID, 10))
+ // Attempt errors in a shape River doesn't write decode leniently,
+ // with an `at` that isn't RFC 3339 left zero.
+ const oddErrorsSQL = `ARRAY['{"at": "2024-01-02 03:04:05+00", "attempt": "1", "error": {"message": "boom"}, "trace": ["frame"]}'::jsonb, '42'::jsonb]`
+ reference.call(t, "insert", map[string]any{"message": "odd errors"}, &oddErrors)
+ execSQL(ctx, t, database, `UPDATE river_job SET errors = `+oddErrorsSQL+`
+ WHERE id = `+strconv.FormatInt(oddErrors.ID, 10))
+ decodable := []int64{ordinary.ID, sparseErrors.ID, oddErrors.ID}
+ reference.call(t, "insert", map[string]any{"message": "array metadata retried"}, &retried)
+ reference.call(t, "insert", map[string]any{
+ "message": "array metadata discarded", "opts": map[string]any{"max_attempts": 1},
+ }, &discarded)
+ setArrayMetadata(t, retried.ID)
+ setArrayMetadata(t, discarded.ID)
+
+ worker.adapter.call(t, "start", map[string]any{
+ "client_id": worker.name + "-decode",
+ "retry_delay_ms": retryDelay.Milliseconds(),
+ }, nil)
+ for _, id := range decodable {
+ var (
+ attempt int
+ state string
+ )
+ pollUntil(t, 30*time.Second, worker.name+" completing a decodable row", func() bool {
+ require.NoError(t, database.QueryRow(ctx,
+ "SELECT state::text, attempt FROM river_job WHERE id = $1", id).Scan(&state, &attempt))
+ return state == "completed"
+ })
+ require.Equal(t, 1, attempt, "%s job %d", worker.name, id)
+ }
+ var worked normalizedJob
+ worker.adapter.call(t, "get", map[string]any{"id": oddErrors.ID}, &worked)
+ require.Equal(t, []normalizedAttemptError{
+ {At: "0001-01-01T00:00:00Z", Attempt: 1, Error: `{"message":"boom"}`, Trace: `["frame"]`},
+ {At: "0001-01-01T00:00:00Z", Error: "42"},
+ }, worked.Errors, worker.name)
+ var errorsText string
+ require.NoError(t, database.QueryRow(ctx,
+ "SELECT errors::text FROM river_job WHERE id = $1", oddErrors.ID).Scan(&errorsText))
+ var expectedText string
+ require.NoError(t, database.QueryRow(ctx, "SELECT ("+oddErrorsSQL+")::text").Scan(&expectedText))
+ require.Equal(t, expectedText, errorsText, "%s rewrote attempt errors it only read", worker.name)
+ failed := 0
+ for _, row := range []struct {
+ failedState string
+ id int64
+ }{
+ {failedState: "retryable", id: retried.ID},
+ {failedState: "discarded", id: discarded.ID},
+ } {
+ if requireUndecodableRowOutcome(ctx, t, database, worker.name, row.id, row.failedState) {
+ failed++
+ }
+ }
+ stats := waitForRuntimeStats(t, worker.adapter, func(stats runtimeStats) bool {
+ return countRuntimeEvent(stats, "job_completed") == len(decodable)+2-failed &&
+ countRuntimeEvent(stats, "job_failed") == failed
+ })
+ require.Zero(t, stats.ErrorHandlerCalls, worker.name)
+ worker.adapter.call(t, "stop", map[string]any{}, nil)
+
+ // The error handler sees an undecodable row's failed attempt, and
+ // its decision applies to it.
+ var handled, afterHandled normalizedJob
+ reference.call(t, "insert", map[string]any{"message": "array metadata handled"}, &handled)
+ setArrayMetadata(t, handled.ID)
+ reference.call(t, "insert", map[string]any{"message": "ordinary after handler"}, &afterHandled)
+ worker.adapter.call(t, "start", map[string]any{
+ "client_id": worker.name + "-decode-handler",
+ "error_handler_cancel": true,
+ }, nil)
+ waitForReferenceCompleted(t, reference, afterHandled.ID, 30*time.Second)
+ handlerCalls := 0
+ if requireUndecodableRowOutcome(ctx, t, database, worker.name, handled.ID, "cancelled") {
+ handlerCalls = 1
+ }
+ waitForRuntimeStats(t, worker.adapter, func(stats runtimeStats) bool {
+ return stats.ErrorHandlerCalls == handlerCalls
+ })
+ worker.adapter.call(t, "stop", map[string]any{}, nil)
+ }
+ })
+}
+
+// requireUndecodableRowOutcome waits for a worker to finish with a claimed row
+// whose metadata is a JSON array, then checks the outcome through SQL, since
+// not every implementation can read the row back. An implementation that can
+// decode the row completes it. One that can't fails the attempt the way River
+// Go fails an undecodable row, reaching failedState, and reports true. Either
+// way, the metadata is left as it was.
+func requireUndecodableRowOutcome(ctx context.Context, t *testing.T, database *pgx.Conn, workerName string, id int64, failedState string) bool {
+ t.Helper()
+
+ var (
+ attempt, errorCount int
+ lastError, lastAttempt *string
+ metadata, state string
+ retryLater, finalized bool
+ )
+ pollUntil(t, 30*time.Second, workerName+" finishing a row it may not decode", func() bool {
+ require.NoError(t, database.QueryRow(ctx, `SELECT state::text, attempt,
+ coalesce(array_length(errors, 1), 0),
+ errors[array_length(errors, 1)] ->> 'error',
+ errors[array_length(errors, 1)] ->> 'attempt',
+ metadata::text, scheduled_at > now() + interval '30 minutes',
+ finalized_at IS NOT NULL
+ FROM river_job WHERE id = $1`, id).Scan(
+ &state, &attempt, &errorCount, &lastError, &lastAttempt,
+ &metadata, &retryLater, &finalized))
+ return state != "available" && state != "running"
+ })
+ require.Equal(t, 1, attempt, "%s job %d", workerName, id)
+ require.Equal(t, "[1]", metadata, "%s rewrote metadata it couldn't decode", workerName)
+ if state == "completed" {
+ require.Zero(t, errorCount, "%s job %d", workerName, id)
+ return false
+ }
+
+ require.Equal(t, failedState, state, "%s job %d", workerName, id)
+ require.Equal(t, 1, errorCount, "%s job %d", workerName, id)
+ require.NotNil(t, lastError, "%s job %d", workerName, id)
+ require.True(t, strings.HasPrefix(*lastError, "job row couldn't be decoded: "),
+ "%s job %d attempt error: %s", workerName, id, *lastError)
+ require.Equal(t, "1", *lastAttempt, "%s job %d", workerName, id)
+ switch failedState {
+ case "retryable":
+ require.True(t, retryLater, "%s job %d wasn't retried with the client retry policy", workerName, id)
+ case "cancelled", "discarded":
+ require.True(t, finalized, "%s job %d", workerName, id)
+ }
+ return true
+}
+
+// TestResilienceSQLiteConformance checks SQLite behavior under a foreign
+// writer and Go-sized integers, using only the sqlite-runtime-v1 profile.
+func TestResilienceSQLiteConformance(t *testing.T) { //nolint:tparallel // Subtests share one SQLite database and run in order.
+ t.Parallel()
+ scenarios := newScenarioTracker(t, scenarioOwnerSQLiteResilience)
+
+ repositoryRoot := repoRoot(t)
+ databaseURL := filepath.Join(t.TempDir(), "river-conformance-resilience.sqlite")
+ const profileName = "sqlite-runtime-v1"
+ goAdapter := startReferenceAdapterForProfile(
+ t, repositoryRoot, databaseURL, "sqlite", profileName, "go",
+ )
+ candidateSpec := conformanceCandidateSpec(t, repositoryRoot, false)
+ candidateAdapter := startAdapterCommandForProfile(
+ t, repositoryRoot, databaseURL, "sqlite", profileName,
+ candidateSpec.Implementation, candidateSpec, candidateSpec.Command,
+ )
+ scenarios.attach(goAdapter, candidateAdapter)
+ goAdapter.call(t, "migrate", map[string]any{}, nil)
+
+ t.Run("sqlite_runtime_go_integer_ranges", func(t *testing.T) { //nolint:paralleltest // Shares the SQLite database.
+ defer scenarios.record(t)
+
+ // River Go stores native integers on SQLite, so `max_attempts` can
+ // exceed a 16-bit integer. Every implementation must still work it.
+ for _, pair := range []struct{ inserter, worker *adapter }{
+ {inserter: goAdapter, worker: candidateAdapter},
+ {inserter: goAdapter, worker: goAdapter},
+ } {
+ var inserted, worked, stored normalizedJob
+ pair.inserter.call(t, "insert", map[string]any{
+ "message": "wide max attempts", "opts": map[string]any{"max_attempts": 40_000},
+ }, &inserted)
+ pair.worker.call(t, "work", map[string]any{
+ "client_id": pair.worker.name + "-wide-integers", "id": inserted.ID,
+ }, &worked)
+ require.Equal(t, "completed", worked.State, pair.worker.name)
+ pair.inserter.call(t, "get", map[string]any{"id": inserted.ID}, &stored)
+ require.Equal(t, 40_000, stored.MaxAttempts, "working the job must not rewrite max_attempts")
+ }
+ })
+
+ // A JSON column changed out of band to text that isn't valid JSON must
+ // not stall its queue. Like River Go, an implementation fails such a
+ // job's attempt without working it, as it fails any row it can't decode,
+ // leaves the value in place, and works the other jobs. An `errors` value
+ // that isn't valid JSON is wrapped in an array, as a string, so the
+ // attempt error can still be appended.
+ t.Run("sqlite_runtime_invalid_json_columns", func(t *testing.T) { //nolint:paralleltest // Shares the SQLite database.
+ defer scenarios.record(t)
+
+ type replacedText struct {
+ Previous *string `json:"previous"`
+ PreviousType string `json:"previous_type"`
+ }
+ columns := []string{"args", "attempted_by", "errors", "metadata", "tags"}
+ for _, worker := range []*adapter{candidateAdapter, goAdapter} {
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ var ordinary normalizedJob
+ goAdapter.call(t, "insert", map[string]any{"message": "ordinary"}, &ordinary)
+ invalid := make(map[string]int64, len(columns))
+ originals := make(map[string]*string, len(columns))
+ for _, column := range columns {
+ var job normalizedJob
+ goAdapter.call(t, "insert", map[string]any{"message": "invalid " + column}, &job)
+ var replaced replacedText
+ goAdapter.call(t, "raw_replace_json_text", map[string]any{
+ "column": column, "id": job.ID, "text": "not json",
+ }, &replaced)
+ invalid[column] = job.ID
+ originals[column] = replaced.Previous
+ }
+
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-invalid-json",
+ "retry_delay_ms": time.Hour.Milliseconds(),
+ }, nil)
+ var completed normalizedJob
+ worker.call(t, "wait", map[string]any{"id": ordinary.ID, "states": []string{"completed"}}, &completed)
+ waitForRuntimeStats(t, worker, func(stats runtimeStats) bool {
+ return countRuntimeEvent(stats, "job_failed") == len(columns)
+ })
+ worker.call(t, "stop", map[string]any{}, nil)
+
+ for _, column := range columns {
+ id := invalid[column]
+ // Restore a readable value, getting back the one the worker
+ // left.
+ var left replacedText
+ restore := originals[column]
+ if column == "errors" {
+ // The wrapped errors are valid JSON; keep them to check.
+ goAdapter.call(t, "raw_replace_json_text", map[string]any{
+ "column": column, "id": id, "text": nil,
+ }, &left)
+ restore = left.Previous
+ }
+ var restored replacedText
+ goAdapter.call(t, "raw_replace_json_text", map[string]any{
+ "column": column, "id": id, "text": restore,
+ }, &restored)
+ if column != "errors" {
+ left = restored
+ require.Equal(t, "text", left.PreviousType, "%s %s", worker.name, column)
+ require.Equal(t, "not json", *left.Previous, "%s rewrote invalid %s", worker.name, column)
+ }
+
+ var failed normalizedJob
+ goAdapter.call(t, "get", map[string]any{"id": id}, &failed)
+ require.Equal(t, "retryable", failed.State, "%s %s", worker.name, column)
+ require.Equal(t, 1, failed.Attempt, "%s %s", worker.name, column)
+ require.NotEmpty(t, failed.Errors, "%s %s", worker.name, column)
+ attemptError := failed.Errors[len(failed.Errors)-1]
+ require.Equal(t, 1, attemptError.Attempt, "%s %s", worker.name, column)
+ require.True(t, strings.HasPrefix(attemptError.Error, "job row couldn't be decoded: "),
+ "%s %s: %s", worker.name, column, attemptError.Error)
+ if column == "errors" {
+ require.Len(t, failed.Errors, 2, worker.name)
+ require.Equal(t, "not json", failed.Errors[0].Error, worker.name)
+ }
+ }
+ }
+ })
+
+ t.Run("sqlite_runtime_completion_under_writer_lock", func(t *testing.T) { //nolint:paralleltest // Shares the SQLite database.
+ defer scenarios.record(t)
+
+ for _, pair := range []struct{ locker, worker *adapter }{
+ {locker: goAdapter, worker: candidateAdapter},
+ {locker: candidateAdapter, worker: goAdapter},
+ } {
+ pair.worker.call(t, "start", map[string]any{"client_id": pair.worker.name + "-writer-lock"}, nil)
+ barrier := pair.worker.name + "-writer-lock"
+ pair.worker.call(t, "barrier_create", map[string]any{"name": barrier}, nil)
+ var inserted, observed normalizedJob
+ pair.worker.call(t, "insert", map[string]any{"behavior": "barrier_wait", "message": barrier}, &inserted)
+ pair.worker.call(t, "wait", map[string]any{"id": inserted.ID, "states": []string{"running"}}, &observed)
+
+ // A write inside an open transaction holds SQLite's write lock.
+ // Keep it past the adapters' five-second busy timeout while the
+ // job finishes, so the first completion write fails.
+ handle := pair.worker.name + "-writer-lock"
+ pair.locker.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ pair.locker.call(t, "tx_insert", map[string]any{
+ "handle": handle, "job": map[string]any{"message": "foreign writer"},
+ }, nil)
+ pair.worker.call(t, "barrier_release", map[string]any{"name": barrier}, nil)
+ time.Sleep(6 * time.Second) // The fault is the lock's duration, not a wait for an outcome.
+ pair.locker.call(t, "tx_rollback", map[string]any{"handle": handle}, nil)
+
+ pollUntil(t, time.Minute, pair.worker.name+" completion after the foreign lock", func() bool {
+ var job normalizedJob
+ pair.locker.call(t, "get", map[string]any{"id": inserted.ID}, &job)
+ return job.State == "completed"
+ })
+ pair.worker.call(t, "stop", map[string]any{}, nil)
+ }
+ })
+}
+
+type resilienceWorker struct {
+ adapter *adapter
+ name string
+ proxy *faultProxy
+}
+
+// faultProxy forwards TCP connections to PostgreSQL and can make the database
+// unavailable to one adapter: it resets established connections and refuses
+// new ones until restored. Unlike terminating backends, this keeps the
+// database down for that adapter while the harness and reference still work.
+type faultProxy struct {
+ down atomic.Bool
+ mu sync.Mutex
+ open map[net.Conn]struct{}
+ rejected atomic.Int64
+ url string
+}
+
+func startFaultProxy(ctx context.Context, t *testing.T, databaseURL string) *faultProxy {
+ t.Helper()
+
+ parsed, err := url.Parse(databaseURL)
+ require.NoError(t, err, "the resilience tier needs a URL-form database URL")
+ upstream := parsed.Host
+ if parsed.Port() == "" {
+ upstream = net.JoinHostPort(parsed.Hostname(), "5432")
+ }
+ listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ proxied := *parsed
+ proxied.Host = listener.Addr().String()
+ proxy := &faultProxy{open: make(map[net.Conn]struct{}), url: proxied.String()}
+ t.Cleanup(func() {
+ _ = listener.Close()
+ proxy.closeAll()
+ })
+
+ go func() {
+ for {
+ client, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ if proxy.down.Load() {
+ proxy.rejected.Add(1)
+ _ = client.Close()
+ continue
+ }
+ go proxy.forward(ctx, client, upstream)
+ }
+ }()
+ return proxy
+}
+
+func (proxy *faultProxy) forward(ctx context.Context, client net.Conn, upstream string) {
+ dialer := &net.Dialer{Timeout: 5 * time.Second}
+ server, err := dialer.DialContext(ctx, "tcp", upstream)
+ if err != nil {
+ _ = client.Close()
+ return
+ }
+ if !proxy.track(client, server) {
+ return
+ }
+ done := make(chan struct{}, 2)
+ pipe := func(destination, source net.Conn) {
+ _, _ = io.Copy(destination, source)
+ done <- struct{}{}
+ }
+ go pipe(server, client)
+ go pipe(client, server)
+ <-done
+ proxy.untrack(client, server)
+}
+
+func (proxy *faultProxy) track(connections ...net.Conn) bool {
+ proxy.mu.Lock()
+ defer proxy.mu.Unlock()
+ if proxy.down.Load() {
+ for _, connection := range connections {
+ _ = connection.Close()
+ }
+ return false
+ }
+ for _, connection := range connections {
+ proxy.open[connection] = struct{}{}
+ }
+ return true
+}
+
+func (proxy *faultProxy) untrack(connections ...net.Conn) {
+ proxy.mu.Lock()
+ defer proxy.mu.Unlock()
+ for _, connection := range connections {
+ _ = connection.Close()
+ delete(proxy.open, connection)
+ }
+}
+
+func (proxy *faultProxy) closeAll() {
+ proxy.mu.Lock()
+ defer proxy.mu.Unlock()
+ for connection := range proxy.open {
+ _ = connection.Close()
+ delete(proxy.open, connection)
+ }
+}
+
+func (proxy *faultProxy) takeDown() {
+ proxy.down.Store(true)
+ proxy.closeAll()
+}
+
+func (proxy *faultProxy) restore() {
+ proxy.down.Store(false)
+}
+
+// waitForRejections waits until the adapter has tried to reconnect `count`
+// times while the proxy is down, proving it noticed the outage.
+func (proxy *faultProxy) waitForRejections(t *testing.T, count int64) {
+ t.Helper()
+ pollUntil(t, time.Minute, "reconnection attempts (did the client stop?)", func() bool {
+ return proxy.rejected.Load() >= count
+ })
+}
+
+func execSQL(ctx context.Context, t *testing.T, database *pgx.Conn, sql string) {
+ t.Helper()
+ _, err := database.Exec(ctx, sql)
+ require.NoError(t, err)
+}
+
+// waitForReferenceCompleted polls a job through the reference adapter, which
+// is connected directly and so unaffected by a worker's faults.
+func waitForReferenceCompleted(t *testing.T, reference *adapter, id int64, timeout time.Duration) normalizedJob {
+ t.Helper()
+ var job normalizedJob
+ pollUntil(t, timeout, "job "+strconv.FormatInt(id, 10)+" completing", func() bool {
+ reference.call(t, "get", map[string]any{"id": id}, &job)
+ return job.State == "completed"
+ })
+ return job
+}
+
+// pollUntil evaluates condition on the test goroutine until it holds, failing
+// the test after timeout.
+func pollUntil(t *testing.T, timeout time.Duration, description string, condition func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for !condition() {
+ require.True(t, time.Now().Before(deadline), "timed out waiting for %s", description)
+ time.Sleep(20 * time.Millisecond)
+ }
+}
diff --git a/conformance/harness/resumable_test.go b/conformance/harness/resumable_test.go
new file mode 100644
index 000000000..fca32d4ec
--- /dev/null
+++ b/conformance/harness/resumable_test.go
@@ -0,0 +1,91 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// Run each attempt in a different implementation. A long retry delay prevents
+// the first engine from reclaiming the next attempt before shutdown.
+func verifyResumableInteroperability(t *testing.T, first, second *adapter) {
+ t.Helper()
+
+ for _, direction := range [][2]*adapter{{first, second}, {second, first}} {
+ producer, consumer := direction[0], direction[1]
+ producer.call(t, "reset", map[string]any{}, nil)
+ var job normalizedJob
+ producer.call(t, "insert", map[string]any{
+ "behavior": "resumable_cursor", "message": "cross-engine cursor",
+ "opts": map[string]any{"max_attempts": 3, "metadata": map[string]any{"application": "retained"}},
+ }, &job)
+ for index, worker := range []*adapter{producer, consumer, producer} {
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-resumable", "max_workers": 1,
+ "retry_delay_ms": 60_000,
+ }, nil)
+ state := "retryable"
+ if index == 2 {
+ state = "completed"
+ }
+ worker.call(t, "wait", map[string]any{"id": job.ID, "states": []string{state}}, &job)
+ worker.call(t, "stop", map[string]any{}, nil)
+ require.Equal(t, index+1, job.Attempt)
+ require.Equal(t, "retained", job.Metadata["application"])
+ require.EqualValues(t, 1, job.Metadata["first_attempt"], "completed first step must never run again")
+ if index == 0 {
+ require.Equal(t, "first", job.Metadata["river:resumable_step"])
+ cursors, ok := job.Metadata["river:resumable_cursor"].(map[string]any)
+ require.True(t, ok, "cursor metadata must be an object")
+ require.EqualValues(t, 7, cursors["second"])
+ } else {
+ require.Equal(t, "second", job.Metadata["river:resumable_step"])
+ require.Nil(t, job.Metadata["river:resumable_cursor"], "consumed cursor must be cleared: worker=%s attempt=%d metadata=%v errors=%v", worker.name, job.Attempt, job.Metadata, job.Errors)
+ require.EqualValues(t, 7, job.Metadata["cursor_observed"])
+ }
+ if index < 2 {
+ consumer.call(t, "retry", map[string]any{"id": job.ID}, &job)
+ }
+ }
+ require.Len(t, job.Errors, 2)
+ }
+}
+
+func verifyResumableValidation(t *testing.T, worker *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-resumable-validation", "max_workers": 1,
+ }, nil)
+ for _, testCase := range []struct {
+ behavior string
+ metadata map[string]any
+ step string
+ }{
+ {behavior: "resumable_duplicate", metadata: map[string]any{}, step: "first"},
+ {behavior: "resumable_duplicate", metadata: map[string]any{"river:resumable_step": "later"}, step: "later"},
+ {behavior: "resumable", metadata: map[string]any{"river:resumable_step": ""}, step: "first"},
+ {behavior: "output", metadata: map[string]any{"river:resumable_cursor": []any{}}},
+ } {
+ var job normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "behavior": testCase.behavior, "message": "resumable validation",
+ "opts": map[string]any{"max_attempts": 1, "metadata": testCase.metadata},
+ }, &job)
+ worker.call(t, "wait", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, "discarded", job.State)
+ require.Len(t, job.Errors, 1)
+ if testCase.step != "" {
+ require.Equal(t, testCase.step, job.Metadata["river:resumable_step"])
+ } else {
+ require.NotContains(t, job.Metadata, "output", "invalid cursors must fail before user work")
+ }
+ if testCase.behavior == "resumable_duplicate" {
+ require.Contains(t, job.Errors[0].Error, "duplicate resumable step")
+ }
+ }
+ worker.call(t, "stop", map[string]any{}, nil)
+}
diff --git a/conformance/harness/retry_test.go b/conformance/harness/retry_test.go
new file mode 100644
index 000000000..6c84e5433
--- /dev/null
+++ b/conformance/harness/retry_test.go
@@ -0,0 +1,81 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// retriedJob is a retried job's row without the values that differ between
+// runs.
+type retriedJob struct {
+ Attempt int
+ Errors int
+ Finalized bool
+ MaxAttempts int
+ State string
+}
+
+// verifyExhaustedJobRetry checks retrying finalized jobs from the other
+// implementation. Go's retry makes a finalized job available again, and when
+// the job has used every attempt it raises max_attempts by one so the job
+// gets another one. One implementation works a job that fails on its only
+// attempt and is discarded, and one that cancels itself with attempts left,
+// and the other retries both, both ways round.
+func verifyExhaustedJobRetry(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ retried := make(map[string]map[string]retriedJob)
+ for _, pair := range []struct {
+ finisher *adapter
+ retrier *adapter
+ }{
+ {finisher: goAdapter, retrier: candidateAdapter},
+ {finisher: candidateAdapter, retrier: goAdapter},
+ } {
+ pair.finisher.call(t, "reset", map[string]any{}, nil)
+ retried[pair.retrier.name] = make(map[string]retriedJob)
+ for label, testCase := range map[string]struct {
+ finalState string
+ params map[string]any
+ }{
+ "exhausted": {
+ finalState: "discarded",
+ params: map[string]any{"behavior": "error", "message": "exhausted retry", "opts": map[string]any{"max_attempts": 1}},
+ },
+ "attempts left": {
+ finalState: "cancelled",
+ params: map[string]any{"behavior": "cancel", "message": "cancelled retry", "opts": map[string]any{"max_attempts": 3}},
+ },
+ } {
+ var job normalizedJob
+ pair.finisher.call(t, "insert", testCase.params, &job)
+ pair.finisher.call(t, "work", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, testCase.finalState, job.State, "%s %s job", pair.finisher.name, label)
+ require.Equal(t, 1, job.Attempt, "%s %s job", pair.finisher.name, label)
+
+ var retriedRow, observed normalizedJob
+ pair.retrier.call(t, "retry", map[string]any{"id": job.ID}, &retriedRow)
+ pair.finisher.call(t, "get", map[string]any{"id": job.ID}, &observed)
+ require.Equal(t, retriedRow, observed, "%s %s job", pair.retrier.name, label)
+ require.True(t, parseTime(t, observed.ScheduledAt).After(parseTime(t, *job.FinalizedAt)),
+ "%s retried the %s job without rescheduling it", pair.retrier.name, label)
+ retried[pair.retrier.name][label] = retriedJob{
+ Attempt: observed.Attempt,
+ Errors: len(observed.Errors),
+ Finalized: observed.FinalizedAt != nil,
+ MaxAttempts: observed.MaxAttempts,
+ State: observed.State,
+ }
+ }
+ }
+
+ require.Equal(t, map[string]retriedJob{
+ "attempts left": {Attempt: 1, Errors: 1, MaxAttempts: 3, State: "available"},
+ "exhausted": {Attempt: 1, Errors: 1, MaxAttempts: 2, State: "available"},
+ }, retried[goAdapter.name])
+ require.Equal(t, retried[goAdapter.name], retried[candidateAdapter.name],
+ "%s and Go retried finalized jobs differently", candidateAdapter.name)
+}
diff --git a/conformance/harness/runtime_scenarios_test.go b/conformance/harness/runtime_scenarios_test.go
new file mode 100644
index 000000000..20ffe098e
--- /dev/null
+++ b/conformance/harness/runtime_scenarios_test.go
@@ -0,0 +1,513 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+type runtimeStats struct {
+ CancelledAtStart int `json:"cancelled_at_start"`
+ ErrorHandlerCalls int `json:"error_handler_calls"`
+ Events []string `json:"events"`
+ PeriodicStarts int `json:"periodic_starts"`
+ ResumableFirstRuns int `json:"resumable_first_runs"`
+ ResumableSecondRuns int `json:"resumable_second_runs"`
+ StuckJobs int `json:"stuck_jobs"`
+ Trace []string `json:"trace"`
+}
+
+func verifyBarrierWaitAndRelease(t *testing.T, current *adapter) {
+ t.Helper()
+
+ current.call(t, "reset", map[string]any{}, nil)
+ current.call(t, "start", map[string]any{
+ "client_id": current.name + "-barrier", "max_workers": 2,
+ }, nil)
+ current.call(t, "barrier_create", map[string]any{"name": "runtime"}, nil)
+ var inserted, running, worked normalizedJob
+ current.call(t, "insert", map[string]any{
+ "behavior": "barrier_wait", "message": "runtime",
+ }, &inserted)
+ current.call(t, "wait", map[string]any{
+ "id": inserted.ID, "states": []string{"running"},
+ }, &running)
+ require.Equal(t, "running", running.State)
+ require.Equal(t, 1, running.Attempt)
+ current.call(t, "barrier_release", map[string]any{"name": "runtime"}, nil)
+ current.call(t, "wait", map[string]any{"id": inserted.ID}, &worked)
+ require.Equal(t, "completed", worked.State)
+ require.Equal(t, running.AttemptedAt, worked.AttemptedAt)
+ current.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyWorkerOutcomes checks the persisted row for each terminal worker
+// outcome in one implementation.
+func verifyWorkerOutcomes(t *testing.T, current *adapter) {
+ t.Helper()
+
+ current.call(t, "reset", map[string]any{}, nil)
+ current.call(t, "start", map[string]any{
+ "client_id": current.name + "-outcomes", "max_workers": 2,
+ }, nil)
+ for _, testCase := range []struct {
+ behavior string
+ errorText string
+ maxAttempts int
+ state string
+ }{
+ {behavior: "cancel", state: "cancelled"},
+ {behavior: "discard", maxAttempts: 1, state: "discarded"},
+ {behavior: "error", errorText: "conformance retryable error", maxAttempts: 1, state: "discarded"},
+ } {
+ params := map[string]any{"behavior": testCase.behavior, "message": testCase.behavior}
+ if testCase.maxAttempts > 0 {
+ params["opts"] = map[string]any{"max_attempts": testCase.maxAttempts}
+ }
+ var inserted, worked normalizedJob
+ current.call(t, "insert", params, &inserted)
+ current.call(t, "wait", map[string]any{"id": inserted.ID}, &worked)
+ require.Equal(t, testCase.state, worked.State, "%s behavior", testCase.behavior)
+ require.Equal(t, 1, worked.Attempt, "%s behavior", testCase.behavior)
+ require.NotNil(t, worked.FinalizedAt, "%s behavior", testCase.behavior)
+ require.Len(t, worked.Errors, 1, "%s behavior", testCase.behavior)
+ require.Equal(t, 1, worked.Errors[0].Attempt, "%s behavior", testCase.behavior)
+ if testCase.errorText != "" {
+ require.Equal(t, testCase.errorText, worked.Errors[0].Error)
+ }
+ }
+
+ var outputInserted, outputWorked normalizedJob
+ current.call(t, "insert", map[string]any{
+ "behavior": "output", "message": "runtime output",
+ }, &outputInserted)
+ current.call(t, "wait", map[string]any{"id": outputInserted.ID}, &outputWorked)
+ require.Equal(t, "completed", outputWorked.State)
+ require.Empty(t, outputWorked.Errors)
+ require.Equal(t, map[string]any{"message": "runtime output"}, outputWorked.Metadata["output"])
+ current.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyPanicAttemptTrace checks that a panic is persisted with its value and
+// a stack trace that the other implementation reads unchanged.
+func verifyPanicAttemptTrace(t *testing.T, worker, observer *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-panic", "max_workers": 1,
+ }, nil)
+ var inserted, worked, observed normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "behavior": "panic", "message": "panic", "opts": map[string]any{"max_attempts": 1},
+ }, &inserted)
+ worker.call(t, "wait", map[string]any{"id": inserted.ID}, &worked)
+ require.Equal(t, "discarded", worked.State)
+ require.Equal(t, 1, worked.Attempt)
+ require.Len(t, worked.Errors, 1)
+ require.Contains(t, worked.Errors[0].Error, "conformance worker panic")
+ require.Equal(t, 1, worked.Errors[0].Attempt)
+ require.NotEmpty(t, worked.Errors[0].Trace)
+ observer.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.Equal(t, worked, observed)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+func verifyTransactionalCompletion(t *testing.T, current *adapter) {
+ t.Helper()
+
+ current.call(t, "reset", map[string]any{}, nil)
+ current.call(t, "start", map[string]any{
+ "client_id": current.name + "-transactional-completion", "max_workers": 1,
+ }, nil)
+ var inserted, worked normalizedJob
+ current.call(t, "insert", map[string]any{
+ "behavior": "transactional_complete", "message": "transactional completion",
+ }, &inserted)
+ current.call(t, "wait", map[string]any{"id": inserted.ID}, &worked)
+ require.Equal(t, "completed", worked.State)
+ require.Empty(t, worked.Errors)
+ require.Equal(t, true, worked.Metadata["transactional_completion"])
+ current.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifySnoozeTransition checks the persisted snooze transition: the
+// `snoozes` counter, an attempt that is given back, and a delay longer than
+// the scheduler interval parking the job as `scheduled` at the snooze time.
+func verifySnoozeTransition(t *testing.T, worker, observer *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-snooze", "max_workers": 1,
+ }, nil)
+
+ var short normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "behavior": "snooze_once", "duration_ms": 5, "message": "short snooze",
+ }, &short)
+ worker.call(t, "wait", map[string]any{"id": short.ID}, &short)
+ require.Equal(t, "completed", short.State)
+ require.Equal(t, 1, short.Attempt, "a snooze must not consume an attempt")
+ require.EqualValues(t, 1, short.Metadata["snoozes"])
+ require.Empty(t, short.Errors)
+
+ // Both implementations default to a five-second scheduler interval; a
+ // longer snooze is persisted as scheduled rather than available.
+ const longSnooze = 10 * time.Second
+ var long normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "behavior": "snooze_once", "duration_ms": longSnooze.Milliseconds(), "message": "long snooze",
+ }, &long)
+ worker.call(t, "wait", map[string]any{"id": long.ID, "states": []string{"scheduled"}}, &long)
+ require.Equal(t, 0, long.Attempt, "a snooze must give its attempt back")
+ require.EqualValues(t, 1, long.Metadata["snoozes"])
+ require.Empty(t, long.Errors)
+ require.Nil(t, long.FinalizedAt)
+ require.NotNil(t, long.AttemptedAt)
+ delay := parseTime(t, long.ScheduledAt).Sub(parseTime(t, *long.AttemptedAt))
+ require.GreaterOrEqual(t, delay, longSnooze-100*time.Millisecond)
+ require.Less(t, delay, longSnooze+2*time.Second)
+ var observed normalizedJob
+ observer.call(t, "get", map[string]any{"id": long.ID}, &observed)
+ require.Equal(t, long, observed)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+func verifyExternalTerminalCompletionRace(t *testing.T, worker, externalizer *adapter) {
+ t.Helper()
+
+ externalizer.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-completion-race", "instrumented": true, "max_workers": 1,
+ }, nil)
+
+ for index, testCase := range []struct {
+ behavior string
+ expectsOutput bool
+ externalState string
+ }{
+ {behavior: "barrier_output", expectsOutput: true, externalState: "completed"},
+ {behavior: "barrier_output", expectsOutput: true, externalState: "discarded"},
+ {behavior: "barrier_wait", externalState: "completed"},
+ } {
+ barrierName := fmt.Sprintf("completion-race-%s-%d", testCase.externalState, index)
+ worker.call(t, "barrier_create", map[string]any{"name": barrierName}, nil)
+ var inserted, running normalizedJob
+ externalizer.call(t, "insert", map[string]any{
+ "behavior": testCase.behavior, "message": barrierName,
+ }, &inserted)
+ externalizer.call(t, "wait", map[string]any{
+ "id": inserted.ID, "states": []string{"running"},
+ }, &running)
+
+ var external normalizedJob
+ externalizer.call(t, "raw_finalize", map[string]any{
+ "id": inserted.ID,
+ "metadata": map[string]any{
+ "external": testCase.externalState,
+ "shared": "external",
+ },
+ "state": testCase.externalState,
+ }, &external)
+ require.Equal(t, testCase.externalState, external.State)
+ require.NotNil(t, external.FinalizedAt)
+ if testCase.externalState == "discarded" {
+ require.Equal(t, []normalizedAttemptError{{
+ At: "2026-02-03T04:05:06.789Z",
+ Attempt: 1,
+ Error: "external discard",
+ Trace: "external trace",
+ }}, external.Errors)
+ } else {
+ require.Empty(t, external.Errors)
+ }
+
+ worker.call(t, "barrier_release", map[string]any{"name": barrierName}, nil)
+ waitForRuntimeStats(t, worker, func(stats runtimeStats) bool {
+ return len(stats.Events) == index+1
+ })
+ var completed normalizedJob
+ externalizer.call(t, "get", map[string]any{"id": inserted.ID}, &completed)
+ if testCase.expectsOutput {
+ require.Equal(t, map[string]any{"race": "worker"}, completed.Metadata["output"])
+ } else {
+ require.NotContains(t, completed.Metadata, "output")
+ }
+ require.Equal(t, testCase.externalState, completed.State)
+ require.Equal(t, external.FinalizedAt, completed.FinalizedAt)
+ require.Equal(t, external.Errors, completed.Errors)
+ require.Equal(t, testCase.externalState, completed.Metadata["external"])
+ require.Equal(t, "external", completed.Metadata["shared"])
+ }
+
+ stats := waitForRuntimeStats(t, worker, func(stats runtimeStats) bool {
+ return len(stats.Events) == 3
+ })
+ require.Equal(t, []string{"job_completed", "job_failed", "job_completed"}, stats.Events)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyExtensionOrder checks global hook and middleware ordering around
+// insertion and work.
+func verifyExtensionOrder(t *testing.T, current *adapter) {
+ t.Helper()
+
+ current.call(t, "reset", map[string]any{}, nil)
+ current.call(t, "start", map[string]any{
+ "client_id": current.name + "-extension-order", "instrumented": true, "max_workers": 1,
+ }, nil)
+ var ordinary normalizedJob
+ current.call(t, "insert", map[string]any{"message": "extension order"}, &ordinary)
+ current.call(t, "wait", map[string]any{"id": ordinary.ID}, &ordinary)
+ require.Equal(t, "completed", ordinary.State)
+ stats := waitForRuntimeStats(t, current, func(stats runtimeStats) bool {
+ return slices.Contains(stats.Events, "job_completed")
+ })
+ // Like River Go, hooks run inside middleware: insertion middleware wraps
+ // the insert-begin hooks, and work middleware wraps the work hooks and
+ // the worker.
+ requireOrderedSubsequence(t, stats.Trace, []string{
+ "middleware:insert_before", "hook:insert_begin", "middleware:insert_after",
+ })
+ requireOrderedSubsequence(t, stats.Trace, []string{
+ "middleware:work_before", "hook:work_begin", "hook:work_end", "middleware:work_after",
+ })
+ requireOrderedSubsequence(t, stats.Trace, []string{"middleware:insert_after", "hook:work_begin"})
+ current.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyResumableRetry checks that a completed resumable step is skipped on
+// the retry after a later step fails.
+func verifyResumableRetry(t *testing.T, current *adapter) {
+ t.Helper()
+
+ current.call(t, "reset", map[string]any{}, nil)
+ current.call(t, "start", map[string]any{
+ "client_id": current.name + "-resumable-retry", "instrumented": true,
+ "max_workers": 1, "retry_delay_ms": 5,
+ }, nil)
+ var resumable normalizedJob
+ current.call(t, "insert", map[string]any{
+ "behavior": "resumable", "message": "resumable", "opts": map[string]any{"max_attempts": 2},
+ }, &resumable)
+ current.call(t, "wait", map[string]any{"id": resumable.ID}, &resumable)
+ require.Equal(t, "completed", resumable.State)
+ require.Equal(t, 2, resumable.Attempt)
+ require.Len(t, resumable.Errors, 1)
+ require.Equal(t, "first", resumable.Metadata["river:resumable_step"])
+ stats := waitForRuntimeStats(t, current, func(stats runtimeStats) bool {
+ return slices.Contains(stats.Events, "job_completed") && slices.Contains(stats.Events, "job_failed")
+ })
+ require.Equal(t, 1, stats.ResumableFirstRuns, "a completed step must not run again")
+ require.Equal(t, 2, stats.ResumableSecondRuns)
+ current.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyDynamicQueues adds, reconfigures, and removes a queue on a running
+// client. Reconfiguration is proven by running two blocked jobs at once
+// after raising the queue's worker limit from one to two.
+func verifyDynamicQueues(t *testing.T, current *adapter) {
+ t.Helper()
+
+ current.call(t, "reset", map[string]any{}, nil)
+ current.call(t, "start", map[string]any{
+ "client_id": current.name + "-dynamic-queues", "max_workers": 1,
+ }, nil)
+ current.call(t, "queue_add", map[string]any{"max_workers": 1, "name": "dynamic"}, nil)
+ current.call(t, "queue_add", map[string]any{"max_workers": 2, "name": "dynamic"}, nil)
+ current.call(t, "barrier_create", map[string]any{"name": "dynamic-concurrency"}, nil)
+ blocked := make([]normalizedJob, 2)
+ for index := range blocked {
+ current.call(t, "insert", map[string]any{
+ "behavior": "barrier_wait", "message": "dynamic-concurrency",
+ "opts": map[string]any{"queue": "dynamic"},
+ }, &blocked[index])
+ }
+ for _, job := range blocked {
+ var running normalizedJob
+ current.call(t, "wait", map[string]any{"id": job.ID, "states": []string{"running"}}, &running)
+ }
+ current.call(t, "barrier_release", map[string]any{"name": "dynamic-concurrency"}, nil)
+ for _, job := range blocked {
+ var completed normalizedJob
+ current.call(t, "wait", map[string]any{"id": job.ID}, &completed)
+ require.Equal(t, "completed", completed.State)
+ require.Equal(t, "dynamic", completed.Queue)
+ }
+
+ current.call(t, "queue_remove", map[string]any{"name": "dynamic"}, nil)
+ var orphaned, marker normalizedJob
+ current.call(t, "insert", map[string]any{
+ "message": "removed queue", "opts": map[string]any{"queue": "dynamic"},
+ }, &orphaned)
+ current.call(t, "insert", map[string]any{"message": "default queue marker"}, &marker)
+ current.call(t, "wait", map[string]any{"id": marker.ID}, &marker)
+ require.Equal(t, "completed", marker.State)
+ current.call(t, "get", map[string]any{"id": orphaned.ID}, &orphaned)
+ require.Equal(t, "available", orphaned.State, "a removed queue must not be worked")
+ current.call(t, "stop", map[string]any{}, nil)
+}
+
+func verifyPeriodicRunOnStart(t *testing.T, current *adapter) {
+ t.Helper()
+
+ current.call(t, "reset", map[string]any{}, nil)
+ current.call(t, "start", map[string]any{
+ "client_id": current.name + "-periodic", "instrumented": true,
+ "max_workers": 1, "periodic_run_on_start": true,
+ }, nil)
+ periodic := waitForListedJob(t, current, map[string]any{
+ "metadata": map[string]any{"river:periodic_job_id": "conformance-periodic"},
+ })
+ current.call(t, "wait", map[string]any{"id": periodic.ID}, &periodic)
+ require.Equal(t, "completed", periodic.State)
+ require.Equal(t, true, periodic.Metadata["periodic"])
+ stats := waitForRuntimeStats(t, current, func(stats runtimeStats) bool {
+ return stats.PeriodicStarts == 1
+ })
+ require.Equal(t, 1, stats.PeriodicStarts)
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ current.call(t, "list", map[string]any{
+ "metadata": map[string]any{"river:periodic_job_id": "conformance-periodic"},
+ }, &listed)
+ require.Len(t, listed.Jobs, 1, "run-on-start must enqueue exactly once per leadership term")
+ current.call(t, "stop", map[string]any{}, nil)
+}
+
+func verifyErrorHandlerCancel(t *testing.T, current *adapter) {
+ t.Helper()
+
+ current.call(t, "reset", map[string]any{}, nil)
+ current.call(t, "start", map[string]any{
+ "client_id": current.name + "-error-handler", "error_handler_cancel": true,
+ "instrumented": true, "max_workers": 1,
+ }, nil)
+ var handled normalizedJob
+ current.call(t, "insert", map[string]any{
+ "behavior": "error", "message": "error handler cancellation",
+ "opts": map[string]any{"max_attempts": 3},
+ }, &handled)
+ current.call(t, "wait", map[string]any{"id": handled.ID}, &handled)
+ require.Equal(t, "cancelled", handled.State)
+ require.Equal(t, 1, handled.Attempt)
+ require.Len(t, handled.Errors, 1)
+ require.Equal(t, "conformance retryable error", handled.Errors[0].Error)
+ stats := waitForRuntimeStats(t, current, func(stats runtimeStats) bool {
+ return stats.ErrorHandlerCalls == 1 && slices.Contains(stats.Events, "job_cancelled")
+ })
+ require.Equal(t, 1, stats.ErrorHandlerCalls)
+ current.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyTimeoutCancellation checks that a job timeout cancels a cooperative
+// worker and records the failed attempt.
+func verifyTimeoutCancellation(t *testing.T, worker, observer *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-timeout", "job_timeout_ms": 20, "max_workers": 1,
+ }, nil)
+ var job, observed normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "behavior": "cooperative_cancel", "message": "timeout cancellation",
+ "opts": map[string]any{"max_attempts": 1},
+ }, &job)
+ worker.call(t, "wait", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, "discarded", job.State)
+ require.Equal(t, 1, job.Attempt)
+ require.Len(t, job.Errors, 1)
+ require.NotEmpty(t, job.Errors[0].Error)
+ require.NotNil(t, job.AttemptedAt)
+ require.NotNil(t, job.FinalizedAt)
+ require.GreaterOrEqual(t, parseTime(t, *job.FinalizedAt).Sub(parseTime(t, *job.AttemptedAt)), 20*time.Millisecond)
+ observer.call(t, "get", map[string]any{"id": job.ID}, &observed)
+ require.Equal(t, job, observed)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+// verifyCompletionBatching completes many jobs at once and requires the
+// completions to share write transactions. PostgreSQL assigns one
+// transaction ID per writing transaction, so completing N jobs one at a time
+// would consume at least N IDs.
+func verifyCompletionBatching(t *testing.T, observer *postgresObserver, current *adapter) {
+ t.Helper()
+
+ const jobCount = 1_000
+ current.call(t, "reset", map[string]any{}, nil)
+ current.call(t, "start", map[string]any{
+ "client_id": current.name + "-completion-batching", "fetch_poll_interval_ms": 1_000,
+ "max_workers": jobCount,
+ }, nil)
+ current.call(t, "barrier_create", map[string]any{"name": "completion-batching"}, nil)
+ jobs := make([]map[string]any, jobCount)
+ for index := range jobs {
+ jobs[index] = map[string]any{"behavior": "barrier_wait", "message": "completion-batching"}
+ }
+ var inserted struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ current.call(t, "insert_many", map[string]any{"jobs": jobs}, &inserted)
+ require.Len(t, inserted.Results, jobCount)
+ waitForListedJobCountWithin(t, current, map[string]any{
+ "limit": jobCount, "states": []string{"running"},
+ }, jobCount, 20*time.Second)
+
+ before := observer.nextTransactionID(t)
+ current.call(t, "barrier_release", map[string]any{"name": "completion-batching"}, nil)
+ completed := waitForListedJobCountWithin(t, current, map[string]any{
+ "limit": jobCount, "states": []string{"completed"},
+ }, jobCount, 20*time.Second)
+ writes := observer.nextTransactionID(t) - before
+ for _, job := range completed {
+ require.Equal(t, 1, job.Attempt)
+ require.Empty(t, job.Errors)
+ }
+ require.Less(t, writes, int64(jobCount/4),
+ "%s used %d write transactions to complete %d jobs; completions are not batched", current.name, writes, jobCount)
+ t.Logf("%s completed %d jobs in %d write transactions", current.name, jobCount, writes)
+ current.call(t, "stop", map[string]any{}, nil)
+}
+
+func verifyRefetchedAttemptCancellation(t *testing.T, worker, canceller *adapter) {
+ t.Helper()
+
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-refetched-cancel", "max_workers": 1,
+ }, nil)
+ var job normalizedJob
+ canceller.call(t, "insert", map[string]any{
+ "behavior": "snooze_then_cancel", "duration_ms": 1, "message": "refetched cancellation",
+ }, &job)
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ worker.call(t, "get", map[string]any{"id": job.ID}, &job)
+ if job.State == "running" && job.Metadata["snoozes"] != nil {
+ break
+ }
+ time.Sleep(time.Millisecond)
+ }
+ require.Equal(t, "running", job.State)
+ require.NotNil(t, job.Metadata["snoozes"])
+ canceller.call(t, "cancel", map[string]any{"id": job.ID}, &job)
+ worker.call(t, "wait", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, "cancelled", job.State)
+ worker.call(t, "stop", map[string]any{}, nil)
+}
+
+func parseTime(t *testing.T, value string) time.Time {
+ t.Helper()
+
+ parsed, err := time.Parse(time.RFC3339Nano, value)
+ require.NoError(t, err)
+ return parsed
+}
diff --git a/conformance/harness/scenario_registry_test.go b/conformance/harness/scenario_registry_test.go
new file mode 100644
index 000000000..40f8c341c
--- /dev/null
+++ b/conformance/harness/scenario_registry_test.go
@@ -0,0 +1,197 @@
+package harness_test
+
+const (
+ scenarioOwnerInsertOnly = "TestInsertOnlyConformance"
+ scenarioOwnerMaintenance = "TestMaintenanceConformance"
+ scenarioOwnerMixed = "TestMixedConformance"
+ scenarioOwnerMultiEngine = "TestMultiEngineConformance"
+ scenarioOwnerMultiEnginePerformance = "TestMultiEnginePerformanceGate"
+ scenarioOwnerMultiEngineSQLite = "TestMultiEngineSQLiteConformance"
+ scenarioOwnerMultiEngineSoak = "TestMultiEngineSoak"
+ scenarioOwnerPerformance = "TestPerformanceGate"
+ scenarioOwnerResilience = "TestResilienceConformance"
+ scenarioOwnerSQLiteResilience = "TestResilienceSQLiteConformance"
+ scenarioOwnerSQLiteRuntime = "TestMixedSQLiteRuntimeConformance"
+ scenarioOwnerSQLiteStorage = "TestMixedSQLiteConformance"
+ scenarioOwnerSoak = "TestMixedSoak"
+)
+
+type scenarioBinding struct {
+ owner string
+ profile string
+ tier string
+}
+
+// scenarioRegistry is the executable source of truth for conformance
+// scenarios. Each owning test must report every bound scenario as passed before
+// it returns successfully; artifact validation separately requires core.json to
+// contain this exact set with matching tiers.
+var scenarioRegistry = map[string]scenarioBinding{ //nolint:gochecknoglobals // shared executable catalog
+ "adapter_handshake_and_capabilities": {owner: scenarioOwnerMixed, tier: "codec"},
+ "barrier_wait_and_release": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "bulk_delete_safety": {owner: scenarioOwnerMixed, tier: "storage"},
+ "candidate_insert_reference_work": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "candidate_migrator_reference_runtime": {owner: scenarioOwnerMixed, tier: "storage"},
+ "candidate_process_kill_reference_rescue": {owner: scenarioOwnerMixed, tier: "chaos"},
+ "claim_order": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "claim_time_cancellation": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "claimed_row_decode_isolation": {owner: scenarioOwnerResilience, tier: "mixed"},
+ "clock_boundary_scheduling": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "completion_batching": {owner: scenarioOwnerMixed, tier: "performance"},
+ "completion_row_lock_wait": {owner: scenarioOwnerResilience, tier: "chaos"},
+ "completion_transient_failure_retry": {owner: scenarioOwnerResilience, tier: "chaos"},
+ "cooperative_remote_cancellation": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "cron_schedule_goldens": {owner: scenarioOwnerMaintenance, tier: "codec"},
+ "cross_language_cancel_retry_race": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "cross_language_unique_conflict": {owner: scenarioOwnerMixed, tier: "codec"},
+ "custom_schema_candidate_migrate_reference_work": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "custom_schema_reference_migrate_candidate_work": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "database_unavailable_reconnect": {owner: scenarioOwnerResilience, tier: "chaos"},
+ "default_retry_policy_schedule": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "deterministic_retry_clock_rng": {owner: scenarioOwnerMixed, tier: "codec"},
+ "differential_job_crud": {owner: scenarioOwnerMixed, tier: "storage"},
+ "differential_job_list_filters_and_cursors": {owner: scenarioOwnerMixed, tier: "storage"},
+ "differential_queue_crud": {owner: scenarioOwnerMixed, tier: "storage"},
+ "dynamic_queue_add_reconfigure_remove": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "error_handler_cancel_override": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "exhausted_job_retry": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "extension_hook_middleware_order": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "external_terminal_completion_race": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "hard_shutdown_soft_stop_classification": {owner: scenarioOwnerResilience, tier: "runtime"},
+ "heterogeneous_fleet_known_kinds": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "historical_migration_down_up": {owner: scenarioOwnerMixed, tier: "storage"},
+ "ignored_cancellation_hard_abort": {owner: scenarioOwnerMixed, tier: "chaos"},
+ "insert_only_insert_notification": {owner: scenarioOwnerInsertOnly, profile: "insert-only-v1", tier: "mixed"},
+ "insert_only_insert_reference_work": {owner: scenarioOwnerInsertOnly, profile: "insert-only-v1", tier: "mixed"},
+ "insert_only_profile_handshake": {owner: scenarioOwnerInsertOnly, profile: "insert-only-v1", tier: "codec"},
+ "insert_only_transactional_insert": {owner: scenarioOwnerInsertOnly, profile: "insert-only-v1", tier: "storage"},
+ "insert_only_typed_batch": {owner: scenarioOwnerInsertOnly, profile: "insert-only-v1", tier: "storage"},
+ "insert_only_unique_insert": {owner: scenarioOwnerInsertOnly, profile: "insert-only-v1", tier: "codec"},
+ "job_cleaner_queue_filters": {owner: scenarioOwnerMixed, tier: "storage"},
+ "job_list_cursor_interchange": {owner: scenarioOwnerMixed, tier: "storage"},
+ "job_row_round_trip_all_fields": {owner: scenarioOwnerMixed, tier: "codec"},
+ "kind_alias_rename": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "leader_election_disabled_both_directions": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "leadership_renewal_under_slow_maintenance": {owner: scenarioOwnerMaintenance, tier: "runtime"},
+ "leadership_same_client_id_term_replacement": {owner: scenarioOwnerMaintenance, tier: "runtime"},
+ "listener_backend_disconnect_reconnect": {owner: scenarioOwnerMixed, tier: "chaos"},
+ "lost_notification_poll_recovery": {owner: scenarioOwnerMixed, tier: "chaos"},
+ "maintenance_job_cleaner_retention": {owner: scenarioOwnerMaintenance, tier: "runtime"},
+ "maintenance_queue_cleaner_keeps_active_queues": {owner: scenarioOwnerMaintenance, tier: "runtime"},
+ "maintenance_reindexer_skips_artifacts": {owner: scenarioOwnerMaintenance, tier: "runtime"},
+ "maintenance_rescuer_full_batch_of_unexpired_jobs": {owner: scenarioOwnerMaintenance, tier: "runtime"},
+ "maintenance_rescuer_stale_selection": {owner: scenarioOwnerMaintenance, tier: "runtime"},
+ "migration_mixed_case_schema": {owner: scenarioOwnerMaintenance, tier: "storage"},
+ "mixed_connection_pool_bound": {owner: scenarioOwnerSoak, tier: "performance"},
+ "mixed_leader_death_failover_both_directions": {owner: scenarioOwnerMixed, tier: "chaos"},
+ "mixed_leader_failover_both_directions": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "mixed_request_resign_terms": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "mixed_skip_locked_competition": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "mixed_soak": {owner: scenarioOwnerSoak, tier: "performance"},
+ "mixed_unknown_kind_error": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "multi_engine_competition": {owner: scenarioOwnerMultiEngine, tier: "mixed"},
+ "multi_engine_directed_candidate_work_notification_cancellation": {owner: scenarioOwnerMultiEngine, tier: "mixed"},
+ "multi_engine_fault_recovery": {owner: scenarioOwnerMultiEngine, tier: "chaos"},
+ "multi_engine_job_list_cursor_interchange": {owner: scenarioOwnerMultiEngine, tier: "storage"},
+ "multi_engine_leader_election_disabled": {owner: scenarioOwnerMultiEngine, tier: "mixed"},
+ "multi_engine_leader_failover": {owner: scenarioOwnerMultiEngine, tier: "mixed"},
+ "multi_engine_process_kill_rescue_failover": {owner: scenarioOwnerMultiEngine, tier: "chaos"},
+ "multi_engine_release_performance": {owner: scenarioOwnerMultiEnginePerformance, tier: "performance"},
+ "multi_engine_resource_bound": {owner: scenarioOwnerMultiEngine, tier: "performance"},
+ "multi_engine_resumable_cursor": {owner: scenarioOwnerMultiEngine, tier: "mixed"},
+ "multi_engine_soak": {owner: scenarioOwnerMultiEngineSoak, tier: "performance"},
+ "multi_engine_sqlite_candidate_pairs": {owner: scenarioOwnerMultiEngineSQLite, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "notification_only_wakeups": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "notification_payloads": {owner: scenarioOwnerMixed, tier: "codec"},
+ "panic_attempt_trace": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "pause_resume_notification": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "periodic_due_job_available": {owner: scenarioOwnerMaintenance, tier: "runtime"},
+ "periodic_run_on_start": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "periodic_unique_cross_engine": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "poll_only_remote_cancellation": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "pool_pressure_completion": {owner: scenarioOwnerMixed, tier: "performance"},
+ "process_kill_restart_and_rescue": {owner: scenarioOwnerMixed, tier: "chaos"},
+ "queue_names_and_unknown_queue_control": {owner: scenarioOwnerMaintenance, tier: "storage"},
+ "reference_insert_candidate_work": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "reference_migrator_candidate_runtime": {owner: scenarioOwnerMixed, tier: "storage"},
+ "reference_process_kill_candidate_rescue": {owner: scenarioOwnerMixed, tier: "chaos"},
+ "refetched_attempt_cancellation": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "release_enqueue_performance": {owner: scenarioOwnerPerformance, tier: "performance"},
+ "release_mixed_performance": {owner: scenarioOwnerPerformance, tier: "performance"},
+ "release_worker_performance": {owner: scenarioOwnerPerformance, tier: "performance"},
+ "remote_cancel_notification": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "remote_queue_subscription_events": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "rescuer_unknown_kind_discard": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "reserved_metadata_cross_engine": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "resumable_cross_engine_cursor": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "resumable_retry": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "resumable_validation": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "rolling_deployment_same_protocol": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "scheduler_unique_conflict_discard": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "shutdown_after_cancel_attempt": {owner: scenarioOwnerResilience, tier: "runtime"},
+ "simulated_yugabyte_polling": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "single_implementation_worker_outcomes": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "snooze_once_metadata_transition": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "sqlite_batch_atomicity": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "storage"},
+ "sqlite_deterministic_retry_unique": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "codec"},
+ "sqlite_insert_get_unique_cross_language": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "mixed"},
+ "sqlite_job_crud": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "storage"},
+ "sqlite_job_rows": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "storage"},
+ "sqlite_migration_cross_language": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "storage"},
+ "sqlite_profile_handshake": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "codec"},
+ "sqlite_runtime_attempted_by_ordering": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_claim_order": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_claim_time_cancellation": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_competing_workers": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_completion_under_writer_lock": {owner: scenarioOwnerSQLiteResilience, profile: "sqlite-runtime-v1", tier: "chaos"},
+ "sqlite_runtime_cross_language_work": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_exhausted_job_retry": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_extensions_resumable_subscriptions": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "runtime"},
+ "sqlite_runtime_external_terminal_completion_race": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_go_integer_ranges": {owner: scenarioOwnerSQLiteResilience, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_heterogeneous_fleet_known_kinds": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_invalid_json_columns": {owner: scenarioOwnerSQLiteResilience, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_job_cleaner_queue_filters": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "storage"},
+ "sqlite_runtime_job_list_cursor_interchange": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "storage"},
+ "sqlite_runtime_job_rows": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_kind_alias_rename": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_leader_election_disabled": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_leadership_failover": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "runtime"},
+ "sqlite_runtime_lifecycle_shutdown": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "runtime"},
+ "sqlite_runtime_notification_payloads": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "codec"},
+ "sqlite_runtime_notification_wakeups": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_periodic_scheduler": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "runtime"},
+ "sqlite_runtime_periodic_unique": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_poll_only_recovery": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "runtime"},
+ "sqlite_runtime_profile_handshake": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "codec"},
+ "sqlite_runtime_queue_crud_reconfigure_pause": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_remote_cancellation": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_remote_queue_subscription_events": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_rescuer_unknown_kind_discard": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_resumable_cross_engine_cursor": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_resumable_validation": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "runtime"},
+ "sqlite_runtime_scheduler_unique_conflict_discard": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_transactional_notification": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_runtime_unique_skip_keeps_existing_kind": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "storage"},
+ "sqlite_runtime_unknown_kind_error": {owner: scenarioOwnerSQLiteRuntime, profile: "sqlite-runtime-v1", tier: "mixed"},
+ "sqlite_timestamp_rounding_ordering": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "codec"},
+ "sqlite_transactions": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "storage"},
+ "sqlite_unique_column_bytes": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "codec"},
+ "sqlite_unsafe_int64_job_ids_rpc_list_cursors": {owner: scenarioOwnerSQLiteStorage, profile: "portable-storage-v1", tier: "codec"},
+ "stuck_job_detection": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "timeout_cancellation": {owner: scenarioOwnerMixed, tier: "runtime"},
+ "transaction_abort_rollback_visibility": {owner: scenarioOwnerMixed, tier: "storage"},
+ "transaction_commit_visibility": {owner: scenarioOwnerMixed, tier: "storage"},
+ "transaction_rollback_visibility": {owner: scenarioOwnerMixed, tier: "storage"},
+ "transactional_batch_insertion": {owner: scenarioOwnerMixed, tier: "storage"},
+ "transactional_completion": {owner: scenarioOwnerMixed, tier: "storage"},
+ "transactional_cross_language_cancel": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "transactional_crud_commit_rollback": {owner: scenarioOwnerMixed, tier: "storage"},
+ "transactional_insert_notification_commit_only": {owner: scenarioOwnerMixed, tier: "mixed"},
+ "transactional_queue_operations": {owner: scenarioOwnerMixed, tier: "storage"},
+ "typed_batch_insertion": {owner: scenarioOwnerMixed, tier: "storage"},
+ "unique_column_bytes": {owner: scenarioOwnerMixed, tier: "codec"},
+ "unique_hash_goldens": {owner: scenarioOwnerMixed, tier: "codec"},
+ "unique_skip_keeps_existing_kind": {owner: scenarioOwnerMixed, tier: "storage"},
+ "unsafe_int64_job_ids_rpc_list_cursors": {owner: scenarioOwnerMixed, tier: "codec"},
+}
diff --git a/conformance/harness/scenario_tracker_test.go b/conformance/harness/scenario_tracker_test.go
new file mode 100644
index 000000000..5519966f4
--- /dev/null
+++ b/conformance/harness/scenario_tracker_test.go
@@ -0,0 +1,143 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "flag"
+ "os"
+ "path"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// scenarioTracker ties registered scenario IDs to the subtests that execute
+// them, so one ID can never be credited by another scenario's assertions.
+type scenarioTracker struct {
+ adapters []*adapter
+ completed map[string]bool
+ owner string
+ t *testing.T
+}
+
+func newScenarioTracker(t *testing.T, owner string) *scenarioTracker {
+ t.Helper()
+
+ conformanceTestsStarted.Add(1)
+ tracker := &scenarioTracker{completed: make(map[string]bool), owner: owner, t: t}
+ t.Cleanup(tracker.verify)
+ return tracker
+}
+
+// attach registers adapters that must be returned to a clean state after a
+// failed scenario so later scenarios in the same owner still run
+// independently.
+func (tracker *scenarioTracker) attach(adapters ...*adapter) {
+ tracker.adapters = append(tracker.adapters, adapters...)
+}
+
+// pass records scenarios verified inline by an owner whose whole body is one
+// scenario. Owners with several scenarios use a subtest per scenario and
+// record instead.
+func (tracker *scenarioTracker) pass(names ...string) {
+ tracker.t.Helper()
+
+ for _, name := range names {
+ tracker.requireOwned(name)
+ tracker.completed[name] = true
+ }
+}
+
+func (tracker *scenarioTracker) requireOwned(name string) {
+ tracker.t.Helper()
+
+ binding, ok := scenarioRegistry[name]
+ require.True(tracker.t, ok, "unregistered conformance scenario %q", name)
+ require.Equal(tracker.t, tracker.owner, binding.owner, "scenario %q is owned by another test", name)
+ require.False(tracker.t, tracker.completed[name], "conformance scenario %q completed more than once", name)
+}
+
+// record marks the calling scenario subtest as passed. Owners run each
+// scenario with t.Run using the scenario ID as the subtest name and defer
+// record as the subtest's first statement, so the ID is credited only when
+// that subtest's own assertions completed without failing or skipping. A
+// failed scenario returns the owner's adapters to a clean state so later
+// scenarios still run independently.
+func (tracker *scenarioTracker) record(t *testing.T) {
+ t.Helper()
+
+ name := path.Base(t.Name())
+ switch {
+ case t.Skipped():
+ t.Errorf("conformance scenario %q skipped; scenarios must pass or fail", name)
+ case t.Failed():
+ for _, current := range tracker.adapters {
+ current.recover()
+ }
+ default:
+ tracker.requireOwned(name)
+ tracker.completed[name] = true
+ }
+}
+
+func (tracker *scenarioTracker) verify() {
+ tracker.t.Helper()
+
+ if tracker.t.Failed() || tracker.t.Skipped() {
+ return
+ }
+ var missing []string
+ for name, binding := range scenarioRegistry {
+ if binding.owner == tracker.owner && !tracker.completed[name] {
+ missing = append(missing, name)
+ }
+ }
+ if len(missing) == 0 {
+ return
+ }
+ slices.Sort(missing)
+ // A -run pattern that selects subtests can exclude scenarios; that is
+ // only acceptable for local debugging.
+ if runPattern := flag.Lookup("test.run").Value.String(); strings.Contains(runPattern, "/") && !conformanceRequired() {
+ tracker.t.Logf("-run %q excluded registered scenarios, so %s is not a complete result: %v", runPattern, tracker.owner, missing)
+ return
+ }
+ tracker.t.Errorf("%s did not run registered scenarios: %v", tracker.owner, missing)
+}
+
+// conformanceRequired reports whether a conformance run must not skip. CI sets
+// RIVER_CONFORMANCE_REQUIRED=1 so a missing database URL or opt-in variable
+// fails instead of passing with skipped tests.
+func conformanceRequired() bool {
+ return os.Getenv("RIVER_CONFORMANCE_REQUIRED") == "1"
+}
+
+// requireEnv returns a required environment variable. When it is unset the
+// test is skipped for local runs and fails when RIVER_CONFORMANCE_REQUIRED=1.
+func requireEnv(t *testing.T, name string) string {
+ t.Helper()
+
+ value := os.Getenv(name)
+ if value == "" {
+ if conformanceRequired() {
+ t.Fatalf("%s is required when RIVER_CONFORMANCE_REQUIRED=1", name)
+ }
+ t.Skipf("%s is required", name)
+ }
+ return value
+}
+
+// requireOptIn skips a long-running tier unless its variable is "1". A
+// required run that selects the tier without enabling it fails instead.
+func requireOptIn(t *testing.T, name string) {
+ t.Helper()
+
+ if os.Getenv(name) != "1" {
+ if conformanceRequired() {
+ t.Fatalf("%s=1 is required when RIVER_CONFORMANCE_REQUIRED=1 selects this tier", name)
+ }
+ t.Skipf("%s=1 is required", name)
+ }
+}
diff --git a/conformance/harness/scheduler_test.go b/conformance/harness/scheduler_test.go
new file mode 100644
index 000000000..afcd910ee
--- /dev/null
+++ b/conformance/harness/scheduler_test.go
@@ -0,0 +1,107 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// schedulerOutcome is what a leader's scheduler did to one due job, without
+// the values that differ between runs.
+type schedulerOutcome struct {
+ Attempt int
+ Finalized bool
+ State string
+ UniqueKeyConflict any
+}
+
+// verifySchedulerUniqueConflictDiscard checks how a leader's scheduler
+// handles due retries of unique jobs, which every implementation does in its
+// own SQL. Go prepares the same retryable jobs for each implementation's
+// leader: a unique job whose key another live job holds, two unique jobs
+// sharing a key with none live, and a job that isn't unique. Like Go's
+// scheduler, the leader must discard the conflicting job and the later of
+// the two duplicates, marking each with `unique_key_conflict`, and make the
+// others available.
+func verifySchedulerUniqueConflictDiscard(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ const queue = "scheduler_discard"
+ uniqueOpts := map[string]any{
+ "max_attempts": 3, "queue": queue,
+ "unique": map[string]any{"by_args": true, "by_state": []string{"available", "pending", "running", "scheduled"}},
+ }
+ insertRetryable := func(label string, opts map[string]any) normalizedJob {
+ t.Helper()
+
+ var job normalizedJob
+ goAdapter.call(t, "insert", map[string]any{"behavior": "error", "message": label, "opts": opts}, &job)
+ return waitForJobStateWithin(t, goAdapter, job.ID, []string{"retryable"}, 30*time.Second)
+ }
+
+ outcomes := make(map[string]map[string]schedulerOutcome)
+ for _, leader := range []*adapter{goAdapter, candidateAdapter} {
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ // The retry delay exceeds Go's default scheduler interval, so the
+ // retries stay retryable until a scheduler makes them due.
+ goAdapter.call(t, "start", map[string]any{
+ "client_id": "scheduler-discard-setup", "leader_election_disabled": true, "max_workers": 1,
+ "queue": queue, "retry_delay_ms": 5_500,
+ }, nil)
+ jobs := map[string]normalizedJob{
+ "conflict": insertRetryable("conflict", uniqueOpts),
+ "duplicate first": insertRetryable("duplicate", uniqueOpts),
+ "duplicate second": insertRetryable("duplicate", uniqueOpts),
+ "not unique": insertRetryable("not unique", map[string]any{"max_attempts": 3, "queue": queue}),
+ }
+ goAdapter.call(t, "stop", map[string]any{}, nil)
+ require.NotEqual(t, jobs["duplicate first"].ID, jobs["duplicate second"].ID,
+ "a retryable job outside its unique states blocked insertion")
+ // A live job takes the conflicting job's key. Nothing works its queue.
+ var holder normalizedJob
+ goAdapter.call(t, "insert", map[string]any{"behavior": "error", "message": "conflict", "opts": uniqueOpts}, &holder)
+ require.NotEqual(t, jobs["conflict"].ID, holder.ID)
+ require.Equal(t, "available", holder.State)
+
+ latest := time.Time{}
+ for _, job := range jobs {
+ if scheduledAt := parseTime(t, job.ScheduledAt); scheduledAt.After(latest) {
+ latest = scheduledAt
+ }
+ }
+ time.Sleep(time.Until(latest.Add(100 * time.Millisecond)))
+ leader.startWithTuning(t, map[string]any{"client_id": "scheduler-discard-leader", "max_workers": 1},
+ map[string]any{"elect_interval_ms": 20, "scheduler_interval_ms": 20})
+ expectedStates := map[string]string{
+ "conflict": "discarded",
+ "duplicate first": "available",
+ "duplicate second": "discarded",
+ "not unique": "available",
+ }
+ outcomes[leader.name] = make(map[string]schedulerOutcome)
+ for label, job := range jobs {
+ scheduled := waitForJobStateWithin(t, goAdapter, job.ID, []string{expectedStates[label]}, 30*time.Second)
+ outcomes[leader.name][label] = schedulerOutcome{
+ Attempt: scheduled.Attempt,
+ Finalized: scheduled.FinalizedAt != nil,
+ State: scheduled.State,
+ UniqueKeyConflict: scheduled.Metadata["unique_key_conflict"],
+ }
+ }
+ leader.call(t, "stop", map[string]any{}, nil)
+ var unchanged normalizedJob
+ goAdapter.call(t, "get", map[string]any{"id": holder.ID}, &unchanged)
+ require.Equal(t, "available", unchanged.State, "%s's scheduler changed the live job holding the key", leader.name)
+ }
+
+ reference := outcomes[goAdapter.name]
+ require.Equal(t, "scheduler_discarded", reference["conflict"].UniqueKeyConflict)
+ require.True(t, reference["conflict"].Finalized)
+ require.Equal(t, "scheduler_discarded", reference["duplicate second"].UniqueKeyConflict)
+ require.Nil(t, reference["duplicate first"].UniqueKeyConflict)
+ require.Equal(t, reference, outcomes[candidateAdapter.name],
+ "%s's scheduler and Go's left due retries differently", candidateAdapter.name)
+}
diff --git a/conformance/harness/schema_validator_test.go b/conformance/harness/schema_validator_test.go
new file mode 100644
index 000000000..1216cb822
--- /dev/null
+++ b/conformance/harness/schema_validator_test.go
@@ -0,0 +1,501 @@
+package harness_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/big"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// schemaValidator validates JSON documents against the subset of JSON Schema
+// 2020-12 that the conformance artifacts use. It deliberately rejects any
+// keyword it does not implement, so a schema can never rely on a constraint
+// that is silently ignored. References may point inside a document
+// ("#/$defs/name") or at another schema file relative to the referencing
+// document ("../schema/normalized-job.schema.json").
+type schemaValidator struct {
+ documents map[string]any
+ mu sync.Mutex
+ patterns map[string]*regexp.Regexp
+}
+
+func newSchemaValidator() *schemaValidator {
+ return &schemaValidator{documents: make(map[string]any), patterns: make(map[string]*regexp.Regexp)}
+}
+
+// schemaAnnotations are keywords that carry no validation.
+var schemaAnnotations = []string{"$defs", "$schema", "description", "title"} //nolint:gochecknoglobals // fixed keyword set
+
+// decodeJSONWithNumbers decodes JSON keeping numbers as json.Number so large
+// integers keep their exact value.
+func decodeJSONWithNumbers(contents []byte) (any, error) {
+ decoder := json.NewDecoder(bytes.NewReader(contents))
+ decoder.UseNumber()
+ var value any
+ if err := decoder.Decode(&value); err != nil {
+ return nil, err
+ }
+ if decoder.More() {
+ return nil, errors.New("trailing data after JSON value")
+ }
+ return value, nil
+}
+
+func (validator *schemaValidator) document(path string) (any, error) {
+ validator.mu.Lock()
+ defer validator.mu.Unlock()
+
+ if document, ok := validator.documents[path]; ok {
+ return document, nil
+ }
+ contents, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ document, err := decodeJSONWithNumbers(contents)
+ if err != nil {
+ return nil, fmt.Errorf("decode schema %s: %w", path, err)
+ }
+ validator.documents[path] = document
+ return document, nil
+}
+
+// validateFile validates value against the schema document at path, or a
+// fragment of it such as "#/$defs/job".
+func (validator *schemaValidator) validateFile(value any, path, fragment string) error {
+ document, err := validator.document(path)
+ if err != nil {
+ return err
+ }
+ schema, err := resolvePointer(document, fragment)
+ if err != nil {
+ return fmt.Errorf("%s%s: %w", path, fragment, err)
+ }
+ return validator.validate(value, schema, path, "$")
+}
+
+func resolvePointer(document any, fragment string) (any, error) {
+ fragment = strings.TrimPrefix(fragment, "#")
+ current := document
+ if fragment == "" {
+ return current, nil
+ }
+ for token := range strings.SplitSeq(strings.TrimPrefix(fragment, "/"), "/") {
+ token = strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")
+ switch container := current.(type) {
+ case map[string]any:
+ var ok bool
+ if current, ok = container[token]; !ok {
+ return nil, fmt.Errorf("pointer segment %q not found", token)
+ }
+ case []any:
+ index, err := strconv.Atoi(token)
+ if err != nil || index < 0 || index >= len(container) {
+ return nil, fmt.Errorf("pointer segment %q is not an index of a %d-item array", token, len(container))
+ }
+ current = container[index]
+ default:
+ return nil, fmt.Errorf("pointer segment %q does not address an object or array", token)
+ }
+ }
+ return current, nil
+}
+
+//nolint:cyclop,gocognit,maintidx // One switch per supported keyword keeps the subset auditable.
+func (validator *schemaValidator) validate(value, schemaValue any, documentPath, location string) error {
+ switch schema := schemaValue.(type) {
+ case bool:
+ if !schema {
+ return fmt.Errorf("%s: no value is allowed here", location)
+ }
+ return nil
+ case map[string]any:
+ keywords := make([]string, 0, len(schema))
+ for keyword := range schema {
+ keywords = append(keywords, keyword)
+ }
+ slices.Sort(keywords)
+ for _, keyword := range keywords {
+ argument := schema[keyword]
+ var err error
+ switch keyword {
+ case "$ref":
+ err = validator.validateRef(value, argument, documentPath, location)
+ case "additionalProperties", "properties", "propertyNames":
+ err = validator.validateObjectKeyword(value, schema, keyword, documentPath, location)
+ case "allOf":
+ alternatives, _ := argument.([]any)
+ for _, alternative := range alternatives {
+ if err = validator.validate(value, alternative, documentPath, location); err != nil {
+ break
+ }
+ }
+ case "if":
+ // A value matching "if" must match "then"; otherwise "else".
+ branch := "else"
+ if validator.validate(value, argument, documentPath, location) == nil {
+ branch = "then"
+ }
+ if branchSchema, ok := schema[branch]; ok {
+ err = validator.validate(value, branchSchema, documentPath, location)
+ }
+ case "then", "else":
+ // Evaluated with "if".
+ case "anyOf", "oneOf":
+ err = validator.validateAlternatives(value, keyword, argument, documentPath, location)
+ case "const":
+ if !jsonEqual(value, argument) {
+ err = fmt.Errorf("%s: must equal %v", location, argument)
+ }
+ case "enum":
+ options, _ := argument.([]any)
+ if !slices.ContainsFunc(options, func(option any) bool { return jsonEqual(value, option) }) {
+ err = fmt.Errorf("%s: %v is not one of %v", location, value, options)
+ }
+ case "exclusiveMinimum", "maximum", "minimum":
+ err = validateBound(value, keyword, argument, location)
+ case "format":
+ err = validateFormat(value, argument, location)
+ case "items":
+ if array, ok := value.([]any); ok {
+ for index, item := range array {
+ if err = validator.validate(item, argument, documentPath, fmt.Sprintf("%s[%d]", location, index)); err != nil {
+ break
+ }
+ }
+ }
+ case "minItems":
+ if array, ok := value.([]any); ok && int64(len(array)) < schemaInteger(argument) {
+ err = fmt.Errorf("%s: needs at least %v items", location, argument)
+ }
+ case "minLength":
+ if text, ok := value.(string); ok && int64(len([]rune(text))) < schemaInteger(argument) {
+ err = fmt.Errorf("%s: needs at least %v characters", location, argument)
+ }
+ case "minProperties":
+ if object, ok := value.(map[string]any); ok && int64(len(object)) < schemaInteger(argument) {
+ err = fmt.Errorf("%s: needs at least %v properties", location, argument)
+ }
+ case "pattern":
+ err = validator.validatePattern(value, argument, location)
+ case "required":
+ if object, ok := value.(map[string]any); ok {
+ names, _ := argument.([]any)
+ for _, name := range names {
+ if _, present := object[fmt.Sprint(name)]; !present {
+ err = fmt.Errorf("%s: missing required property %q", location, name)
+ break
+ }
+ }
+ }
+ case "type":
+ err = validateType(value, argument, location)
+ case "uniqueItems":
+ if array, ok := value.([]any); ok && argument == true {
+ for index := range array {
+ for other := range index {
+ if jsonEqual(array[index], array[other]) {
+ err = fmt.Errorf("%s: items %d and %d are equal", location, other, index)
+ }
+ }
+ }
+ }
+ default:
+ if !slices.Contains(schemaAnnotations, keyword) {
+ err = fmt.Errorf("%s: schema keyword %q is not supported by the harness validator", location, keyword)
+ }
+ }
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ default:
+ return fmt.Errorf("%s: schema must be an object or boolean", location)
+ }
+}
+
+func (validator *schemaValidator) validateRef(value, argument any, documentPath, location string) error {
+ reference, ok := argument.(string)
+ if !ok {
+ return fmt.Errorf("%s: $ref must be a string", location)
+ }
+ target, fragment, _ := strings.Cut(reference, "#")
+ path := documentPath
+ if target != "" {
+ path = filepath.Clean(filepath.Join(filepath.Dir(documentPath), target))
+ }
+ document, err := validator.document(path)
+ if err != nil {
+ return fmt.Errorf("%s: resolve %s: %w", location, reference, err)
+ }
+ schema, err := resolvePointer(document, fragment)
+ if err != nil {
+ return fmt.Errorf("%s: resolve %s: %w", location, reference, err)
+ }
+ return validator.validate(value, schema, path, location)
+}
+
+func (validator *schemaValidator) validateObjectKeyword(value any, schema map[string]any, keyword, documentPath, location string) error {
+ object, ok := value.(map[string]any)
+ if !ok {
+ return nil
+ }
+ properties, _ := schema["properties"].(map[string]any)
+ names := make([]string, 0, len(object))
+ for name := range object {
+ names = append(names, name)
+ }
+ slices.Sort(names)
+ for _, name := range names {
+ child := location + "." + name
+ switch keyword {
+ case "additionalProperties":
+ if _, declared := properties[name]; declared {
+ continue
+ }
+ if schema[keyword] == false {
+ return fmt.Errorf("%s: unknown property", child)
+ }
+ if err := validator.validate(object[name], schema[keyword], documentPath, child); err != nil {
+ return err
+ }
+ case "properties":
+ if propertySchema, declared := properties[name]; declared {
+ if err := validator.validate(object[name], propertySchema, documentPath, child); err != nil {
+ return err
+ }
+ }
+ case "propertyNames":
+ if err := validator.validate(name, schema[keyword], documentPath, child+" (name)"); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func (validator *schemaValidator) validateAlternatives(value any, keyword string, argument any, documentPath, location string) error {
+ alternatives, _ := argument.([]any)
+ matches := 0
+ var failures []string
+ for _, alternative := range alternatives {
+ if err := validator.validate(value, alternative, documentPath, location); err != nil {
+ failures = append(failures, err.Error())
+ continue
+ }
+ matches++
+ }
+ switch {
+ case matches == 0:
+ return fmt.Errorf("%s: matches no %s alternative: %s", location, keyword, strings.Join(failures, "; "))
+ case keyword == "oneOf" && matches > 1:
+ return fmt.Errorf("%s: matches %d oneOf alternatives", location, matches)
+ }
+ return nil
+}
+
+func (validator *schemaValidator) validatePattern(value, argument any, location string) error {
+ text, isString := value.(string)
+ if !isString {
+ return nil
+ }
+ pattern, _ := argument.(string)
+ validator.mu.Lock()
+ compiled, cached := validator.patterns[pattern]
+ if !cached {
+ var err error
+ if compiled, err = regexp.Compile(pattern); err != nil {
+ validator.mu.Unlock()
+ return fmt.Errorf("%s: invalid pattern %q: %w", location, pattern, err)
+ }
+ validator.patterns[pattern] = compiled
+ }
+ validator.mu.Unlock()
+ if !compiled.MatchString(text) {
+ return fmt.Errorf("%s: %q does not match %q", location, text, pattern)
+ }
+ return nil
+}
+
+func validateType(value, argument any, location string) error {
+ var names []string
+ switch typed := argument.(type) {
+ case string:
+ names = []string{typed}
+ case []any:
+ names = make([]string, 0, len(typed))
+ for _, name := range typed {
+ names = append(names, fmt.Sprint(name))
+ }
+ }
+ for _, name := range names {
+ if jsonType(value) == name || (name == "number" && jsonType(value) == "integer") {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s: %s is not of type %v", location, jsonType(value), names)
+}
+
+func jsonType(value any) string {
+ switch typed := value.(type) {
+ case nil:
+ return "null"
+ case bool:
+ return "boolean"
+ case string:
+ return "string"
+ case []any:
+ return "array"
+ case map[string]any:
+ return "object"
+ case json.Number:
+ if _, ok := new(big.Int).SetString(typed.String(), 10); ok {
+ return "integer"
+ }
+ return "number"
+ case float64:
+ if typed == float64(int64(typed)) {
+ return "integer"
+ }
+ return "number"
+ }
+ return fmt.Sprintf("%T", value)
+}
+
+func validateBound(value any, keyword string, argument any, location string) error {
+ number, ok := value.(json.Number)
+ if !ok {
+ return nil
+ }
+ actual, _, err := big.ParseFloat(number.String(), 10, 256, big.ToNearestEven)
+ if err != nil {
+ return fmt.Errorf("%s: %w", location, err)
+ }
+ bound, _, err := big.ParseFloat(fmt.Sprint(argument), 10, 256, big.ToNearestEven)
+ if err != nil {
+ return fmt.Errorf("%s: invalid %s: %w", location, keyword, err)
+ }
+ comparison := actual.Cmp(bound)
+ if (keyword == "minimum" && comparison < 0) || (keyword == "maximum" && comparison > 0) ||
+ (keyword == "exclusiveMinimum" && comparison <= 0) {
+ return fmt.Errorf("%s: %s violates %s %v", location, number, keyword, argument)
+ }
+ return nil
+}
+
+func validateFormat(value, argument any, location string) error {
+ text, ok := value.(string)
+ if !ok {
+ return nil
+ }
+ if argument != "date-time" {
+ return fmt.Errorf("%s: format %v is not supported by the harness validator", location, argument)
+ }
+ if _, err := time.Parse(time.RFC3339Nano, text); err != nil {
+ return fmt.Errorf("%s: %q is not an RFC 3339 date-time", location, text)
+ }
+ return nil
+}
+
+func schemaInteger(argument any) int64 {
+ number, _ := argument.(json.Number)
+ value, _ := number.Int64()
+ return value
+}
+
+func jsonEqual(first, second any) bool {
+ firstBytes, firstErr := json.Marshal(first)
+ secondBytes, secondErr := json.Marshal(second)
+ return firstErr == nil && secondErr == nil && bytes.Equal(firstBytes, secondBytes)
+}
+
+func TestSchemaValidator(t *testing.T) {
+ t.Parallel()
+
+ type testBundle struct {
+ path string
+ validator *schemaValidator
+ }
+
+ setup := func(t *testing.T, schema string) *testBundle {
+ t.Helper()
+
+ path := filepath.Join(t.TempDir(), "schema.json")
+ require.NoError(t, os.WriteFile(path, []byte(schema), 0o600))
+ return &testBundle{path: path, validator: newSchemaValidator()}
+ }
+ validate := func(t *testing.T, bundle *testBundle, value string) error {
+ t.Helper()
+
+ decoded, err := decodeJSONWithNumbers([]byte(value))
+ require.NoError(t, err)
+ return bundle.validator.validateFile(decoded, bundle.path, "")
+ }
+
+ t.Run("AdditionalPropertiesRejectUnknownNames", func(t *testing.T) {
+ t.Parallel()
+
+ bundle := setup(t, `{"additionalProperties": false, "properties": {"known": {"type": "string"}}, "type": "object"}`)
+ require.NoError(t, validate(t, bundle, `{"known": "value"}`))
+ require.ErrorContains(t, validate(t, bundle, `{"known": "value", "unknown": 1}`), "$.unknown: unknown property")
+ })
+
+ t.Run("ConditionalsAndAllOf", func(t *testing.T) {
+ t.Parallel()
+
+ bundle := setup(t, `{"allOf": [{"if": {"properties": {"kind": {"const": "a"}}}, "then": {"required": ["a"]}, "else": {"required": ["b"]}}], "type": "object"}`)
+ require.NoError(t, validate(t, bundle, `{"a": 1, "kind": "a"}`))
+ require.NoError(t, validate(t, bundle, `{"b": 1, "kind": "c"}`))
+ require.ErrorContains(t, validate(t, bundle, `{"kind": "a"}`), `missing required property "a"`)
+ require.ErrorContains(t, validate(t, bundle, `{"kind": "c"}`), `missing required property "b"`)
+ })
+
+ t.Run("IntegersKeepExactValues", func(t *testing.T) {
+ t.Parallel()
+
+ bundle := setup(t, `{"maximum": 18446744073709551615, "minimum": 0, "type": "integer"}`)
+ require.NoError(t, validate(t, bundle, `18446744073709551615`))
+ require.ErrorContains(t, validate(t, bundle, `18446744073709551616`), "maximum")
+ require.ErrorContains(t, validate(t, bundle, `-1`), "minimum")
+ require.ErrorContains(t, validate(t, bundle, `1.5`), "not of type")
+ })
+
+ t.Run("ReferencesResolveAcrossFiles", func(t *testing.T) {
+ t.Parallel()
+
+ bundle := setup(t, `{"$defs": {"local": {"$ref": "other.json#/$defs/name"}}, "$ref": "#/$defs/local"}`)
+ require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(bundle.path), "other.json"),
+ []byte(`{"$defs": {"name": {"minLength": 2, "type": "string"}}}`), 0o600))
+ require.NoError(t, validate(t, bundle, `"ok"`))
+ require.ErrorContains(t, validate(t, bundle, `"x"`), "at least 2 characters")
+ })
+
+ t.Run("RequiredAndEnum", func(t *testing.T) {
+ t.Parallel()
+
+ bundle := setup(t, `{"properties": {"state": {"enum": ["a", "b"]}}, "required": ["state"], "type": "object"}`)
+ require.NoError(t, validate(t, bundle, `{"state": "a"}`))
+ require.ErrorContains(t, validate(t, bundle, `{}`), `missing required property "state"`)
+ require.ErrorContains(t, validate(t, bundle, `{"state": "c"}`), "is not one of")
+ })
+
+ t.Run("UnsupportedKeywordsFail", func(t *testing.T) {
+ t.Parallel()
+
+ bundle := setup(t, `{"maxLength": 3, "type": "string"}`)
+ require.ErrorContains(t, validate(t, bundle, `"abc"`), `keyword "maxLength" is not supported`)
+ })
+}
diff --git a/conformance/harness/sqlite_test.go b/conformance/harness/sqlite_test.go
new file mode 100644
index 000000000..d4e51e46d
--- /dev/null
+++ b/conformance/harness/sqlite_test.go
@@ -0,0 +1,1083 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "cmp"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+//nolint:paralleltest,tparallel // Scenarios share one database and adapter processes, so they run sequentially.
+func TestMixedSQLiteConformance(t *testing.T) {
+ t.Parallel()
+
+ scenarios := newScenarioTracker(t, scenarioOwnerSQLiteStorage)
+ repositoryRoot := repoRoot(t)
+ databaseURL := filepath.Join(t.TempDir(), "river-conformance.sqlite")
+ goAdapter := startReferenceAdapterForProfile(t, repositoryRoot, databaseURL, "sqlite", "", "go")
+ candidateSpec := conformanceCandidateSpec(t, repositoryRoot, false)
+ candidateSpec.requireProfile(t, profilePortableStorage)
+ candidateAdapter := startAdapterCommandForProfile(
+ t, repositoryRoot, databaseURL, "sqlite", "", candidateSpec.Implementation, candidateSpec, candidateSpec.Command,
+ )
+ scenarios.attach(goAdapter, candidateAdapter)
+ pair := mixedPair{candidate: candidateAdapter, candidateSpec: candidateSpec, reference: goAdapter}
+
+ t.Run("sqlite_profile_handshake", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyProfileHandshakes(t, repositoryRoot, "conformance/adapter/profiles/sqlite.json", candidateSpec, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_deterministic_retry_unique", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyDeterministicControls(t, repositoryRoot, goAdapter, candidateAdapter)
+ verifyUniqueKeyGoldens(t, repositoryRoot, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_migration_cross_language", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteMigrations(t, readManifest(t, repositoryRoot).Migration.Latest, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_insert_get_unique_cross_language", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteCrossLanguageInsertion(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_batch_atomicity", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyBatchInsertion(t, goAdapter, candidateAdapter)
+ pair.eachDirection(func(actor, observer *adapter) {
+ verifyTransactionalBatchInsertion(t, actor, observer)
+ })
+ })
+ t.Run("sqlite_job_rows", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteJobRows(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_unique_column_bytes", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUniqueColumnBytes(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_job_crud", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyDifferentialJobCRUD(t, goAdapter, candidateAdapter)
+ verifyLargeMetadataRoundTrip(t, goAdapter, candidateAdapter)
+ verifyBulkDeleteSafety(t, goAdapter, candidateAdapter)
+ verifyDifferentialListCursors(t, goAdapter, candidateAdapter, false)
+ })
+ t.Run("sqlite_unsafe_int64_job_ids_rpc_list_cursors", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUnsafeInt64JobIDs(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_transactions", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteTransactions(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_timestamp_rounding_ordering", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteTimestampEncoding(t, goAdapter, candidateAdapter)
+ })
+}
+
+//nolint:paralleltest,tparallel // Scenarios share one database and adapter processes, so they run sequentially.
+func TestMixedSQLiteRuntimeConformance(t *testing.T) {
+ t.Parallel()
+
+ scenarios := newScenarioTracker(t, scenarioOwnerSQLiteRuntime)
+ repositoryRoot := repoRoot(t)
+ databaseURL := filepath.Join(t.TempDir(), "river-conformance-runtime.sqlite")
+ const profileName = "sqlite-runtime-v1"
+ goAdapter := startReferenceAdapterForProfile(t, repositoryRoot, databaseURL, "sqlite", profileName, "go")
+ candidateSpec := conformanceCandidateSpec(t, repositoryRoot, false)
+ candidateSpec.requireProfile(t, profileSQLiteRuntime)
+ candidateAdapter := startAdapterCommandForProfile(
+ t, repositoryRoot, databaseURL, "sqlite", profileName,
+ candidateSpec.Implementation, candidateSpec, candidateSpec.Command,
+ )
+ scenarios.attach(goAdapter, candidateAdapter)
+ pair := mixedPair{candidate: candidateAdapter, candidateSpec: candidateSpec, reference: goAdapter}
+
+ t.Run("sqlite_runtime_profile_handshake", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyProfileHandshakes(t, repositoryRoot, "conformance/adapter/profiles/sqlite-runtime.json", candidateSpec, goAdapter, candidateAdapter)
+ })
+ goAdapter.call(t, "migrate", map[string]any{}, nil)
+ t.Run("sqlite_runtime_cross_language_work", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteCrossLanguageWork(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_external_terminal_completion_race", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyExternalTerminalCompletionRace(t, goAdapter, candidateAdapter)
+ verifyExternalTerminalCompletionRace(t, candidateAdapter, goAdapter)
+ })
+ t.Run("sqlite_runtime_claim_order", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyClaimOrder(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_scheduler_unique_conflict_discard", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySchedulerUniqueConflictDiscard(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_exhausted_job_retry", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyExhaustedJobRetry(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_kind_alias_rename", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyKindAliasRename(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_heterogeneous_fleet_known_kinds", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyHeterogeneousFleet(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_rescuer_unknown_kind_discard", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyRescuerUnknownKind(t, goAdapter, candidateAdapter, func(t *testing.T, name string) *adapter {
+ t.Helper()
+
+ return startReferenceAdapterForProfile(t, repositoryRoot, databaseURL, "sqlite", profileName, name)
+ })
+ })
+ t.Run("sqlite_runtime_unknown_kind_error", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUnknownKind(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_unique_skip_keeps_existing_kind", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUniqueSkipKeepsExistingKind(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_attempted_by_ordering", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteAttemptedByHistory(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_competing_workers", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteCompetingWorkers(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_queue_crud_reconfigure_pause", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteQueues(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_job_cleaner_queue_filters", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyJobCleanerQueueFilters(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_notification_wakeups", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(controller, worker *adapter) {
+ verifyInsertNotificationWakeup(t, controller, worker)
+ verifyPauseResumeNotification(t, controller, worker)
+ })
+ })
+ t.Run("sqlite_runtime_remote_cancellation", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(controller, worker *adapter) {
+ verifyRemoteCancelNotification(t, controller, worker)
+ verifyCooperativeRemoteCancellation(t, controller, worker)
+ verifyPollOnlyRemoteCancellation(t, controller, worker)
+ })
+ verifySQLiteCancelNotifications(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_claim_time_cancellation", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(canceller, claimer *adapter) { verifyClaimTimeCancellation(t, canceller, claimer, false) })
+ })
+ t.Run("sqlite_runtime_notification_payloads", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyNotificationPayloads(t, goAdapter, candidateAdapter, func(actor *adapter) notificationCapture {
+ observer := goAdapter
+ if actor == goAdapter {
+ observer = candidateAdapter
+ }
+ return newSQLiteNotificationCapture(t, observer)
+ })
+ })
+ t.Run("sqlite_runtime_remote_queue_subscription_events", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyRemoteQueueSubscriptionEvents(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_transactional_notification", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteTransactionalNotification(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_job_rows", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteWorkedJobRows(t, goAdapter, candidateAdapter)
+ verifySQLiteRuntimeJobRows(t, repositoryRoot, databaseURL, profileName, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_job_list_cursor_interchange", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyJobListCursorInterchange(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_leadership_failover", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteLeadershipFailover(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_leader_election_disabled", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachDirection(func(disabled, eligible *adapter) { verifyLeaderElectionDisabled(t, disabled, eligible) })
+ })
+ t.Run("sqlite_runtime_periodic_scheduler", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLitePeriodicScheduler(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_periodic_unique", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyUniquePeriodicJob(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_extensions_resumable_subscriptions", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifySQLiteAdvancedRuntime(t, current) })
+ })
+ t.Run("sqlite_runtime_resumable_validation", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ pair.eachAdapter(func(current *adapter) { verifyResumableValidation(t, current) })
+ })
+ t.Run("sqlite_runtime_resumable_cross_engine_cursor", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifyResumableInteroperability(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_poll_only_recovery", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLitePollOnly(t, goAdapter, candidateAdapter)
+ })
+ t.Run("sqlite_runtime_lifecycle_shutdown", func(t *testing.T) {
+ defer scenarios.record(t)
+
+ verifySQLiteLifecycle(t, goAdapter, candidateAdapter)
+ })
+}
+
+// verifyProfileHandshakes checks that the reference and candidate advertise
+// exactly the named profile's capabilities and methods.
+func verifyProfileHandshakes(t *testing.T, repositoryRoot, profilePath string, candidateSpec adapterSpec, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ var profile adapterProfile
+ profileBytes, err := os.ReadFile(filepath.Join(repositoryRoot, profilePath))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(profileBytes, &profile))
+ manifest := readManifest(t, repositoryRoot)
+ for _, testCase := range []struct {
+ adapter *adapter
+ implementation string
+ }{
+ {adapter: goAdapter, implementation: "go"},
+ {adapter: candidateAdapter, implementation: candidateSpec.Implementation},
+ } {
+ var handshake adapterHandshake
+ testCase.adapter.call(t, "handshake", map[string]any{}, &handshake)
+ require.Equal(t, testCase.implementation, handshake.Implementation)
+ require.Equal(t, manifest.Implementations[testCase.implementation].Version, handshake.ImplementationVersion)
+ require.Equal(t, profile.Backend, handshake.Backend)
+ require.Equal(t, profile.Name, handshake.Profile)
+ require.Equal(t, profile.ProtocolRevision, handshake.ProtocolRevision)
+ require.Equal(t, profile.Capabilities, handshake.Capabilities)
+ require.Equal(t, profile.Methods, handshake.Methods)
+ require.Equal(t, map[string]int{manifest.Migration.Line: manifest.Migration.Latest}, handshake.MigrationLines)
+ }
+ verifyRequestStrictness(t, goAdapter, candidateAdapter)
+ contract, err := sharedAdapterContract()
+ require.NoError(t, err)
+ for method := range contract.methods {
+ if !slices.Contains(profile.Methods, method) {
+ for _, current := range []*adapter{goAdapter, candidateAdapter} {
+ current.requireUnvalidatedCallError(t, method, map[string]any{}, "method_not_found")
+ }
+ }
+ }
+}
+
+func verifySQLiteCompetingWorkers(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ goAdapter.call(t, "start", map[string]any{
+ "client_id": "go-sqlite-competitor", "max_workers": 2,
+ }, nil)
+ candidateAdapter.call(t, "start", map[string]any{
+ "client_id": "candidate-sqlite-competitor", "max_workers": 2,
+ }, nil)
+ const jobCount = 40
+ jobs := make([]map[string]any, jobCount)
+ for index := range jobs {
+ jobs[index] = map[string]any{
+ "behavior": "sleep", "duration_ms": 20,
+ "message": fmt.Sprintf("SQLite competing worker %d", index),
+ "opts": map[string]any{"tags": []string{"sqlite_competing_workers"}},
+ }
+ }
+ var inserted struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ goAdapter.call(t, "insert_many", map[string]any{"jobs": jobs}, &inserted)
+ require.Len(t, inserted.Results, jobCount)
+ worked := waitForListedJobCount(t, candidateAdapter, map[string]any{
+ "states": []string{"completed"}, "tags_all": []string{"sqlite_competing_workers"},
+ }, jobCount)
+ workerIDs := make(map[string]bool)
+ for _, job := range worked {
+ for _, workerID := range job.AttemptedBy {
+ workerIDs[workerID] = true
+ }
+ }
+ require.True(t, workerIDs["go-sqlite-competitor"], "Go worker claimed no jobs")
+ require.True(t, workerIDs["candidate-sqlite-competitor"], "Candidate worker claimed no jobs")
+ goAdapter.call(t, "stop", map[string]any{}, nil)
+ candidateAdapter.call(t, "stop", map[string]any{}, nil)
+}
+
+func verifySQLiteAdvancedRuntime(t *testing.T, adapter *adapter) {
+ t.Helper()
+
+ adapter.call(t, "reset", map[string]any{}, nil)
+ adapter.call(t, "start", map[string]any{
+ "client_id": adapter.name + "-sqlite-advanced-runtime", "instrumented": true,
+ "max_workers": 2, "retry_delay_ms": 5,
+ }, nil)
+
+ var ordinary normalizedJob
+ adapter.call(t, "insert", map[string]any{"message": "SQLite extension order"}, &ordinary)
+ adapter.call(t, "wait", map[string]any{"id": ordinary.ID}, &ordinary)
+ require.Equal(t, "completed", ordinary.State)
+
+ var resumable normalizedJob
+ adapter.call(t, "insert", map[string]any{
+ "behavior": "resumable", "message": "SQLite resumable",
+ "opts": map[string]any{"max_attempts": 2},
+ }, &resumable)
+ adapter.call(t, "wait", map[string]any{"id": resumable.ID}, &resumable)
+ require.Equal(t, "completed", resumable.State)
+ require.Len(t, resumable.Errors, 1)
+ require.Equal(t, "first", resumable.Metadata["river:resumable_step"])
+
+ adapter.call(t, "queue_pause", map[string]any{"name": "default"}, nil)
+ _ = waitForRuntimeStats(t, adapter, func(stats runtimeStats) bool {
+ return slices.Contains(stats.Events, "queue_paused")
+ })
+ adapter.call(t, "queue_resume", map[string]any{"name": "default"}, nil)
+ stats := waitForRuntimeStats(t, adapter, func(stats runtimeStats) bool {
+ return stats.ResumableFirstRuns == 1 && stats.ResumableSecondRuns == 2 &&
+ slices.Contains(stats.Events, "job_completed") &&
+ slices.Contains(stats.Events, "job_failed") &&
+ slices.Contains(stats.Events, "queue_paused") &&
+ slices.Contains(stats.Events, "queue_resumed")
+ })
+ requireOrderedSubsequence(t, stats.Trace, []string{
+ "hook:insert_begin",
+ "middleware:insert_before",
+ "middleware:insert_after",
+ })
+ requireOrderedSubsequence(t, stats.Trace, []string{
+ "hook:work_begin",
+ "hook:work_end",
+ })
+ requireOrderedSubsequence(t, stats.Trace, []string{
+ "middleware:work_before",
+ "middleware:work_after",
+ })
+ adapter.call(t, "stop", map[string]any{}, nil)
+}
+
+func verifySQLiteAttemptedByHistory(t *testing.T, inserter, worker *adapter) {
+ t.Helper()
+
+ inserter.call(t, "reset", map[string]any{}, nil)
+ var job normalizedJob
+ inserter.call(t, "insert", map[string]any{
+ "behavior": "error", "message": "SQLite attempted_by history",
+ "opts": map[string]any{"max_attempts": 200},
+ }, &job)
+ const attemptCount = 102
+ workerIDs := make([]string, attemptCount)
+ for attempt := range attemptCount {
+ workerIDs[attempt] = fmt.Sprintf("%s-sqlite-history-%03d", worker.name, attempt)
+ worker.call(t, "start", map[string]any{
+ "client_id": workerIDs[attempt], "max_workers": 1, "retry_delay_ms": 60_000,
+ }, nil)
+ worker.call(t, "wait", map[string]any{
+ "id": job.ID, "states": []string{"retryable"},
+ }, &job)
+ require.Equal(t, attempt+1, job.Attempt)
+ worker.call(t, "stop", map[string]any{}, nil)
+ if attempt+1 < attemptCount {
+ inserter.call(t, "retry", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, "available", job.State)
+ }
+ }
+ for _, observer := range []*adapter{inserter, worker} {
+ observer.call(t, "get", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, workerIDs[attemptCount-100:], job.AttemptedBy)
+ }
+}
+
+func verifySQLiteCrossLanguageWork(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ inserter *adapter
+ worker *adapter
+ }{
+ {inserter: goAdapter, worker: candidateAdapter},
+ {inserter: candidateAdapter, worker: goAdapter},
+ } {
+ pair.inserter.call(t, "reset", map[string]any{}, nil)
+ var inserted, worked normalizedJob
+ pair.inserter.call(t, "insert", map[string]any{
+ "message": "SQLite cross-language work " + pair.inserter.name,
+ }, &inserted)
+ pair.worker.call(t, "work", map[string]any{
+ "client_id": pair.worker.name + "-sqlite-worker", "id": inserted.ID,
+ }, &worked)
+ require.Equal(t, "completed", worked.State)
+ require.Equal(t, []string{pair.worker.name + "-sqlite-worker"}, worked.AttemptedBy)
+ }
+}
+
+func verifySQLiteLeadershipFailover(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ verifyLeadershipRequestLifecycle(t, nil, goAdapter, candidateAdapter)
+
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ goAdapter.call(t, "start", map[string]any{
+ "client_id": "go-sqlite-leader", "max_workers": 1,
+ }, nil)
+ candidateAdapter.call(t, "start", map[string]any{
+ "client_id": "candidate-sqlite-leader", "max_workers": 1,
+ }, nil)
+ first := waitForLeader(t, goAdapter, "")
+ var leader, follower *adapter
+ var followerID string
+ if first == "go-sqlite-leader" {
+ leader, follower, followerID = goAdapter, candidateAdapter, "candidate-sqlite-leader"
+ } else {
+ require.Equal(t, "candidate-sqlite-leader", first)
+ leader, follower, followerID = candidateAdapter, goAdapter, "go-sqlite-leader"
+ }
+ leader.call(t, "stop", map[string]any{}, nil)
+ require.Equal(t, followerID, waitForLeader(t, follower, first))
+ term := readLeader(t, follower)
+ follower.call(t, "request_resign", map[string]any{}, nil)
+ _ = waitForLeaderTerm(t, follower, term.ElectedAt)
+ follower.call(t, "stop", map[string]any{}, nil)
+}
+
+func verifySQLiteLifecycle(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, worker := range []*adapter{goAdapter, candidateAdapter} {
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-sqlite-lifecycle", "max_workers": 1,
+ }, nil)
+ var job normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "behavior": "sleep", "duration_ms": 150, "message": "graceful SQLite shutdown",
+ }, &job)
+ worker.call(t, "wait", map[string]any{
+ "id": job.ID, "states": []string{"running"},
+ }, &job)
+ worker.call(t, "stop", map[string]any{}, nil)
+ worker.call(t, "get", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, "completed", job.State)
+ }
+}
+
+func verifySQLitePeriodicScheduler(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, worker := range []*adapter{goAdapter, candidateAdapter} {
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.startWithTuning(t, map[string]any{
+ "client_id": worker.name + "-sqlite-maintenance", "instrumented": true,
+ "max_workers": 1, "periodic_run_on_start": true,
+ }, map[string]any{"scheduler_interval_ms": 20})
+ var scheduled normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "message": "SQLite scheduled job",
+ "opts": map[string]any{
+ "scheduled_at": time.Now().Add(150 * time.Millisecond).UTC().Format(time.RFC3339Nano),
+ "tags": []string{"sqlite_scheduler"},
+ },
+ }, &scheduled)
+ worker.call(t, "wait", map[string]any{"id": scheduled.ID}, &scheduled)
+ require.Equal(t, "completed", scheduled.State)
+
+ periodic := waitForListedJob(t, worker, map[string]any{})
+ deadline := time.Now().Add(10 * time.Second)
+ for periodic.Metadata["river:periodic_job_id"] != "conformance-periodic" && time.Now().Before(deadline) {
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ worker.call(t, "list", map[string]any{}, &listed)
+ for _, candidate := range listed.Jobs {
+ if candidate.Metadata["river:periodic_job_id"] == "conformance-periodic" {
+ periodic = candidate
+ break
+ }
+ }
+ if periodic.Metadata["river:periodic_job_id"] != "conformance-periodic" {
+ time.Sleep(10 * time.Millisecond)
+ }
+ }
+ require.Equal(t, "conformance-periodic", periodic.Metadata["river:periodic_job_id"])
+ worker.call(t, "wait", map[string]any{"id": periodic.ID}, &periodic)
+ require.Equal(t, "completed", periodic.State)
+ stats := waitForRuntimeStats(t, worker, func(stats runtimeStats) bool {
+ return stats.PeriodicStarts == 1
+ })
+ require.Equal(t, 1, stats.PeriodicStarts)
+ worker.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+func verifySQLitePollOnly(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ inserter *adapter
+ worker *adapter
+ }{
+ {inserter: goAdapter, worker: candidateAdapter},
+ {inserter: candidateAdapter, worker: goAdapter},
+ } {
+ pair.worker.call(t, "reset", map[string]any{}, nil)
+ pair.worker.call(t, "start", map[string]any{
+ "client_id": pair.worker.name + "-sqlite-poll-only", "fetch_poll_interval_ms": 20,
+ "max_workers": 1, "poll_only": true,
+ }, nil)
+ var job normalizedJob
+ pair.inserter.call(t, "insert", map[string]any{
+ "message": "SQLite poll-only recovery " + pair.inserter.name,
+ }, &job)
+ pair.worker.call(t, "wait", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, "completed", job.State)
+ pair.worker.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+func verifySQLiteQueues(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ observer *adapter
+ writer *adapter
+ }{
+ {observer: candidateAdapter, writer: goAdapter},
+ {observer: goAdapter, writer: candidateAdapter},
+ } {
+ pair.writer.call(t, "reset", map[string]any{}, nil)
+ pair.writer.call(t, "start", map[string]any{
+ "client_id": pair.writer.name + "-sqlite-queue-crud", "max_workers": 1,
+ }, nil)
+ pair.writer.call(t, "stop", map[string]any{}, nil)
+ var observed, updated, written normalizedQueue
+ pair.writer.call(t, "queue_get", map[string]any{"name": "default"}, &written)
+ pair.observer.call(t, "queue_get", map[string]any{"name": "default"}, &observed)
+ require.Equal(t, written, observed)
+ pair.observer.call(t, "queue_update", map[string]any{
+ "metadata": map[string]any{"updated_by": pair.observer.name}, "name": "default",
+ }, &updated)
+ pair.writer.call(t, "queue_get", map[string]any{"name": "default"}, &observed)
+ require.Equal(t, updated, observed)
+ var queues struct {
+ Queues []normalizedQueue `json:"queues"`
+ }
+ pair.writer.call(t, "queue_list", map[string]any{}, &queues)
+ require.Contains(t, queues.Queues, updated)
+ }
+ verifyTransactionalJobCRUD(t, goAdapter, candidateAdapter)
+ verifyTransactionalQueueOperations(t, goAdapter, candidateAdapter)
+ for _, worker := range []*adapter{goAdapter, candidateAdapter} {
+ worker.call(t, "reset", map[string]any{}, nil)
+ worker.call(t, "start", map[string]any{
+ "client_id": worker.name + "-sqlite-dynamic-queue", "instrumented": true,
+ "max_workers": 1,
+ }, nil)
+ worker.call(t, "queue_add", map[string]any{"max_workers": 1, "name": "dynamic"}, nil)
+ worker.call(t, "queue_add", map[string]any{"max_workers": 2, "name": "dynamic"}, nil)
+ var warmup normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "message": "activate SQLite dynamic queue",
+ "opts": map[string]any{"queue": "dynamic"},
+ }, &warmup)
+ worker.call(t, "wait", map[string]any{"id": warmup.ID}, &warmup)
+ require.Equal(t, "completed", warmup.State)
+ worker.call(t, "queue_pause", map[string]any{"name": "dynamic"}, nil)
+ _ = waitForRuntimeStats(t, worker, func(stats runtimeStats) bool {
+ return slices.Contains(stats.Events, "queue_paused")
+ })
+ // A default-queue marker inserted after the paused job proves the
+ // worker kept fetching while the dynamic queue held its job.
+ var job, marker normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "message": "SQLite dynamic queue", "opts": map[string]any{"queue": "dynamic"},
+ }, &job)
+ worker.call(t, "insert", map[string]any{"message": "SQLite default queue marker"}, &marker)
+ worker.call(t, "wait", map[string]any{"id": marker.ID}, &marker)
+ require.Equal(t, "completed", marker.State)
+ worker.call(t, "get", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, "available", job.State)
+ worker.call(t, "queue_resume", map[string]any{"name": "dynamic"}, nil)
+ var queue normalizedQueue
+ worker.call(t, "queue_get", map[string]any{"name": "dynamic"}, &queue)
+ worker.call(t, "wait", map[string]any{"id": job.ID}, &job)
+ require.Equal(t, "completed", job.State)
+ require.NotNil(t, job.AttemptedAt)
+ require.False(t, parseTime(t, *job.AttemptedAt).Before(parseTime(t, queue.UpdatedAt)),
+ "paused dynamic queue job attempted before it resumed")
+ worker.call(t, "queue_remove", map[string]any{"name": "dynamic"}, nil)
+ worker.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+func verifySQLiteTransactionalNotification(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ controller *adapter
+ worker *adapter
+ }{
+ {controller: candidateAdapter, worker: goAdapter},
+ {controller: goAdapter, worker: candidateAdapter},
+ } {
+ pair.worker.call(t, "reset", map[string]any{}, nil)
+ pair.worker.call(t, "start", map[string]any{
+ "client_id": pair.worker.name + "-sqlite-transaction-notification",
+ "fetch_poll_interval_ms": 60_000, "max_workers": 2,
+ }, nil)
+ handle := "sqlite-notification-" + pair.controller.name
+ tag := strings.ReplaceAll(handle, "-", "_")
+ pair.controller.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var inserted struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ pair.controller.call(t, "tx_insert_many", map[string]any{
+ "handle": handle,
+ "jobs": []map[string]any{
+ {"message": handle + " first", "opts": map[string]any{"tags": []string{tag}}},
+ {"message": handle + " second", "opts": map[string]any{"tags": []string{tag}}},
+ },
+ }, &inserted)
+ require.Len(t, inserted.Results, 2)
+ // The worker polls once a minute, so prompt completion after commit
+ // proves the committed outbox notification woke it.
+ startedAt := time.Now()
+ pair.controller.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ waitForListedJobCount(t, pair.worker, map[string]any{
+ "states": []string{"completed"}, "tags_all": []string{tag},
+ }, 2)
+ require.Less(t, time.Since(startedAt), 5*time.Second)
+ pair.worker.call(t, "stop", map[string]any{}, nil)
+ }
+}
+
+func verifySQLiteCrossLanguageInsertion(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ observer *adapter
+ writer *adapter
+ }{
+ {observer: candidateAdapter, writer: goAdapter},
+ {observer: goAdapter, writer: candidateAdapter},
+ } {
+ pair.writer.call(t, "reset", map[string]any{}, nil)
+ params := map[string]any{
+ "message": "SQLite insertion from " + pair.writer.name,
+ "opts": map[string]any{
+ "metadata": map[string]any{"writer": pair.writer.name},
+ "tags": []string{"sqlite_cross_language"},
+ },
+ }
+ var inserted, observed normalizedJob
+ pair.writer.call(t, "insert", params, &inserted)
+ require.NotNil(t, inserted.Errors)
+ require.Empty(t, inserted.Errors)
+ pair.observer.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.NotNil(t, observed.Errors)
+ require.Equal(t, inserted, observed)
+
+ // Each unique option must produce the same key and states in both
+ // implementations, so the observer's insertion is a duplicate.
+ for _, testCase := range uniqueColumnCases() {
+ uniqueParams := map[string]any{
+ "message": "SQLite unique " + testCase.name + " from " + pair.writer.name,
+ "opts": testCase.opts,
+ }
+ pair.writer.call(t, "insert", uniqueParams, &inserted)
+ pair.observer.call(t, "insert", uniqueParams, &observed)
+ require.Equal(t, inserted, observed, "%s: %s inserted a duplicate of %s's job", testCase.name, pair.observer.name, pair.writer.name)
+ }
+ }
+}
+
+func verifySQLiteMigrations(t *testing.T, latest int, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ type migrationResult struct {
+ Applied []int `json:"applied"`
+ Existing []int `json:"existing"`
+ Valid bool `json:"valid"`
+ }
+ expectedLatest := make([]int, latest)
+ for index := range latest {
+ expectedLatest[index] = index + 1
+ }
+ for initializerIndex, initializer := range []*adapter{goAdapter, candidateAdapter} {
+ observer := []*adapter{candidateAdapter, goAdapter}[initializerIndex]
+ for version := 1; version <= len(expectedLatest); version++ {
+ var result migrationResult
+ initializer.call(t, "migrate", map[string]any{
+ "direction": "down", "target_version": -1,
+ }, &result)
+ require.Empty(t, result.Existing)
+
+ initializer.call(t, "migrate", map[string]any{
+ "direction": "up", "target_version": version,
+ }, &result)
+ require.Equal(t, expectedLatest[:version], result.Applied)
+ require.Equal(t, expectedLatest[:version], result.Existing)
+ require.Equal(t, version == len(expectedLatest), result.Valid)
+
+ observer.call(t, "migrate", map[string]any{
+ "direction": "down", "dry_run": true, "target_version": version,
+ }, &result)
+ require.Empty(t, result.Applied)
+ require.Equal(t, expectedLatest[:version], result.Existing)
+
+ observer.call(t, "migrate", map[string]any{}, &result)
+ require.Equal(t, expectedLatest[version:], result.Applied)
+ require.Equal(t, expectedLatest, result.Existing)
+ require.True(t, result.Valid)
+ var inserted, observed normalizedJob
+ observer.call(t, "insert", map[string]any{
+ "message": fmt.Sprintf("SQLite historical migration %d", version),
+ }, &inserted)
+ initializer.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.Equal(t, inserted, observed)
+
+ initializer.call(t, "migrate", map[string]any{
+ "direction": "down", "target_version": version,
+ }, &result)
+ require.Equal(t, expectedLatest[:version], result.Existing)
+ observer.call(t, "migrate", map[string]any{
+ "direction": "down", "dry_run": true, "target_version": version,
+ }, &result)
+ require.Empty(t, result.Applied)
+ require.Equal(t, expectedLatest[:version], result.Existing)
+
+ observer.call(t, "migrate", map[string]any{}, &result)
+ require.Equal(t, expectedLatest, result.Existing)
+ require.True(t, result.Valid)
+ observer.call(t, "migrate", map[string]any{
+ "direction": "down", "target_version": -1,
+ }, &result)
+ require.Empty(t, result.Existing)
+ }
+ }
+ var result migrationResult
+ goAdapter.call(t, "migrate", map[string]any{}, &result)
+ require.Equal(t, expectedLatest, result.Applied)
+ require.Equal(t, expectedLatest, result.Existing)
+ require.True(t, result.Valid)
+}
+
+// verifySQLiteTimestampEncoding has each implementation write the same
+// scheduled times, which SQLite stores as millisecond text, and requires
+// every writer to round them as Go does: to the nearest millisecond, with
+// halfway values rounded up (toward the future even before 1970), carrying
+// into the second. Both implementations must read every row back the same
+// way and list them in time order.
+func verifySQLiteTimestampEncoding(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ testCases := []struct {
+ expected string
+ expectedRaw string
+ input string
+ }{
+ {expected: "2026-01-02T03:04:05.123Z", expectedRaw: "2026-01-02 03:04:05.123", input: "2026-01-02T03:04:05.1234Z"},
+ {expected: "2026-01-02T03:04:05.124Z", expectedRaw: "2026-01-02 03:04:05.124", input: "2026-01-02T03:04:05.1238Z"},
+ {expected: "2026-01-02T03:04:05Z", expectedRaw: "2026-01-02 03:04:05.000", input: "2026-01-02T03:04:05.0004999Z"},
+ {expected: "2026-01-02T03:04:05.001Z", expectedRaw: "2026-01-02 03:04:05.001", input: "2026-01-02T03:04:05.0005Z"},
+ {expected: "2026-01-02T03:04:06Z", expectedRaw: "2026-01-02 03:04:06.000", input: "2026-01-02T03:04:05.9995Z"},
+ {expected: "1970-01-01T00:00:00Z", expectedRaw: "1970-01-01 00:00:00.000", input: "1969-12-31T23:59:59.9995Z"},
+ {expected: "1969-12-31T23:59:59.998Z", expectedRaw: "1969-12-31 23:59:59.998", input: "1969-12-31T23:59:59.9975Z"},
+ }
+ type insertedJob struct {
+ expected time.Time
+ id int64
+ }
+ writers := []*adapter{goAdapter, candidateAdapter}
+ inserted := make([]insertedJob, 0, len(writers)*len(testCases))
+ for _, writer := range writers {
+ for _, testCase := range testCases {
+ var job normalizedJob
+ writer.call(t, "insert", map[string]any{
+ "message": "SQLite timestamp " + testCase.input,
+ "opts": map[string]any{
+ "scheduled_at": testCase.input,
+ "tags": []string{"sqlite_timestamps"},
+ },
+ }, &job)
+ require.Equal(t, testCase.expected, job.ScheduledAt, "%s writing %s", writer.name, testCase.input)
+ inserted = append(inserted, insertedJob{expected: parseTime(t, testCase.expected), id: job.ID})
+ for _, observer := range []*adapter{goAdapter, candidateAdapter} {
+ var observed normalizedJob
+ observer.call(t, "get", map[string]any{"id": job.ID}, &observed)
+ require.Equal(t, testCase.expected, observed.ScheduledAt,
+ "%s reading %s's %s", observer.name, writer.name, testCase.input)
+ var raw struct {
+ CreatedAt string `json:"created_at"`
+ ScheduledAt string `json:"scheduled_at"`
+ }
+ observer.call(t, "raw_job_timestamps", map[string]any{"id": job.ID}, &raw)
+ require.Equal(t, testCase.expectedRaw, raw.ScheduledAt, "%s's stored %s", writer.name, testCase.input)
+ _, err := time.Parse("2006-01-02 15:04:05.000", raw.CreatedAt)
+ require.NoError(t, err)
+ }
+ }
+ }
+ slices.SortStableFunc(inserted, func(a, b insertedJob) int {
+ return cmp.Or(a.expected.Compare(b.expected), cmp.Compare(a.id, b.id))
+ })
+ expectedIDs := make([]int64, len(inserted))
+ for index, job := range inserted {
+ expectedIDs[index] = job.id
+ }
+ for _, observer := range []*adapter{goAdapter, candidateAdapter} {
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ observer.call(t, "list", map[string]any{
+ "direction": "asc", "limit": len(inserted), "order_by": "scheduled_at", "states": []string{"scheduled"},
+ "tags_all": []string{"sqlite_timestamps"},
+ }, &listed)
+ require.Equal(t, expectedIDs, jobIDs(listed.Jobs), "%s listing by scheduled_at", observer.name)
+ }
+}
+
+func verifySQLiteTransactions(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ actor *adapter
+ observer *adapter
+ }{
+ {actor: goAdapter, observer: candidateAdapter},
+ {actor: candidateAdapter, observer: goAdapter},
+ } {
+ pair.actor.call(t, "reset", map[string]any{}, nil)
+ handle := "sqlite-commit-" + pair.actor.name
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var inserted, inTransaction normalizedJob
+ pair.actor.call(t, "tx_insert", map[string]any{
+ "handle": handle,
+ "job": map[string]any{
+ "message": "SQLite transaction commit",
+ "opts": map[string]any{"tags": []string{"sqlite_transaction"}},
+ },
+ }, &inserted)
+ pair.actor.call(t, "tx_get", map[string]any{
+ "handle": handle, "id": inserted.ID,
+ }, &inTransaction)
+ require.Equal(t, inserted, inTransaction)
+ requireJobNotFound(t, pair.observer, inserted.ID)
+ pair.actor.call(t, "tx_update", map[string]any{
+ "handle": handle, "id": inserted.ID, "output": map[string]any{"committed": true},
+ }, &inTransaction)
+ pair.actor.call(t, "tx_cancel", map[string]any{
+ "handle": handle, "id": inserted.ID,
+ }, &inTransaction)
+ require.Equal(t, "cancelled", inTransaction.State)
+ pair.actor.call(t, "tx_retry", map[string]any{
+ "handle": handle, "id": inserted.ID,
+ }, &inTransaction)
+ require.Equal(t, "available", inTransaction.State)
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.actor.call(t, "tx_list", map[string]any{
+ "handle": handle, "ids": []int64{inserted.ID},
+ }, &listed)
+ require.Equal(t, []normalizedJob{inTransaction}, listed.Jobs)
+ pair.actor.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ var observed normalizedJob
+ pair.observer.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.Equal(t, inTransaction, observed)
+
+ handle = "sqlite-rollback-" + pair.actor.name
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ pair.actor.call(t, "tx_insert", map[string]any{
+ "handle": handle, "job": map[string]any{"message": "SQLite transaction rollback"},
+ }, &inserted)
+ pair.actor.call(t, "tx_rollback", map[string]any{"handle": handle}, nil)
+ requireJobNotFound(t, pair.observer, inserted.ID)
+
+ handle = "sqlite-batch-error-" + pair.actor.name
+ tag := strings.ReplaceAll(handle, "-", "_")
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ pair.actor.requireCallError(t, "tx_insert_many", map[string]any{
+ "handle": handle,
+ "jobs": []map[string]any{
+ {"message": "must not partially commit", "opts": map[string]any{"tags": []string{tag}}},
+ {"message": "invalid", "opts": map[string]any{"priority": 99}},
+ },
+ }, "rejected")
+ pair.actor.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ pair.observer.call(t, "list", map[string]any{"tags_all": []string{tag}}, &listed)
+ require.Empty(t, listed.Jobs)
+ }
+}
+
+// rawNotification is one SQLite outbox row as `raw_notifications` returns it.
+type rawNotification struct {
+ ID int64 `json:"id"`
+ Payload string `json:"payload"`
+ PayloadType string `json:"payload_type"`
+ Topic string `json:"topic"`
+}
+
+// rawNotificationsAfter returns the outbox rows after afterID, read by
+// observer.
+func rawNotificationsAfter(t *testing.T, observer *adapter, afterID int64) []rawNotification {
+ t.Helper()
+
+ var result struct {
+ Notifications []rawNotification `json:"notifications"`
+ }
+ observer.call(t, "raw_notifications", map[string]any{"after_id": afterID}, &result)
+ return result.Notifications
+}
+
+// verifySQLiteCancelNotifications has each engine cancel jobs and checks the
+// control notification it writes to the SQLite outbox against Go's: the
+// topic, the payload's storage type, and the payload as JSON. A cancellation
+// publishes only when its transaction commits, both engines see the other's
+// rows, and cancelling a finalized job publishes nothing. The insert
+// notifications written along the way must match too.
+func verifySQLiteCancelNotifications(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ lastID := func() int64 {
+ notifications := rawNotificationsAfter(t, goAdapter, 0)
+ if len(notifications) == 0 {
+ return 0
+ }
+ return notifications[len(notifications)-1].ID
+ }
+ requireCancelNotification := func(actor *adapter, after int64, job normalizedJob) int64 {
+ t.Helper()
+
+ var id int64
+ for _, observer := range []*adapter{goAdapter, candidateAdapter} {
+ notifications := rawNotificationsAfter(t, observer, after)
+ require.Len(t, notifications, 1, "%s cancellation read by %s", actor.name, observer.name)
+ require.Equal(t, "river_control", notifications[0].Topic)
+ require.Equal(t, "text", notifications[0].PayloadType)
+ require.JSONEq(t, fmt.Sprintf(`{"action":"cancel","job_id":%d,"queue":%q}`, job.ID, job.Queue),
+ notifications[0].Payload, "%s cancellation read by %s", actor.name, observer.name)
+ id = notifications[0].ID
+ }
+ return id
+ }
+
+ insertNotifications := map[string]rawNotification{}
+ insertedJobIDs := map[string]int64{}
+ for _, pair := range []struct {
+ actor, observer *adapter
+ }{
+ {actor: goAdapter, observer: candidateAdapter},
+ {actor: candidateAdapter, observer: goAdapter},
+ } {
+ pair.actor.call(t, "reset", map[string]any{}, nil)
+ var job normalizedJob
+ pair.actor.call(t, "insert", map[string]any{"message": "cancel notifications"}, &job)
+
+ after := lastID()
+ handle := "sqlite-cancel-notification-rollback-" + pair.actor.name
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ pair.actor.call(t, "tx_cancel", map[string]any{"handle": handle, "id": job.ID}, nil)
+ require.Empty(t, rawNotificationsAfter(t, pair.observer, after), "uncommitted cancellation published")
+ pair.actor.call(t, "tx_rollback", map[string]any{"handle": handle}, nil)
+ require.Empty(t, rawNotificationsAfter(t, pair.observer, after), "rolled-back cancellation published")
+
+ handle = "sqlite-cancel-notification-commit-" + pair.actor.name
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ pair.actor.call(t, "tx_cancel", map[string]any{"handle": handle, "id": job.ID}, nil)
+ pair.actor.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ after = requireCancelNotification(pair.actor, after, job)
+
+ // The job is finalized now, so cancelling it again changes nothing
+ // and publishes nothing.
+ pair.actor.call(t, "cancel", map[string]any{"id": job.ID}, nil)
+ require.Empty(t, rawNotificationsAfter(t, pair.observer, after), "cancelling a finalized job published")
+
+ pair.actor.call(t, "insert", map[string]any{"message": "cancel notifications"}, &job)
+ inserted := rawNotificationsAfter(t, pair.observer, after)
+ require.Len(t, inserted, 1, "%s insertion", pair.actor.name)
+ insertNotifications[pair.actor.name] = inserted[0]
+ insertedJobIDs[pair.actor.name] = job.ID
+ pair.actor.call(t, "cancel", map[string]any{"id": job.ID}, nil)
+ requireCancelNotification(pair.actor, inserted[0].ID, job)
+ }
+
+ // Insert notifications aren't the subject here, but the same outbox read
+ // compares them too.
+ require.Equal(t,
+ semanticNotifications(t, []rawNotification{insertNotifications[goAdapter.name]}, insertedJobIDs[goAdapter.name]),
+ semanticNotifications(t, []rawNotification{insertNotifications[candidateAdapter.name]}, insertedJobIDs[candidateAdapter.name]),
+ "insert notifications differ")
+}
diff --git a/conformance/harness/storage_scenarios_test.go b/conformance/harness/storage_scenarios_test.go
new file mode 100644
index 000000000..77655a751
--- /dev/null
+++ b/conformance/harness/storage_scenarios_test.go
@@ -0,0 +1,1346 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// verifyCustomSchema migrates a custom schema with one implementation, then
+// has the other insert and work a job in it. It also checks that the worker
+// accepts the longest portable schema name and rejects invalid names.
+func verifyCustomSchema(t *testing.T, schema string, migrator, worker *adapter) {
+ t.Helper()
+
+ migrator.call(t, "migrate", map[string]any{"schema": schema}, nil)
+ migrator.call(t, "reset", map[string]any{"schema": schema}, nil)
+
+ var inserted, observed, worked normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "message": "custom schema", "schema": schema,
+ }, &inserted)
+ migrator.call(t, "get", map[string]any{
+ "id": inserted.ID, "schema": schema,
+ }, &observed)
+ require.Equal(t, inserted, observed)
+ worker.call(t, "work", map[string]any{
+ "id": inserted.ID, "schema": schema,
+ }, &worked)
+ require.Equal(t, "completed", worked.State)
+ migrator.call(t, "get", map[string]any{
+ "id": inserted.ID, "schema": schema,
+ }, &observed)
+ require.Equal(t, worked, observed)
+
+ boundarySchema := strings.Repeat("s", 46)
+ migrator.call(t, "migrate", map[string]any{"schema": boundarySchema}, nil)
+ var boundaryJob normalizedJob
+ worker.call(t, "insert", map[string]any{
+ "message": "maximum portable schema", "schema": boundarySchema,
+ }, &boundaryJob)
+ require.Positive(t, boundaryJob.ID)
+ worker.requireCallError(t, "insert", map[string]any{
+ "message": "schema too long", "schema": strings.Repeat("s", 47),
+ }, "rejected")
+ // Any other schema name works in both implementations as long as it's
+ // quoted, like Go's `SafeIdentifier`, so only the length is portable to
+ // reject here.
+}
+
+// verifyConcurrentUniqueConflicts proves that a unique insert blocks on
+// another implementation's uncommitted conflicting insert and then returns
+// the committed winner. The loser's backend is observed waiting on a lock in
+// PostgreSQL before the winner commits, so a slow response cannot pass as a
+// blocked one.
+func verifyConcurrentUniqueConflicts(t *testing.T, observer *postgresObserver, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ allStates := []string{
+ "available",
+ "cancelled",
+ "completed",
+ "discarded",
+ "pending",
+ "retryable",
+ "running",
+ "scheduled",
+ }
+ fixedScheduledAt := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano)
+ testCases := []struct {
+ name string
+ opts map[string]any
+ }{
+ {
+ name: "by_args",
+ opts: map[string]any{"unique": map[string]any{"by_args": true}},
+ },
+ {
+ name: "by_period",
+ opts: map[string]any{
+ "scheduled_at": fixedScheduledAt,
+ "unique": map[string]any{"by_period_ms": 60_000},
+ },
+ },
+ {
+ name: "by_queue",
+ opts: map[string]any{
+ "queue": "unique_queue",
+ "unique": map[string]any{"by_queue": true},
+ },
+ },
+ {
+ name: "by_state",
+ opts: map[string]any{"unique": map[string]any{"by_state": allStates}},
+ },
+ }
+
+ for _, testCase := range testCases {
+ for _, direction := range []struct {
+ loser *adapter
+ winner *adapter
+ }{
+ {loser: candidateAdapter, winner: goAdapter},
+ {loser: goAdapter, winner: candidateAdapter},
+ } {
+ loser, winner := direction.loser, direction.winner
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ winnerHandle := fmt.Sprintf("%s-%s-winner", testCase.name, winner.name)
+ loserHandle := fmt.Sprintf("%s-%s-loser", testCase.name, loser.name)
+ winner.call(t, "tx_begin", map[string]any{"handle": winnerHandle}, nil)
+ loser.call(t, "tx_begin", map[string]any{"handle": loserHandle}, nil)
+
+ params := map[string]any{
+ "handle": winnerHandle,
+ "job": map[string]any{
+ "message": "concurrent unique " + testCase.name,
+ "opts": testCase.opts,
+ },
+ }
+ var winnerJob normalizedJob
+ winner.call(t, "tx_insert", params, &winnerJob)
+
+ loserParams := map[string]any{"handle": loserHandle, "job": params["job"]}
+ type loserResult struct {
+ err error
+ job normalizedJob
+ }
+ resultCh := make(chan loserResult, 1)
+ go func() {
+ var job normalizedJob
+ err := loser.callWithoutTest("tx_insert", loserParams, &job)
+ resultCh <- loserResult{err: err, job: job}
+ }()
+
+ observer.waitForLockWait(t, loser.applicationName)
+ select {
+ case result := <-resultCh:
+ t.Fatalf("%s unique insert returned while %s's conflict was uncommitted (%s): %+v", loser.name, winner.name, testCase.name, result)
+ default:
+ }
+ winner.call(t, "tx_commit", map[string]any{"handle": winnerHandle}, nil)
+
+ var result loserResult
+ select {
+ case result = <-resultCh:
+ case <-time.After(5 * time.Second):
+ t.Fatalf("%s unique insert remained blocked after %s committed (%s)", loser.name, winner.name, testCase.name)
+ }
+ require.NoError(t, result.err)
+ loser.call(t, "tx_commit", map[string]any{"handle": loserHandle}, nil)
+ require.Equal(t, winnerJob, result.job)
+
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ goAdapter.call(t, "list", map[string]any{}, &listed)
+ require.Equal(t, []normalizedJob{winnerJob}, listed.Jobs)
+ }
+ }
+
+ // Sequential inserts with the same unique arguments from both
+ // implementations resolve to one row.
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+ uniqueParams := map[string]any{
+ "message": "cross-language unique",
+ "opts": map[string]any{"unique": map[string]any{"by_args": true}},
+ }
+ var uniqueGo, uniqueCandidate normalizedJob
+ goAdapter.call(t, "insert", uniqueParams, &uniqueGo)
+ candidateAdapter.call(t, "insert", uniqueParams, &uniqueCandidate)
+ require.Equal(t, uniqueGo, uniqueCandidate)
+}
+
+// verifyConcurrentCancelRetryRace races a cancel, then a retry, between the
+// implementations. The winner holds the job's row lock in an open
+// transaction until the loser's request is observed waiting on it, so the
+// loser's statement starts before the winner commits. Its update then
+// matches nothing, and it must return the winner's committed row rather than
+// the row as its statement first saw it.
+func verifyConcurrentCancelRetryRace(t *testing.T, observer *postgresObserver, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, direction := range []struct {
+ loser *adapter
+ winner *adapter
+ }{
+ {loser: candidateAdapter, winner: goAdapter},
+ {loser: goAdapter, winner: candidateAdapter},
+ } {
+ loser, winner := direction.loser, direction.winner
+ goAdapter.call(t, "reset", map[string]any{}, nil)
+
+ race := func(t *testing.T, operation string, id int64) {
+ t.Helper()
+
+ handle := fmt.Sprintf("%s-%s-winner", operation, winner.name)
+ winner.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var winnerJob normalizedJob
+ winner.call(t, "tx_"+operation, map[string]any{"handle": handle, "id": id}, &winnerJob)
+
+ type loserResult struct {
+ err error
+ job normalizedJob
+ }
+ resultCh := make(chan loserResult, 1)
+ go func() {
+ var job normalizedJob
+ err := loser.callWithoutTest(operation, map[string]any{"id": id}, &job)
+ resultCh <- loserResult{err: err, job: job}
+ }()
+
+ observer.waitForLockWait(t, loser.applicationName)
+ select {
+ case result := <-resultCh:
+ t.Fatalf("%s %s returned while %s's was uncommitted: %+v", loser.name, operation, winner.name, result)
+ default:
+ }
+ winner.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+
+ var result loserResult
+ select {
+ case result = <-resultCh:
+ case <-time.After(5 * time.Second):
+ t.Fatalf("%s %s remained blocked after %s committed", loser.name, operation, winner.name)
+ }
+ require.NoError(t, result.err)
+ require.Equal(t, winnerJob, result.job,
+ "%s lost a %s race to %s and must return the committed row", loser.name, operation, winner.name)
+
+ var committed normalizedJob
+ goAdapter.call(t, "get", map[string]any{"id": id}, &committed)
+ require.Equal(t, winnerJob, committed)
+ }
+
+ var job normalizedJob
+ goAdapter.call(t, "insert", map[string]any{
+ "message": "cancel and retry race",
+ "opts": map[string]any{"scheduled_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano)},
+ }, &job)
+ race(t, "cancel", job.ID)
+ race(t, "retry", job.ID)
+ }
+}
+
+// verifyBatchInsertion checks typed batch insertion results, ordering,
+// duplicate reporting, repeated unique keys, invalid unique options, and
+// atomic rejection outside a transaction.
+func verifyBatchInsertion(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ actor *adapter
+ observer *adapter
+ }{
+ {actor: goAdapter, observer: candidateAdapter},
+ {actor: candidateAdapter, observer: goAdapter},
+ } {
+ pair.actor.call(t, "reset", map[string]any{}, nil)
+ pair.actor.requireCallError(t, "insert_many", map[string]any{
+ "jobs": []map[string]any{},
+ }, "rejected")
+ uniqueParams := map[string]any{
+ "message": "typed batch duplicate " + pair.actor.name,
+ "opts": map[string]any{"unique": map[string]any{"by_args": true}},
+ }
+ var existing normalizedJob
+ pair.actor.call(t, "insert", uniqueParams, &existing)
+
+ jobs := []map[string]any{
+ {
+ "message": "typed batch first " + pair.actor.name,
+ "opts": map[string]any{
+ "metadata": map[string]any{"batch_index": 0},
+ "priority": 2,
+ "tags": []string{"typed_batch_" + pair.actor.name},
+ },
+ },
+ uniqueParams,
+ {
+ "message": "typed batch third " + pair.actor.name,
+ "opts": map[string]any{
+ "pending": true,
+ "tags": []string{"typed_batch_" + pair.actor.name},
+ },
+ },
+ }
+ var inserted struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ pair.actor.call(t, "insert_many", map[string]any{"jobs": jobs}, &inserted)
+ require.Len(t, inserted.Results, 3)
+ for _, result := range inserted.Results {
+ require.NotNil(t, result.Job.Errors)
+ require.Empty(t, result.Job.Errors)
+ }
+ require.False(t, inserted.Results[0].UniqueSkippedAsDuplicate)
+ require.EqualValues(t, 0, inserted.Results[0].Job.Metadata["batch_index"])
+ require.Equal(t, 2, inserted.Results[0].Job.Priority)
+ require.Equal(t, existing, inserted.Results[1].Job)
+ require.True(t, inserted.Results[1].UniqueSkippedAsDuplicate)
+ require.False(t, inserted.Results[2].UniqueSkippedAsDuplicate)
+ require.Equal(t, "pending", inserted.Results[2].Job.State)
+
+ var observed normalizedJob
+ for _, result := range inserted.Results {
+ observed = normalizedJob{}
+ pair.observer.call(t, "get", map[string]any{"id": result.Job.ID}, &observed)
+ require.Equal(t, result.Job, observed)
+ }
+
+ invalidTag := "invalid_batch_" + pair.actor.name
+ pair.actor.requireCallError(t, "insert_many", map[string]any{"jobs": []map[string]any{
+ {"message": "must roll back", "opts": map[string]any{"tags": []string{invalidTag}}},
+ {"message": "invalid priority", "opts": map[string]any{"priority": 99}},
+ }}, "rejected")
+ var invalidRows struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.observer.call(t, "list", map[string]any{"tags_all": []string{invalidTag}}, &invalidRows)
+ require.Empty(t, invalidRows.Jobs)
+
+ // A unique key may appear only once in a batch among jobs whose state
+ // it covers. PostgreSQL reports a database error and SQLite a
+ // rejection, so only the failure and its atomicity are compared.
+ repeatedTag := "repeated_key_batch_" + pair.actor.name
+ repeated := map[string]any{
+ "message": "repeated unique key " + pair.actor.name,
+ "opts": map[string]any{
+ "tags": []string{repeatedTag},
+ "unique": map[string]any{"by_args": true},
+ },
+ }
+ response := pair.actor.callResponse(t, "insert_many", map[string]any{
+ "jobs": []map[string]any{repeated, repeated},
+ })
+ require.NotNil(t, response.Error, "%s adapter inserted a batch repeating a unique key", pair.actor.name)
+ var repeatedRows struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.observer.call(t, "list", map[string]any{"tags_all": []string{repeatedTag}}, &repeatedRows)
+ require.Empty(t, repeatedRows.Jobs)
+
+ // Excluding the kind needs arguments, queue, or period in the key.
+ pair.actor.requireCallError(t, "insert", map[string]any{
+ "message": "unique without kind " + pair.actor.name,
+ "opts": map[string]any{"unique": map[string]any{"exclude_kind": true}},
+ }, "rejected")
+ }
+}
+
+func verifyLargeBatchInsertion(t *testing.T, adapters ...*adapter) {
+ t.Helper()
+
+ const batchSize = 6_000
+ for _, actor := range adapters {
+ actor.call(t, "reset", map[string]any{}, nil)
+ jobs := make([]map[string]any, batchSize)
+ for index := range jobs {
+ jobs[index] = map[string]any{
+ "message": fmt.Sprintf("large ordinary batch %s %d", actor.name, index),
+ "opts": map[string]any{
+ "metadata": map[string]any{"batch_index": index},
+ },
+ }
+ }
+ var inserted struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ actor.call(t, "insert_many", map[string]any{"jobs": jobs}, &inserted)
+ require.Len(t, inserted.Results, batchSize)
+ for index, result := range inserted.Results {
+ require.EqualValues(t, index, result.Job.Metadata["batch_index"], "result %d is out of input order", index)
+ }
+ }
+}
+
+// verifyTransactionalBatchInsertion checks that typed batches inserted in a
+// caller-managed transaction are invisible to the other implementation until
+// commit and never visible after rollback.
+func verifyTransactionalBatchInsertion(t *testing.T, actor, observer *adapter) {
+ t.Helper()
+
+ actor.call(t, "reset", map[string]any{}, nil)
+ emptyHandle := "batch-empty-" + actor.name
+ actor.call(t, "tx_begin", map[string]any{"handle": emptyHandle}, nil)
+ actor.requireCallError(t, "tx_insert_many", map[string]any{
+ "handle": emptyHandle,
+ "jobs": []map[string]any{},
+ }, "rejected")
+ actor.call(t, "tx_commit", map[string]any{"handle": emptyHandle}, nil)
+ for _, commit := range []bool{false, true} {
+ actor.call(t, "reset", map[string]any{}, nil)
+ outcome := "rollback"
+ if commit {
+ outcome = "commit"
+ }
+ handle := fmt.Sprintf("batch-%s-typed-%s", actor.name, outcome)
+ tag := strings.ReplaceAll(handle, "-", "_")
+ actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ jobs := []map[string]any{
+ {
+ "message": handle + " first",
+ "opts": map[string]any{
+ "metadata": map[string]any{"batch_index": 0},
+ "priority": 2,
+ "tags": []string{tag},
+ },
+ },
+ {
+ "message": handle + " second",
+ "opts": map[string]any{
+ "metadata": map[string]any{"batch_index": 1},
+ "priority": 3,
+ "tags": []string{tag},
+ },
+ },
+ }
+ var result struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ actor.call(t, "tx_insert_many", map[string]any{"handle": handle, "jobs": jobs}, &result)
+ require.Len(t, result.Results, 2)
+ require.EqualValues(t, 0, result.Results[0].Job.Metadata["batch_index"])
+ require.EqualValues(t, 1, result.Results[1].Job.Metadata["batch_index"])
+
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ observer.call(t, "list", map[string]any{"tags_all": []string{tag}}, &listed)
+ require.Empty(t, listed.Jobs)
+ if commit {
+ actor.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ observer.call(t, "list", map[string]any{
+ "direction": "asc", "order_by": "id", "tags_all": []string{tag},
+ }, &listed)
+ require.Len(t, listed.Jobs, 2)
+ require.Equal(t, []int{2, 3}, []int{listed.Jobs[0].Priority, listed.Jobs[1].Priority})
+ } else {
+ actor.call(t, "tx_rollback", map[string]any{"handle": handle}, nil)
+ observer.call(t, "list", map[string]any{"tags_all": []string{tag}}, &listed)
+ require.Empty(t, listed.Jobs)
+ }
+ }
+}
+
+// verifyDifferentialJobCRUD writes with one implementation and reads,
+// updates, cancels, retries, and deletes with alternating implementations.
+func verifyDifferentialJobCRUD(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ reader *adapter
+ writer *adapter
+ }{
+ {reader: candidateAdapter, writer: goAdapter},
+ {reader: goAdapter, writer: candidateAdapter},
+ } {
+ writerTag := "writer_" + pair.writer.name
+ pair.writer.call(t, "reset", map[string]any{}, nil)
+ var inserted, observed normalizedJob
+ pair.writer.call(t, "insert", map[string]any{
+ "message": "differential CRUD",
+ "opts": map[string]any{
+ "metadata": map[string]any{"writer": pair.writer.name},
+ "priority": 3,
+ "tags": []string{"all_jobs", writerTag},
+ },
+ }, &inserted)
+ pair.reader.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.Equal(t, inserted, observed)
+
+ listParams := map[string]any{
+ "ids": []int64{inserted.ID}, "tags_all": []string{"all_jobs", writerTag},
+ }
+ var readerList, writerList struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.writer.call(t, "list", listParams, &writerList)
+ pair.reader.call(t, "list", listParams, &readerList)
+ require.Equal(t, writerList, readerList)
+ require.Equal(t, []normalizedJob{inserted}, writerList.Jobs)
+
+ var updated normalizedJob
+ pair.reader.call(t, "update", map[string]any{
+ "id": inserted.ID, "output": map[string]any{"updated_by": pair.reader.name},
+ }, &updated)
+ require.Equal(t, map[string]any{"updated_by": pair.reader.name}, updated.Metadata["output"])
+ pair.writer.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.Equal(t, updated, observed)
+
+ var cancelled normalizedJob
+ pair.writer.call(t, "cancel", map[string]any{"id": inserted.ID}, &cancelled)
+ pair.reader.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.Equal(t, cancelled, observed)
+ require.Equal(t, "cancelled", cancelled.State)
+
+ var retried normalizedJob
+ pair.reader.call(t, "retry", map[string]any{"id": inserted.ID}, &retried)
+ pair.writer.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.Equal(t, retried, observed)
+ require.Equal(t, "available", retried.State)
+ require.Nil(t, retried.FinalizedAt)
+
+ var deleted normalizedJob
+ pair.writer.call(t, "delete", map[string]any{"id": inserted.ID}, &deleted)
+ require.Equal(t, retried, deleted)
+ requireJobNotFound(t, pair.reader, inserted.ID)
+ }
+}
+
+// verifyBulkDeleteSafety deletes an explicit ID set across implementations
+// and requires both implementations to refuse an unfiltered bulk delete.
+func verifyBulkDeleteSafety(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ reader *adapter
+ writer *adapter
+ }{
+ {reader: candidateAdapter, writer: goAdapter},
+ {reader: goAdapter, writer: candidateAdapter},
+ } {
+ pair.writer.call(t, "reset", map[string]any{}, nil)
+ bulkIDs := make([]int64, 0, 2)
+ for index := range 2 {
+ var bulk normalizedJob
+ pair.writer.call(t, "insert", map[string]any{
+ "message": fmt.Sprintf("bulk delete %d", index),
+ }, &bulk)
+ bulkIDs = append(bulkIDs, bulk.ID)
+ }
+ var survivor normalizedJob
+ pair.writer.call(t, "insert", map[string]any{"message": "bulk delete survivor"}, &survivor)
+ var bulkDeleted struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.reader.call(t, "delete_many", map[string]any{"ids": bulkIDs}, &bulkDeleted)
+ require.ElementsMatch(t, bulkIDs, jobIDs(bulkDeleted.Jobs))
+ for _, id := range bulkIDs {
+ requireJobNotFound(t, pair.writer, id)
+ }
+ for _, current := range []*adapter{pair.writer, pair.reader} {
+ current.requireCallError(t, "delete_many", map[string]any{}, "rejected")
+ }
+ var observed normalizedJob
+ pair.writer.call(t, "get", map[string]any{"id": survivor.ID}, &observed)
+ require.Equal(t, survivor, observed)
+ }
+}
+
+// verifyJobCleanerQueueFilters runs batches of the job cleaner's deletion with
+// each implementation over jobs the other finalized. Retained jobs in queues
+// `kept1`/`kept2` are inserted before jobs in `deleted1`/`deleted2`, so they
+// hold the lowest IDs and outnumber a batch of 2. A query that limits
+// candidates before applying queue filters would select only retained jobs,
+// delete nothing, and stop the cleaner from making progress.
+func verifyJobCleanerQueueFilters(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ queues := []string{"kept1", "kept2", "kept1", "kept2", "kept1", "kept2", "deleted1", "deleted2", "deleted1", "deleted2", "deleted1"}
+ for _, pair := range []struct {
+ cleaner *adapter
+ writer *adapter
+ }{
+ {cleaner: candidateAdapter, writer: goAdapter},
+ {cleaner: goAdapter, writer: candidateAdapter},
+ } {
+ for _, testCase := range []struct {
+ name string
+ queuesExcluded []string
+ queuesIncluded []string // nil omits the inclusion filter
+ wantBatches []int // jobs deleted by each successive batch
+ wantDeletedQueues []string // queues whose jobs are eligible
+ }{
+ // `kept1` appears in both lists; exclusion takes precedence.
+ {name: "both", queuesExcluded: []string{"kept1", "kept2"}, queuesIncluded: []string{"deleted1", "deleted2", "kept1"}, wantBatches: []int{2, 2, 1, 0}, wantDeletedQueues: []string{"deleted1", "deleted2"}},
+ // An empty exclusion list excludes nothing.
+ {name: "empty excluded", queuesExcluded: []string{}, wantBatches: []int{2, 2, 2, 2, 2, 1, 0}, wantDeletedQueues: []string{"deleted1", "deleted2", "kept1", "kept2"}},
+ // An empty inclusion list matches no queues, unlike an absent one.
+ {name: "empty included", queuesIncluded: []string{}, wantBatches: []int{0}},
+ {name: "excluded", queuesExcluded: []string{"kept1", "kept2"}, wantBatches: []int{2, 2, 1, 0}, wantDeletedQueues: []string{"deleted1", "deleted2"}},
+ {name: "included", queuesIncluded: []string{"deleted1", "deleted2"}, wantBatches: []int{2, 2, 1, 0}, wantDeletedQueues: []string{"deleted1", "deleted2"}},
+ {name: "missing included", queuesIncluded: []string{"missing"}, wantBatches: []int{0}},
+ {name: "no filters", wantBatches: []int{2, 2, 2, 2, 2, 1, 0}, wantDeletedQueues: []string{"deleted1", "deleted2", "kept1", "kept2"}},
+ } {
+ pair.writer.call(t, "reset", map[string]any{}, nil)
+ allIDs := make([]int64, 0, len(queues))
+ var eligibleIDs []int64
+ for _, queue := range queues {
+ var job normalizedJob
+ pair.writer.call(t, "insert", map[string]any{
+ "message": "job cleaner queue filters", "opts": map[string]any{"queue": queue},
+ }, &job)
+ pair.writer.call(t, "cancel", map[string]any{"id": job.ID}, nil)
+ allIDs = append(allIDs, job.ID)
+ if slices.Contains(testCase.wantDeletedQueues, queue) {
+ eligibleIDs = append(eligibleIDs, job.ID)
+ }
+ }
+
+ params := map[string]any{
+ // Every job was finalized just now, so a future horizon makes
+ // each one old enough to delete.
+ "before": time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano),
+ "limit": 2,
+ }
+ if testCase.queuesExcluded != nil {
+ params["queues_excluded"] = testCase.queuesExcluded
+ }
+ if testCase.queuesIncluded != nil {
+ params["queues_included"] = testCase.queuesIncluded
+ }
+ var deletedTotal int
+ for batch, wantDeleted := range testCase.wantBatches {
+ var result struct {
+ Deleted int `json:"deleted"`
+ }
+ pair.cleaner.call(t, "delete_finalized", params, &result)
+ require.Equal(t, wantDeleted, result.Deleted, "%s batch %d over %s's jobs (%s)", pair.cleaner.name, batch, pair.writer.name, testCase.name)
+ deletedTotal += result.Deleted
+
+ // Batches delete the oldest eligible jobs first, so exactly
+ // the first deletedTotal eligible jobs are gone.
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.writer.call(t, "list", map[string]any{"ids": allIDs, "limit": len(allIDs), "order_by": "id"}, &listed)
+ require.Equal(t,
+ slices.DeleteFunc(slices.Clone(allIDs), func(id int64) bool { return slices.Contains(eligibleIDs[:deletedTotal], id) }),
+ jobIDs(listed.Jobs),
+ "%s batch %d over %s's jobs (%s)", pair.cleaner.name, batch, pair.writer.name, testCase.name,
+ )
+ }
+ require.Len(t, eligibleIDs, deletedTotal, "%s over %s's jobs (%s)", pair.cleaner.name, pair.writer.name, testCase.name)
+ }
+ }
+}
+
+// verifyDifferentialListCursors pages through a filtered list with cursors
+// emitted by one implementation and consumed by the other.
+func verifyDifferentialListCursors(t *testing.T, goAdapter, candidateAdapter *adapter, filterMetadata bool) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ reader *adapter
+ writer *adapter
+ }{
+ {reader: candidateAdapter, writer: goAdapter},
+ {reader: goAdapter, writer: candidateAdapter},
+ } {
+ pair.writer.call(t, "reset", map[string]any{}, nil)
+ paginationIDs := make([]int64, 0, 3)
+ for index := range 3 {
+ var paginationJob normalizedJob
+ pair.writer.call(t, "insert", map[string]any{
+ "message": fmt.Sprintf("pagination %d", index),
+ "opts": map[string]any{
+ "metadata": map[string]any{"pagination_writer": pair.writer.name},
+ "priority": index + 1,
+ "scheduled_at": fmt.Sprintf("2099-01-01T00:00:0%dZ", index+1),
+ "tags": []string{"pagination_jobs"},
+ },
+ }, &paginationJob)
+ paginationIDs = append(paginationIDs, paginationJob.ID)
+ }
+ // A job outside every filter must never appear.
+ var excluded normalizedJob
+ pair.writer.call(t, "insert", map[string]any{"message": "pagination excluded"}, &excluded)
+ type jobPage struct {
+ Cursor *string `json:"cursor"`
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pageParams := func(after *string) map[string]any {
+ params := map[string]any{
+ "direction": "desc",
+ "limit": 2,
+ "order_by": "scheduled_at",
+ "priorities": []int{1, 2, 3},
+ "states": []string{"scheduled"},
+ "tags_all": []string{"pagination_jobs"},
+ }
+ if filterMetadata {
+ params["metadata"] = map[string]any{"pagination_writer": pair.writer.name}
+ }
+ if after != nil {
+ params["after"] = *after
+ }
+ return params
+ }
+ var readerPage, writerPage jobPage
+ pair.reader.call(t, "list", pageParams(nil), &readerPage)
+ pair.writer.call(t, "list", pageParams(nil), &writerPage)
+ require.Equal(t, writerPage, readerPage)
+ require.Equal(t, []int64{paginationIDs[2], paginationIDs[1]}, jobIDs(writerPage.Jobs))
+ require.NotNil(t, writerPage.Cursor)
+
+ var readerSecondPage, writerSecondPage jobPage
+ pair.reader.call(t, "list", pageParams(writerPage.Cursor), &readerSecondPage)
+ pair.writer.call(t, "list", pageParams(readerPage.Cursor), &writerSecondPage)
+ require.Equal(t, writerSecondPage, readerSecondPage)
+ require.Equal(t, []int64{paginationIDs[0]}, jobIDs(writerSecondPage.Jobs))
+ }
+}
+
+// jobListCursorKind is a job kind that Go's `encoding/json` escapes (`<`,
+// `>`, and `&` become `\u003c`, `\u003e`, and `\u0026`) and whose cursor
+// text always contains `-`, wherever the kind falls in the Base64 groups:
+// one of three consecutive `~` bytes ends a group, and its low six bits
+// encode as `-`.
+const jobListCursorKind = "conformance_cursor<>&~~~"
+
+// verifyJobListCursorInterchange checks that job-list cursors are
+// interchangeable for each sort field: both engines emit byte-identical
+// cursor text for the same page, and each resumes from the other's cursor
+// to the same next page, in both directions. Time ordering over mixed states
+// uses the first listed state's field for every job and its cursor, with
+// nulls last ascending and first descending.
+func verifyJobListCursorInterchange(t *testing.T, first, second *adapter) {
+ t.Helper()
+
+ type jobPage struct {
+ Cursor *string `json:"cursor"`
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ type listCase struct {
+ kind string
+ orderBy string
+ // order lists the kind's jobs by insertion index in ascending list
+ // order, or nil for insertion order.
+ order []int
+ states []string
+ }
+ const echoKind = "conformance_echo"
+ for _, pair := range []struct {
+ reader *adapter
+ writer *adapter
+ }{
+ {reader: second, writer: first},
+ {reader: first, writer: second},
+ } {
+ pair.writer.call(t, "reset", map[string]any{}, nil)
+ idsByKind := make(map[string][]int64, 2)
+ for index := range 3 {
+ // Scheduled times have fractional seconds that Go encodes with
+ // trailing zeros trimmed, like `.12`.
+ var scheduled, raw normalizedJob
+ pair.writer.call(t, "insert", map[string]any{
+ "message": fmt.Sprintf("cursor %d", index),
+ "opts": map[string]any{
+ "scheduled_at": fmt.Sprintf("2099-01-01T00:00:0%d.%d2Z", index+1, index+1),
+ },
+ }, &scheduled)
+ idsByKind[echoKind] = append(idsByKind[echoKind], scheduled.ID)
+ // A raw row's `scheduled_at` comes from a column default that
+ // SQLite stores in a non-canonical format, so this kind is
+ // ordered only by ID until it is cancelled.
+ pair.writer.call(t, "raw_insert_no_notify", map[string]any{
+ "kind": jobListCursorKind, "message": fmt.Sprintf("cursor %d", index),
+ }, &raw)
+ idsByKind[jobListCursorKind] = append(idsByKind[jobListCursorKind], raw.ID)
+ }
+
+ verifyCases := func(cases []listCase) {
+ for _, current := range cases {
+ for _, direction := range []string{"asc", "desc"} {
+ description := fmt.Sprintf("%s -> %s: kind %s ordered by %s %s",
+ pair.writer.name, pair.reader.name, current.kind, current.orderBy, direction)
+ expected := slices.Clone(idsByKind[current.kind])
+ if current.order != nil {
+ expected = expected[:0]
+ for _, index := range current.order {
+ expected = append(expected, idsByKind[current.kind][index])
+ }
+ }
+ if direction == "desc" {
+ slices.Reverse(expected)
+ }
+ params := func(after *string) map[string]any {
+ params := map[string]any{
+ "direction": direction,
+ "kinds": []string{current.kind},
+ "limit": 2,
+ "order_by": current.orderBy,
+ }
+ if current.states != nil {
+ params["states"] = current.states
+ }
+ if after != nil {
+ params["after"] = *after
+ }
+ return params
+ }
+
+ var readerPage, writerPage jobPage
+ pair.writer.call(t, "list", params(nil), &writerPage)
+ pair.reader.call(t, "list", params(nil), &readerPage)
+ require.Equal(t, expected[:2], jobIDs(writerPage.Jobs), description)
+ require.Equal(t, writerPage, readerPage, description)
+ require.NotNil(t, writerPage.Cursor, description)
+ cursor := *writerPage.Cursor
+ if current.kind == jobListCursorKind {
+ require.Contains(t, cursor, "-", description)
+ }
+
+ var resumed jobPage
+ pair.reader.call(t, "list", params(&cursor), &resumed)
+ require.Equal(t, expected[2:], jobIDs(resumed.Jobs), description)
+ }
+ }
+ }
+ verifyCases([]listCase{
+ {kind: echoKind, orderBy: "id"},
+ {kind: echoKind, orderBy: "scheduled_at", states: []string{"scheduled"}},
+ {kind: echoKind, orderBy: "time", states: []string{"scheduled"}},
+ {kind: jobListCursorKind, orderBy: "id"},
+ })
+ // Cancelling in ID order sets increasing `finalized_at` times.
+ for _, kind := range []string{echoKind, jobListCursorKind} {
+ for _, id := range idsByKind[kind] {
+ pair.writer.call(t, "cancel", map[string]any{"id": id}, nil)
+ }
+ }
+ verifyCases([]listCase{
+ {kind: echoKind, orderBy: "finalized_at", states: []string{"cancelled"}},
+ {kind: echoKind, orderBy: "time", states: []string{"cancelled"}},
+ {kind: jobListCursorKind, orderBy: "finalized_at", states: []string{"cancelled"}},
+ {kind: jobListCursorKind, orderBy: "time", states: []string{"cancelled"}},
+ })
+
+ // Retrying the middle job makes it available again, scheduled now and
+ // without a finalized time. Listed with cancelled jobs, every job is
+ // ordered by the first state's field, so a page can end on a job of
+ // the other state, and the retried job's null `finalized_at` sorts
+ // last ascending.
+ echoIDs := idsByKind[echoKind]
+ pair.writer.call(t, "retry", map[string]any{"id": echoIDs[1]}, nil)
+ verifyCases([]listCase{
+ {kind: echoKind, orderBy: "time", order: []int{0, 2, 1}, states: []string{"cancelled", "available"}},
+ {kind: echoKind, orderBy: "time", order: []int{1, 0, 2}, states: []string{"available", "cancelled"}},
+ })
+ // With the last job retried too, pages end on a null `finalized_at`.
+ pair.writer.call(t, "retry", map[string]any{"id": echoIDs[2]}, nil)
+ verifyCases([]listCase{
+ {kind: echoKind, orderBy: "time", order: []int{0, 1, 2}, states: []string{"cancelled", "available"}},
+ })
+ }
+}
+
+// verifyDifferentialQueueCRUD compares persisted queue rows and metadata
+// updates across implementations, including the `metadata_changed` control
+// notification an update sends, which River Go's producers hand to their
+// extension.
+func verifyDifferentialQueueCRUD(t *testing.T, observer *postgresObserver, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ controlChannel := observer.currentSchema(t) + ".river_control"
+
+ for _, pair := range []struct {
+ reader *adapter
+ writer *adapter
+ }{
+ {reader: candidateAdapter, writer: goAdapter},
+ {reader: goAdapter, writer: candidateAdapter},
+ } {
+ pair.writer.call(t, "reset", map[string]any{}, nil)
+ pair.writer.call(t, "start", map[string]any{
+ "client_id": pair.writer.name + "-queue-crud", "max_workers": 1,
+ }, nil)
+ pair.writer.call(t, "stop", map[string]any{}, nil)
+ var readerQueue, updatedQueue, writerQueue normalizedQueue
+ pair.writer.call(t, "queue_get", map[string]any{"name": "default"}, &writerQueue)
+ pair.reader.call(t, "queue_get", map[string]any{"name": "default"}, &readerQueue)
+ require.Equal(t, writerQueue, readerQueue)
+ require.Equal(t, "default", writerQueue.Name)
+ require.Nil(t, writerQueue.PausedAt)
+ listener := observer.listen(t, controlChannel)
+ pair.reader.call(t, "queue_update", map[string]any{
+ "metadata": map[string]any{"updated_by": pair.reader.name}, "name": "default",
+ }, &updatedQueue)
+ require.Equal(t, map[string]any{"updated_by": pair.reader.name}, updatedQueue.Metadata)
+ payloads := listener.receiveUntilMarker(t, observer, pair.reader.name+"-queue-update-marker")
+ require.Len(t, payloads, 1, "%s: one control notification per metadata update", pair.reader.name)
+ require.JSONEq(t,
+ `{"action":"metadata_changed","metadata":{"updated_by":"`+pair.reader.name+`"},"queue":"default"}`,
+ payloads[0], pair.reader.name)
+ pair.writer.call(t, "queue_get", map[string]any{"name": "default"}, &writerQueue)
+ require.Equal(t, updatedQueue, writerQueue)
+ var readerQueues, writerQueues struct {
+ Queues []normalizedQueue `json:"queues"`
+ }
+ pair.reader.call(t, "queue_list", map[string]any{}, &readerQueues)
+ pair.writer.call(t, "queue_list", map[string]any{}, &writerQueues)
+ require.Equal(t, writerQueues, readerQueues)
+ require.Contains(t, writerQueues.Queues, updatedQueue)
+ }
+}
+
+func verifyUnsafeInt64JobIDs(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ const firstUnsafeID int64 = 9_007_199_254_740_993
+ type jobPage struct {
+ Cursor *string `json:"cursor"`
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ for pairIndex, pair := range []struct {
+ reader *adapter
+ writer *adapter
+ }{
+ {reader: candidateAdapter, writer: goAdapter},
+ {reader: goAdapter, writer: candidateAdapter},
+ } {
+ pair.writer.call(t, "reset", map[string]any{}, nil)
+ ids := []int64{
+ firstUnsafeID + int64(pairIndex*10),
+ firstUnsafeID + int64(pairIndex*10) + 1,
+ }
+ for _, id := range ids {
+ var inserted struct {
+ ID int64 `json:"id"`
+ }
+ pair.writer.call(t, "raw_insert_exact_json", map[string]any{"id": id}, &inserted)
+ require.Equal(t, id, inserted.ID)
+
+ var observed normalizedJob
+ pair.reader.call(t, "get", map[string]any{"id": id}, &observed)
+ require.Equal(t, id, observed.ID)
+ }
+
+ listParams := func(after *string) map[string]any {
+ params := map[string]any{
+ "direction": "asc",
+ "ids": ids,
+ "limit": 1,
+ "order_by": "id",
+ }
+ if after != nil {
+ params["after"] = *after
+ }
+ return params
+ }
+ var readerFirst, writerFirst jobPage
+ pair.reader.call(t, "list", listParams(nil), &readerFirst)
+ pair.writer.call(t, "list", listParams(nil), &writerFirst)
+ require.Equal(t, writerFirst, readerFirst)
+ require.Equal(t, []int64{ids[0]}, normalizedJobIDs(writerFirst.Jobs))
+ require.NotNil(t, writerFirst.Cursor)
+
+ var readerSecond, writerSecond jobPage
+ pair.reader.call(t, "list", listParams(writerFirst.Cursor), &readerSecond)
+ pair.writer.call(t, "list", listParams(readerFirst.Cursor), &writerSecond)
+ require.Equal(t, writerSecond, readerSecond)
+ require.Equal(t, []int64{ids[1]}, normalizedJobIDs(writerSecond.Jobs))
+
+ var cancelled, observed normalizedJob
+ pair.reader.call(t, "cancel", map[string]any{"id": ids[0]}, &cancelled)
+ pair.writer.call(t, "get", map[string]any{"id": ids[0]}, &observed)
+ require.Equal(t, cancelled, observed)
+ require.Equal(t, ids[0], cancelled.ID)
+ require.Equal(t, "cancelled", cancelled.State)
+ }
+}
+
+func verifyJobRowRoundTrip(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ inserter *adapter
+ observer *adapter
+ }{
+ {inserter: goAdapter, observer: candidateAdapter},
+ {inserter: candidateAdapter, observer: goAdapter},
+ } {
+ pair.inserter.call(t, "reset", map[string]any{}, nil)
+ var exactInserted struct {
+ ID int64 `json:"id"`
+ }
+ pair.inserter.call(t, "raw_insert_exact_json", map[string]any{}, &exactInserted)
+ var exactAtInserter, exactAtObserver struct {
+ Decimal string `json:"decimal"`
+ Integer string `json:"integer"`
+ Negative string `json:"negative"`
+ }
+ pair.inserter.call(t, "raw_job_exact_json", map[string]any{"id": exactInserted.ID}, &exactAtInserter)
+ pair.observer.call(t, "raw_job_exact_json", map[string]any{"id": exactInserted.ID}, &exactAtObserver)
+ require.Equal(t, exactAtInserter, exactAtObserver)
+ require.Equal(t, "0.12345678901234567890123456789", exactAtObserver.Decimal)
+ require.Equal(t, "9223372036854775807", exactAtObserver.Integer)
+ require.Equal(t, "-9223372036854775808", exactAtObserver.Negative)
+
+ var inserted, observed normalizedJob
+ pair.inserter.call(t, "raw_insert_full_row", map[string]any{}, &inserted)
+ pair.observer.call(t, "get", map[string]any{"id": inserted.ID}, &observed)
+ require.Equal(t, inserted, observed)
+ require.Equal(t, map[string]any{
+ "nested": map[string]any{"enabled": true},
+ "values": []any{float64(1), "two", nil},
+ }, observed.Args)
+ require.Equal(t, 3, observed.Attempt)
+ require.NotNil(t, observed.AttemptedAt)
+ require.Equal(t, "2026-01-02T03:04:06.123456Z", *observed.AttemptedAt)
+ require.Equal(t, []string{"go-client", "candidate-client"}, observed.AttemptedBy)
+ require.Equal(t, "2026-01-02T03:04:05.6789Z", observed.CreatedAt)
+ require.Len(t, observed.Errors, 1)
+ require.Equal(t, "2026-01-02T03:04:06.123456Z", observed.Errors[0].At)
+ require.Equal(t, 3, observed.Errors[0].Attempt)
+ require.Equal(t, "worker failed: escaped \"detail\"", observed.Errors[0].Error)
+ require.Equal(t, "frame one\nframe two", observed.Errors[0].Trace)
+ require.NotNil(t, observed.FinalizedAt)
+ require.Equal(t, "2026-01-02T03:04:07.000001Z", *observed.FinalizedAt)
+ require.Equal(t, "conformance_full_row", observed.Kind)
+ require.Equal(t, 4, observed.MaxAttempts)
+ require.Equal(t, map[string]any{
+ "output": map[string]any{"ok": true},
+ "river:rescue_count": float64(2),
+ "user": "metadata",
+ }, observed.Metadata)
+ require.Equal(t, 2, observed.Priority)
+ require.Equal(t, "priority_jobs", observed.Queue)
+ require.Equal(t, "2026-01-02T03:04:05.999999Z", observed.ScheduledAt)
+ require.Equal(t, "discarded", observed.State)
+ require.Equal(t, []string{"alpha_tag", "beta_tag"}, observed.Tags)
+ require.NotNil(t, observed.UniqueKey)
+ require.Equal(t, strings.Repeat("ab", 32), *observed.UniqueKey)
+ require.Equal(t, []string{
+ "available", "completed", "pending", "retryable", "running", "scheduled",
+ }, observed.UniqueStates)
+ }
+}
+
+// verifyLargeMetadataRoundTrip keeps large numeric values in a string-valued
+// RPC parameter so neither adapter's JSON-RPC decoder can round them before
+// the database sees them.
+func verifyLargeMetadataRoundTrip(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ const metadataJSON = `{"negative":-9223372036854775808,"big_integer":123456789012345678901234567890,"beyond_float":1e400,"long_decimal":0.1000000000000000055511151231257827}`
+ type exactTokens struct {
+ BigInteger string `json:"big_integer"`
+ BeyondFloat string `json:"beyond_float"`
+ LongDecimal string `json:"long_decimal"`
+ }
+ for _, pair := range []struct {
+ writer *adapter
+ reader *adapter
+ }{
+ {writer: goAdapter, reader: candidateAdapter},
+ {writer: candidateAdapter, reader: goAdapter},
+ } {
+ pair.writer.call(t, "reset", map[string]any{}, nil)
+ var inserted struct {
+ ID int64 `json:"id"`
+ }
+ pair.writer.call(t, "raw_insert_exact_json", map[string]any{"metadata_json": metadataJSON}, &inserted)
+ read := func(actor *adapter) exactTokens {
+ t.Helper()
+ var tokens exactTokens
+ actor.call(t, "raw_job_exact_json", map[string]any{"id": inserted.ID}, &tokens)
+ return tokens
+ }
+ before := read(pair.writer)
+ require.Equal(t, before, read(pair.reader))
+ require.Equal(t, "123456789012345678901234567890", before.BigInteger)
+ require.Equal(t, "0.1000000000000000055511151231257827", before.LongDecimal)
+ require.NotEmpty(t, before.BeyondFloat)
+
+ pair.reader.call(t, "update", map[string]any{"id": inserted.ID, "output": "preserved"}, nil)
+ require.Equal(t, before, read(pair.writer))
+ require.Equal(t, before, read(pair.reader))
+ }
+}
+
+// verifyTransactionalJobCRUD runs job CRUD inside one implementation's
+// transaction and observes commit and rollback from the other.
+func verifyTransactionalJobCRUD(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ actor *adapter
+ observer *adapter
+ }{
+ {actor: goAdapter, observer: candidateAdapter},
+ {actor: candidateAdapter, observer: goAdapter},
+ } {
+ pair.actor.call(t, "reset", map[string]any{}, nil)
+
+ handle := pair.actor.name + "-transactional-crud-commit"
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var inserted normalizedJob
+ pair.actor.call(t, "tx_insert", map[string]any{
+ "handle": handle,
+ "job": map[string]any{
+ "message": "transactional CRUD",
+ "opts": map[string]any{
+ "metadata": map[string]any{"actor": pair.actor.name},
+ "tags": []string{"transactional_crud"},
+ },
+ },
+ }, &inserted)
+ requireJobNotFound(t, pair.observer, inserted.ID)
+
+ var transactionalJob normalizedJob
+ pair.actor.call(t, "tx_get", map[string]any{
+ "handle": handle, "id": inserted.ID,
+ }, &transactionalJob)
+ require.Equal(t, inserted, transactionalJob)
+ pair.actor.call(t, "tx_update", map[string]any{
+ "handle": handle, "id": inserted.ID,
+ "output": map[string]any{"updated_by": pair.actor.name},
+ }, &transactionalJob)
+ require.Equal(t, map[string]any{"updated_by": pair.actor.name}, transactionalJob.Metadata["output"])
+ var transactionalJobs struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.actor.call(t, "tx_list", map[string]any{
+ "handle": handle, "ids": []int64{inserted.ID},
+ }, &transactionalJobs)
+ require.Equal(t, []normalizedJob{transactionalJob}, transactionalJobs.Jobs)
+
+ pair.actor.call(t, "tx_cancel", map[string]any{
+ "handle": handle, "id": inserted.ID,
+ }, &transactionalJob)
+ require.Equal(t, "cancelled", transactionalJob.State)
+ pair.actor.call(t, "tx_retry", map[string]any{
+ "handle": handle, "id": inserted.ID,
+ }, &transactionalJob)
+ require.Equal(t, "available", transactionalJob.State)
+ requireJobNotFound(t, pair.observer, inserted.ID)
+ pair.actor.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+
+ var observedJob normalizedJob
+ pair.observer.call(t, "get", map[string]any{"id": inserted.ID}, &observedJob)
+ require.Equal(t, transactionalJob, observedJob)
+
+ handle = pair.actor.name + "-transactional-crud-rollback"
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var deleted normalizedJob
+ pair.actor.call(t, "tx_delete", map[string]any{
+ "handle": handle, "id": inserted.ID,
+ }, &deleted)
+ require.Equal(t, transactionalJob, deleted)
+ pair.observer.call(t, "get", map[string]any{"id": inserted.ID}, &observedJob)
+ require.Equal(t, transactionalJob, observedJob)
+ pair.actor.call(t, "tx_rollback", map[string]any{"handle": handle}, nil)
+ pair.observer.call(t, "get", map[string]any{"id": inserted.ID}, &observedJob)
+ require.Equal(t, transactionalJob, observedJob)
+
+ bulkIDs := make([]int64, 0, 2)
+ for index := range 2 {
+ var bulk normalizedJob
+ pair.actor.call(t, "insert", map[string]any{
+ "message": fmt.Sprintf("transactional bulk delete %d", index),
+ }, &bulk)
+ bulkIDs = append(bulkIDs, bulk.ID)
+ }
+ handle = pair.actor.name + "-transactional-bulk-delete"
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var deletedMany struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.actor.call(t, "tx_delete_many", map[string]any{
+ "handle": handle, "ids": bulkIDs,
+ }, &deletedMany)
+ require.ElementsMatch(t, bulkIDs, jobIDs(deletedMany.Jobs))
+ for _, id := range bulkIDs {
+ pair.observer.call(t, "get", map[string]any{"id": id}, &observedJob)
+ require.Equal(t, id, observedJob.ID)
+ }
+ pair.actor.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ for _, id := range bulkIDs {
+ requireJobNotFound(t, pair.observer, id)
+ }
+ }
+}
+
+// verifyTransactionalQueueOperations updates, pauses, and resumes a queue in
+// one implementation's transaction and observes commit and rollback from the
+// other.
+func verifyTransactionalQueueOperations(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ for _, pair := range []struct {
+ actor *adapter
+ observer *adapter
+ }{
+ {actor: goAdapter, observer: candidateAdapter},
+ {actor: candidateAdapter, observer: goAdapter},
+ } {
+ pair.actor.call(t, "reset", map[string]any{}, nil)
+ pair.actor.call(t, "start", map[string]any{
+ "client_id": pair.actor.name + "-transactional-queues", "max_workers": 1,
+ }, nil)
+ pair.actor.call(t, "stop", map[string]any{}, nil)
+
+ var queueBefore normalizedQueue
+ pair.observer.call(t, "queue_get", map[string]any{"name": "default"}, &queueBefore)
+
+ handle := pair.actor.name + "-transactional-queue-commit"
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ var queueInTransaction normalizedQueue
+ pair.actor.call(t, "tx_queue_update", map[string]any{
+ "handle": handle,
+ "metadata": map[string]any{"updated_by": pair.actor.name},
+ "name": "default",
+ }, &queueInTransaction)
+ require.Equal(t, map[string]any{"updated_by": pair.actor.name}, queueInTransaction.Metadata)
+ pair.actor.call(t, "tx_queue_pause", map[string]any{
+ "handle": handle, "name": "default",
+ }, nil)
+ pair.actor.call(t, "tx_queue_get", map[string]any{
+ "handle": handle, "name": "default",
+ }, &queueInTransaction)
+ require.NotNil(t, queueInTransaction.PausedAt)
+ var queuesInTransaction struct {
+ Queues []normalizedQueue `json:"queues"`
+ }
+ pair.actor.call(t, "tx_queue_list", map[string]any{
+ "handle": handle,
+ }, &queuesInTransaction)
+ require.Contains(t, queuesInTransaction.Queues, queueInTransaction)
+
+ var observedQueue normalizedQueue
+ pair.observer.call(t, "queue_get", map[string]any{"name": "default"}, &observedQueue)
+ require.Equal(t, queueBefore, observedQueue)
+ pair.actor.call(t, "tx_commit", map[string]any{"handle": handle}, nil)
+ pair.observer.call(t, "queue_get", map[string]any{"name": "default"}, &observedQueue)
+ require.Equal(t, queueInTransaction, observedQueue)
+
+ handle = pair.actor.name + "-transactional-queue-rollback"
+ pair.actor.call(t, "tx_begin", map[string]any{"handle": handle}, nil)
+ pair.actor.call(t, "tx_queue_resume", map[string]any{
+ "handle": handle, "name": "default",
+ }, nil)
+ pair.actor.call(t, "tx_queue_get", map[string]any{
+ "handle": handle, "name": "default",
+ }, &observedQueue)
+ require.Nil(t, observedQueue.PausedAt)
+ pair.observer.call(t, "queue_get", map[string]any{"name": "default"}, &observedQueue)
+ require.Equal(t, queueInTransaction, observedQueue)
+ pair.actor.call(t, "tx_rollback", map[string]any{"handle": handle}, nil)
+ pair.observer.call(t, "queue_get", map[string]any{"name": "default"}, &observedQueue)
+ require.Equal(t, queueInTransaction, observedQueue)
+ }
+}
+
+func verifyHistoricalMigrations(t *testing.T, latest int, adapters ...*adapter) {
+ t.Helper()
+
+ type migrationResult struct {
+ Applied []int `json:"applied"`
+ Existing []int `json:"existing"`
+ Valid bool `json:"valid"`
+ }
+ expectedLatest := make([]int, latest)
+ for index := range latest {
+ expectedLatest[index] = index + 1
+ }
+ for initializerIndex, initializer := range adapters {
+ upgrader := adapters[(initializerIndex+1)%len(adapters)]
+ for version := 1; version <= latest; version++ {
+ schema := fmt.Sprintf("river_conformance_history_%s_%d", initializer.name, version)
+ var result migrationResult
+ initializer.call(t, "migrate", map[string]any{
+ "direction": "down", "schema": schema, "target_version": -1,
+ }, &result)
+ initializer.call(t, "migrate", map[string]any{
+ "direction": "up", "schema": schema, "target_version": version,
+ }, &result)
+ require.Equal(t, expectedLatest[:version], result.Existing)
+ require.Equal(t, version == latest, result.Valid)
+
+ upgrader.call(t, "migrate", map[string]any{
+ "direction": "up", "schema": schema,
+ }, &result)
+ require.Equal(t, expectedLatest, result.Existing)
+ require.True(t, result.Valid)
+ var inserted, observed normalizedJob
+ upgrader.call(t, "insert", map[string]any{
+ "message": fmt.Sprintf("historical migration %d", version), "schema": schema,
+ }, &inserted)
+ initializer.call(t, "get", map[string]any{
+ "id": inserted.ID, "schema": schema,
+ }, &observed)
+ require.Equal(t, inserted, observed)
+
+ initializer.call(t, "migrate", map[string]any{
+ "direction": "down", "schema": schema, "target_version": version,
+ }, &result)
+ require.Equal(t, expectedLatest[:version], result.Existing)
+ upgrader.call(t, "migrate", map[string]any{
+ "direction": "up", "schema": schema,
+ }, &result)
+ require.Equal(t, expectedLatest, result.Existing)
+ require.True(t, result.Valid)
+ upgrader.call(t, "migrate", map[string]any{
+ "direction": "down", "schema": schema, "target_version": -1,
+ }, &result)
+ require.Empty(t, result.Existing)
+ }
+ }
+}
+
+// verifyDeterministicControls evaluates each implementation's production
+// default retry policy at fixed clock and seed inputs and requires the delay
+// to fall within the bounds generated from River's Go retry policy.
+func verifyDeterministicControls(t *testing.T, repositoryRoot string, adapters ...*adapter) {
+ t.Helper()
+
+ var fixture struct {
+ RetryCases []struct {
+ ErrorCount int `json:"error_count"`
+ JobID int64 `json:"job_id"`
+ MaxDelayNS int64 `json:"max_delay_ns"`
+ MinDelayNS int64 `json:"min_delay_ns"`
+ Now string `json:"now"`
+ Seed uint64 `json:"seed"`
+ } `json:"retry_cases"`
+ }
+ contents, err := os.ReadFile(filepath.Join(repositoryRoot, "conformance/fixtures/protocol_values.json"))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(contents, &fixture))
+ require.NotEmpty(t, fixture.RetryCases)
+ for _, testCase := range fixture.RetryCases {
+ for _, adapter := range adapters {
+ adapter.call(t, "clock_set", map[string]any{"now": testCase.Now}, nil)
+ adapter.call(t, "rng_seed", map[string]any{"seed": testCase.Seed}, nil)
+ var result struct {
+ DelayNS int64 `json:"delay_ns"`
+ }
+ adapter.call(t, "retry_delay", map[string]any{
+ "error_count": testCase.ErrorCount,
+ "job_id": testCase.JobID,
+ }, &result)
+ require.GreaterOrEqual(t, result.DelayNS, testCase.MinDelayNS, "%s adapter error_count %d", adapter.name, testCase.ErrorCount)
+ require.LessOrEqual(t, result.DelayNS, testCase.MaxDelayNS, "%s adapter error_count %d", adapter.name, testCase.ErrorCount)
+ }
+ }
+}
diff --git a/conformance/harness/unique_test.go b/conformance/harness/unique_test.go
new file mode 100644
index 000000000..c085da6b1
--- /dev/null
+++ b/conformance/harness/unique_test.go
@@ -0,0 +1,100 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func verifyUniqueKeyGoldens(t *testing.T, repositoryRoot string, adapters ...*adapter) {
+ t.Helper()
+
+ var fixture struct {
+ Cases []json.RawMessage `json:"cases"`
+ }
+ contents, err := os.ReadFile(filepath.Join(repositoryRoot, "conformance/fixtures/unique_keys.json"))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(contents, &fixture))
+ require.NotEmpty(t, fixture.Cases)
+
+ for _, encodedCase := range fixture.Cases {
+ var expected struct {
+ ExpectedError string `json:"expected_error"`
+ ExpectedSHA256 string `json:"expected_sha256"`
+ ExpectedStateMask int `json:"expected_state_mask"`
+ Name string `json:"name"`
+ }
+ require.NoError(t, json.Unmarshal(encodedCase, &expected))
+ for _, adapter := range adapters {
+ if expected.ExpectedError != "" {
+ adapter.requireCallError(t, "unique_key", encodedCase, expected.ExpectedError)
+ continue
+ }
+ var actual struct {
+ SHA256 string `json:"sha256"`
+ StateMask int `json:"state_mask"`
+ }
+ adapter.call(t, "unique_key", encodedCase, &actual)
+ require.Equal(t, expected.ExpectedSHA256, actual.SHA256,
+ "%s adapter fixture %s", adapter.name, expected.Name)
+ require.Equal(t, expected.ExpectedStateMask, actual.StateMask,
+ "%s adapter fixture %s", adapter.name, expected.Name)
+ }
+ }
+}
+
+// verifyUniqueSkipKeepsExistingKind has one implementation insert a job unique
+// by args with `exclude_kind`, then gives it another kind out of band, which
+// leaves its unique key shared with `conformance_echo` insertions of the same
+// args. The other implementation inserts those args singly and in a batch.
+// Both are skipped as duplicates, and both must return the existing job and
+// leave it as it was rather than rewriting its kind to their own, which would
+// hand it to the wrong worker.
+func verifyUniqueSkipKeepsExistingKind(t *testing.T, goAdapter, candidateAdapter *adapter) {
+ t.Helper()
+
+ const existingKind = "conformance_unique_other_kind"
+ for _, pair := range []struct {
+ first *adapter
+ skipper *adapter
+ }{
+ {first: goAdapter, skipper: candidateAdapter},
+ {first: candidateAdapter, skipper: goAdapter},
+ } {
+ pair.first.call(t, "reset", map[string]any{}, nil)
+ job := map[string]any{
+ "message": "unique skip keeps kind " + pair.first.name,
+ "opts": map[string]any{
+ "unique": map[string]any{"by_args": true, "exclude_kind": true},
+ },
+ }
+ var existing normalizedJob
+ pair.first.call(t, "insert", job, &existing)
+ require.Equal(t, "conformance_echo", existing.Kind)
+ pair.first.call(t, "raw_set_kind", map[string]any{"id": existing.ID, "kind": existingKind}, &existing)
+ require.Equal(t, existingKind, existing.Kind)
+
+ var single normalizedJob
+ pair.skipper.call(t, "insert", job, &single)
+ require.Equal(t, existing, single, "%s insert", pair.skipper.name)
+
+ var batch struct {
+ Results []normalizedInsertResult `json:"results"`
+ }
+ pair.skipper.call(t, "insert_many", map[string]any{"jobs": []map[string]any{job}}, &batch)
+ require.Len(t, batch.Results, 1)
+ require.True(t, batch.Results[0].UniqueSkippedAsDuplicate, "%s insert_many", pair.skipper.name)
+ require.Equal(t, existing, batch.Results[0].Job, "%s insert_many", pair.skipper.name)
+
+ var listed struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ pair.first.call(t, "list", map[string]any{}, &listed)
+ require.Equal(t, []normalizedJob{existing}, listed.Jobs)
+ }
+}
diff --git a/conformance/harness/wait_test.go b/conformance/harness/wait_test.go
new file mode 100644
index 000000000..22628d7df
--- /dev/null
+++ b/conformance/harness/wait_test.go
@@ -0,0 +1,207 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func normalizedJobIDs(jobs []normalizedJob) []int64 {
+ ids := make([]int64, len(jobs))
+ for index, job := range jobs {
+ ids[index] = job.ID
+ }
+ return ids
+}
+
+func waitForListedJob(t *testing.T, adapter *adapter, params map[string]any) normalizedJob {
+ t.Helper()
+
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ var result struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ adapter.call(t, "list", params, &result)
+ if len(result.Jobs) > 0 {
+ return result.Jobs[0]
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("%s adapter did not list a matching job", adapter.name)
+ return normalizedJob{}
+}
+
+func waitForListedJobCount(t *testing.T, adapter *adapter, params map[string]any, count int) []normalizedJob {
+ t.Helper()
+
+ return waitForListedJobCountWithin(t, adapter, params, count, 5*time.Second)
+}
+
+// waitForListedJobCountWithin polls a job list until it contains exactly
+// count jobs or the timeout elapses.
+func waitForListedJobCountWithin(t *testing.T, adapter *adapter, params map[string]any, count int, timeout time.Duration) []normalizedJob {
+ t.Helper()
+
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ var result struct {
+ Jobs []normalizedJob `json:"jobs"`
+ }
+ adapter.call(t, "list", params, &result)
+ if len(result.Jobs) == count {
+ return result.Jobs
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("%s did not list %d matching jobs", adapter.name, count)
+ return nil
+}
+
+func waitForRuntimeStats(t *testing.T, adapter *adapter, predicate func(runtimeStats) bool) runtimeStats {
+ t.Helper()
+
+ deadline := time.Now().Add(5 * time.Second)
+ var stats runtimeStats
+ for time.Now().Before(deadline) {
+ adapter.call(t, "runtime_stats", map[string]any{}, &stats)
+ if predicate(stats) {
+ return stats
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("%s adapter runtime observations did not converge: %+v", adapter.name, stats)
+ return runtimeStats{}
+}
+
+func countRuntimeEvent(stats runtimeStats, kind string) int {
+ count := 0
+ for _, event := range stats.Events {
+ if event == kind {
+ count++
+ }
+ }
+ return count
+}
+
+func requireOrderedSubsequence(t *testing.T, values, expected []string) {
+ t.Helper()
+
+ index := 0
+ for _, value := range values {
+ if value == expected[index] {
+ index++
+ if index == len(expected) {
+ return
+ }
+ }
+ }
+ t.Fatalf("expected ordered subsequence %v in %v", expected, values)
+}
+
+func mapKeys(values map[string]bool) []string {
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ return keys
+}
+
+func jobIDs(jobs []normalizedJob) []int64 {
+ ids := make([]int64, len(jobs))
+ for index, job := range jobs {
+ ids[index] = job.ID
+ }
+ return ids
+}
+
+func waitForLeader(t *testing.T, observer *adapter, previous string) string {
+ t.Helper()
+
+ deadline := time.Now().Add(12 * time.Second)
+ var observations []string
+ for time.Now().Before(deadline) {
+ var result struct {
+ ElectedAt *string `json:"elected_at"`
+ LeaderID *string `json:"leader_id"`
+ }
+ observer.call(t, "leader", map[string]any{}, &result)
+ leaderID, electedAt := "", ""
+ if result.LeaderID != nil {
+ leaderID = *result.LeaderID
+ }
+ if result.ElectedAt != nil {
+ electedAt = *result.ElectedAt
+ }
+ observation := leaderID + "@" + electedAt
+ if len(observations) == 0 || observations[len(observations)-1] != observation {
+ observations = append(observations, observation)
+ }
+ if result.LeaderID != nil && *result.LeaderID != previous {
+ return *result.LeaderID
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ t.Fatalf("leader did not change from %q; observations=%v; %s adapter stderr: %s", previous, observations, observer.name, observer.stderr.String())
+ return ""
+}
+
+type leaderTerm struct {
+ ElectedAt string
+ LeaderID string
+}
+
+func readLeader(t *testing.T, observer *adapter) leaderTerm {
+ t.Helper()
+
+ var result struct {
+ ElectedAt *string `json:"elected_at"`
+ LeaderID *string `json:"leader_id"`
+ }
+ observer.call(t, "leader", map[string]any{}, &result)
+ if result.ElectedAt == nil || result.LeaderID == nil {
+ return leaderTerm{}
+ }
+ return leaderTerm{ElectedAt: *result.ElectedAt, LeaderID: *result.LeaderID}
+}
+
+func waitForLeaderTerm(t *testing.T, observer *adapter, previousElectedAt string) leaderTerm {
+ t.Helper()
+
+ deadline := time.Now().Add(12 * time.Second)
+ for time.Now().Before(deadline) {
+ term := readLeader(t, observer)
+ if term.ElectedAt != "" && term.ElectedAt != previousElectedAt {
+ return term
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ t.Fatalf("leadership term did not change from %q", previousElectedAt)
+ return leaderTerm{}
+}
+
+func waitForListener(t *testing.T, observer *adapter) {
+ t.Helper()
+
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ var result struct {
+ Count int `json:"count"`
+ }
+ response := observer.callResponse(t, "listener_count", map[string]any{})
+ if response.Error != nil {
+ time.Sleep(25 * time.Millisecond)
+ continue
+ }
+ require.NoError(t, json.Unmarshal(response.Result, &result))
+ if result.Count > 0 {
+ return
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ t.Fatalf("%s adapter did not establish a LISTEN connection", observer.name)
+}
diff --git a/conformance/harness/yugabyte_test.go b/conformance/harness/yugabyte_test.go
new file mode 100644
index 000000000..8032f80e1
--- /dev/null
+++ b/conformance/harness/yugabyte_test.go
@@ -0,0 +1,116 @@
+//go:build riverconformance
+
+package harness_test
+
+import (
+ "context"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/stretchr/testify/require"
+)
+
+// simulatedYugabyteSchema holds River's tables and the functions that make
+// PostgreSQL look like YugabyteDB to connections that search it first.
+const simulatedYugabyteSchema = "river_conformance_yugabyte"
+
+// verifySimulatedYugabyte runs both implementations on PostgreSQL made to
+// look like YugabyteDB without LISTEN/NOTIFY, the way River Go's own tests
+// simulate it. A schema ahead of pg_catalog on the adapters' search_path
+// shadows version() and current_setting(text, boolean) with a Yugabyte
+// version whose yb_enable_listen_notify setting is absent, and shadows
+// pg_notify with a function that raises, so any notification fails the
+// operation that sends it.
+//
+// Each implementation must detect the server by itself: write unique jobs
+// with a nonce rather than rely on xmax, which Yugabyte lacks, so the other
+// implementation's duplicate insert returns the same job; send no
+// notification when it inserts or cancels; and, without being configured
+// as poll-only, notice the other implementation's cancellation of its
+// running job by polling. The simulation doesn't emulate Yugabyte's storage
+// or transaction semantics.
+func verifySimulatedYugabyte(t *testing.T, observer *postgresObserver, root, databaseURL string, candidateSpec adapterSpec) {
+ t.Helper()
+
+ ctx := context.Background()
+ schema := pgx.Identifier{simulatedYugabyteSchema}.Sanitize()
+ _, err := observer.pool.Exec(ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE;
+CREATE SCHEMA `+schema+`;
+CREATE FUNCTION `+schema+`.version() RETURNS text LANGUAGE sql AS $$
+ SELECT 'PostgreSQL 15.12-YB-2025.2.1.0-b1'::text
+$$;
+CREATE FUNCTION `+schema+`.current_setting(setting_name text, missing_ok boolean) RETURNS text LANGUAGE sql AS $$
+ SELECT CASE WHEN setting_name = 'yb_enable_listen_notify' THEN NULL::text
+ ELSE pg_catalog.current_setting(setting_name, missing_ok) END
+$$;
+CREATE FUNCTION `+schema+`.pg_notify(text, text) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN RAISE EXCEPTION 'LISTEN/NOTIFY is unavailable'; END
+$$;`)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ _, err := observer.pool.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schema+` CASCADE`)
+ require.NoError(t, err)
+ })
+
+ // Spaces are escaped as %20 rather than +, which not every driver's URL
+ // parser decodes as a space.
+ parsed, err := url.Parse(databaseURL)
+ require.NoError(t, err)
+ options := "options=" + strings.ReplaceAll(url.QueryEscape("-c search_path="+simulatedYugabyteSchema+",pg_catalog"), "+", "%20")
+ if parsed.RawQuery != "" {
+ options = parsed.RawQuery + "&" + options
+ }
+ parsed.RawQuery = options
+ yugabyteURL := parsed.String()
+
+ goAdapter := startReferenceAdapter(t, root, yugabyteURL, "go-yugabyte")
+ candidateAdapter := startCandidateAdapter(t, root, yugabyteURL, candidateSpec.Implementation+"-yugabyte", candidateSpec, candidateSpec.Command)
+ // Without a schema, River uses the connection's current schema, the
+ // simulated one.
+ goAdapter.call(t, "migrate", map[string]any{}, nil)
+
+ for _, pair := range []struct {
+ controller *adapter
+ worker *adapter
+ }{
+ {controller: goAdapter, worker: candidateAdapter},
+ {controller: candidateAdapter, worker: goAdapter},
+ } {
+ pair.worker.call(t, "reset", map[string]any{}, nil)
+
+ unique := map[string]any{
+ "message": "simulated yugabyte unique " + pair.controller.name,
+ "opts": map[string]any{"unique": map[string]any{"by_args": true}},
+ }
+ var inserted, duplicate normalizedJob
+ pair.controller.call(t, "insert", unique, &inserted)
+ // Adapters leave the nonce out of the jobs they report.
+ var hasNonce bool
+ require.NoError(t, observer.pool.QueryRow(ctx,
+ `SELECT metadata ? 'river:unique_nonce' FROM `+schema+`.river_job WHERE id = $1`, inserted.ID,
+ ).Scan(&hasNonce))
+ require.True(t, hasNonce, "%s inserted a unique job without a nonce", pair.controller.name)
+ pair.worker.call(t, "insert", unique, &duplicate)
+ require.Equal(t, inserted.ID, duplicate.ID, "%s inserted a duplicate of %s's unique job", pair.worker.name, pair.controller.name)
+
+ pair.worker.call(t, "start", map[string]any{
+ "client_id": pair.worker.name + "-yugabyte", "fetch_poll_interval_ms": 100, "max_workers": 1,
+ }, nil)
+ var cancellable normalizedJob
+ pair.controller.call(t, "insert", map[string]any{
+ "behavior": "cooperative_cancel", "message": "simulated yugabyte cancel",
+ }, &cancellable)
+ pair.worker.call(t, "wait", map[string]any{
+ "id": cancellable.ID, "states": []string{"running"},
+ }, &cancellable)
+ startedAt := time.Now()
+ pair.controller.call(t, "cancel", map[string]any{"id": cancellable.ID}, nil)
+ pair.worker.call(t, "wait", map[string]any{"id": cancellable.ID}, &cancellable)
+ require.Equal(t, "cancelled", cancellable.State)
+ require.Less(t, time.Since(startedAt), 6*time.Second)
+ pair.worker.call(t, "stop", map[string]any{}, nil)
+ }
+}
diff --git a/conformance/manifest.json b/conformance/manifest.json
new file mode 100644
index 000000000..599a7f242
--- /dev/null
+++ b/conformance/manifest.json
@@ -0,0 +1,56 @@
+{
+ "$schema": "schema/protocol.schema.json",
+ "capabilities": {
+ "barriers": "complete",
+ "cancel": "complete",
+ "custom_schema": "complete",
+ "deterministic_controls": "complete",
+ "extensions": "complete",
+ "fault_injection": "complete",
+ "get": "complete",
+ "insert": "complete",
+ "job_crud": "complete",
+ "leadership": "complete",
+ "lifecycle": "complete",
+ "maintenance": "complete",
+ "migrate": "complete",
+ "notifications": "complete",
+ "periodic_jobs": "complete",
+ "poll_only": "complete",
+ "queues": "complete",
+ "reset": "complete",
+ "resumable_jobs": "complete",
+ "retry": "complete",
+ "scheduler": "complete",
+ "subscriber_lag": "planned",
+ "subscriptions": "complete",
+ "transactions": "complete",
+ "unique_jobs": "complete",
+ "work": "complete"
+ },
+ "capability_decisions": {
+ "subscriber_lag": "Implementations report subscriber lag through their own APIs, but adapter protocol revision 1 exposes no normalized lag observation, so no shared scenario can verify it. A later contract revision must add one before any implementation claims it."
+ },
+ "implementations": {
+ "go": {
+ "package": "github.com/riverqueue/river",
+ "registry": "go",
+ "version": "0.49.0"
+ },
+ "javascript": {
+ "package": "riverqueue",
+ "registry": "npm",
+ "version": "0.49.0-alpha.1"
+ },
+ "rust": {
+ "package": "riverqueue",
+ "registry": "crates.io",
+ "version": "0.49.0-alpha.1"
+ }
+ },
+ "migration": {
+ "latest": 8,
+ "line": "main"
+ },
+ "protocol_revision": 1
+}
diff --git a/conformance/migrations-sqlite.json b/conformance/migrations-sqlite.json
new file mode 100644
index 000000000..4b7b5d782
--- /dev/null
+++ b/conformance/migrations-sqlite.json
@@ -0,0 +1,70 @@
+{
+ "database": "sqlite",
+ "files": [
+ {
+ "path": "riverdriver/riversqlite/migration/main/001_create_river_migration.down.sql",
+ "sha256": "34c87dc594bf7520bc3ae69f6f0da8d2d9a472616ab38b37e63d4e3838da06d2"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/001_create_river_migration.up.sql",
+ "sha256": "d15597cb0bb884fb0727d2a29ad8313842708b55fd561a5fe62e37aad5f34298"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/002_initial_schema.down.sql",
+ "sha256": "900508ba08d0ca3c8451eb2854cd9ab837166ef55736393524253b6228438470"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/002_initial_schema.up.sql",
+ "sha256": "58bc64db39fa813ab1eee92b5c3f6e4463f88ac1de85df731d959cdede7d4f35"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/003_river_job_tags_non_null.down.sql",
+ "sha256": "223eb849addf451228e7f057c2e29b005aaf63ddf25c51a7a088426d139d9dd9"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/003_river_job_tags_non_null.up.sql",
+ "sha256": "ae9961ea15b2fbe88298c687dd524ada29e018f8fbc493db517e988d9d0b61c2"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/004_pending_and_more.down.sql",
+ "sha256": "28065bbe82dbaa187d8705861d8fec558f210eb739feba63e7913a09a5e9ae3f"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/004_pending_and_more.up.sql",
+ "sha256": "8c11c8d2bf63200e2cfe58dca1e5d30131fed0f99cb74146b3116fa1286a93f5"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/005_migration_unique_client.down.sql",
+ "sha256": "9960dc49a2293a9bdbdb32ca61dc2971cde5b49ac658ef21ed7f96ec6e011997"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/005_migration_unique_client.up.sql",
+ "sha256": "67c32e81494b62baf1e6b0fb6025e882a7b1be7c9ed2236799d17d00912213c4"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/006_bulk_unique.down.sql",
+ "sha256": "b9e778134d15e815cf0694f06444f738cde072c2d297978eb30d7bd7bdb2802c"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/006_bulk_unique.up.sql",
+ "sha256": "96713f4832bcf9343df30b62c6c35b03daca23aa9a02adbd7eb0091556df3659"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql",
+ "sha256": "55bffeb528b40dffc0cbec2f22d1463aef37a8b3d7977f83729cc505bb77c745"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql",
+ "sha256": "441a05e1d9aa4f151877ccf725b0b0a86f27013442297d4b621c05676270d0b0"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/008_job_id_autoincrement.down.sql",
+ "sha256": "04871283fe5d4cab4ac70da28d8509aa7ba765994d528c2dcc7ebe0ee596710c"
+ },
+ {
+ "path": "riverdriver/riversqlite/migration/main/008_job_id_autoincrement.up.sql",
+ "sha256": "049c9bf615f24a326bcc31ddc87b46f76e3de11e3eea3b2b9e1358131b3f3bed"
+ }
+ ],
+ "line": "main"
+}
diff --git a/conformance/migrations.json b/conformance/migrations.json
new file mode 100644
index 000000000..e2daebc52
--- /dev/null
+++ b/conformance/migrations.json
@@ -0,0 +1,70 @@
+{
+ "database": "postgres",
+ "files": [
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/001_create_river_migration.down.sql",
+ "sha256": "34c87dc594bf7520bc3ae69f6f0da8d2d9a472616ab38b37e63d4e3838da06d2"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/001_create_river_migration.up.sql",
+ "sha256": "79def9ab1643beee7776c499559ec199a03b5b26036c122dc3ba13ec3d078dc0"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/002_initial_schema.down.sql",
+ "sha256": "8e7e73755b3e9cd1d46f0dffeadd427b86af13cea2f41f3d30af1624329db9b9"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/002_initial_schema.up.sql",
+ "sha256": "8915c00d08ed98625865c705b6fd0bd14c113b7cdd0cb218ee894eca1d32ad03"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/003_river_job_tags_non_null.down.sql",
+ "sha256": "bca44f6f0e926411c9e26e7ce2598bbdb5102b286f380135d9a5bcd96a77cbb8"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/003_river_job_tags_non_null.up.sql",
+ "sha256": "dedb183bb302c005bc72caf2901ff693bbab11413308c5e0567ddffb51e667ef"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/004_pending_and_more.down.sql",
+ "sha256": "91b5ced7b9d707a0de73f5b312596935950b70229f58aa9bf3ca362aa7a408c8"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/004_pending_and_more.up.sql",
+ "sha256": "3f7418b0cf78ede9a9ec730bdfc4389a84e05989531b205fc4ece0d2bb10e390"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/005_migration_unique_client.down.sql",
+ "sha256": "de84dca49a5d618d2a4973b13a69830fbebb0f9635babfa50c6f19577193425b"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/005_migration_unique_client.up.sql",
+ "sha256": "b760f487152c7d92102869d46b8a64dc1e2094d5675e690ffbe52a747eee8431"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/006_bulk_unique.down.sql",
+ "sha256": "726483f6e5aa7dd02cdd974cd7bf716973a8d0a97ba6dbc5dc5304aaf54c7ad7"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/006_bulk_unique.up.sql",
+ "sha256": "3b133f7ce4662d3dc8bd4a57628e0e116a300b2635a79315557aa1369849f0fb"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql",
+ "sha256": "9131aae235187dbdaaa822dab2a475a884e917d9af05e3c98fb95c152eaa769a"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql",
+ "sha256": "47ec8031b88e69004de2def5bc3109d969f71ee4c33a1e7dac2fb8c9dd19182d"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/008_job_id_autoincrement.down.sql",
+ "sha256": "0c3750a947d6494db07d56f5d3735a5e49a2cbfa1f7a09771227c31c8147bf70"
+ },
+ {
+ "path": "riverdriver/riverpgxv5/migration/main/008_job_id_autoincrement.up.sql",
+ "sha256": "0c3750a947d6494db07d56f5d3735a5e49a2cbfa1f7a09771227c31c8147bf70"
+ }
+ ],
+ "line": "main"
+}
diff --git a/conformance/scenarios/core.json b/conformance/scenarios/core.json
new file mode 100644
index 000000000..095ec83dd
--- /dev/null
+++ b/conformance/scenarios/core.json
@@ -0,0 +1,120 @@
+{
+ "$schema": "../schema/scenarios.schema.json",
+ "protocol_revision": 1,
+ "scenarios": [
+ { "evidence": [{ "path": "conformance/harness/mixed_test.go", "symbol": "verifyPostgresHandshakes" }], "name": "adapter_handshake_and_capabilities", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyBarrierWaitAndRelease" }], "name": "barrier_wait_and_release", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyBulkDeleteSafety" }], "name": "bulk_delete_safety", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/mixed_test.go", "symbol": "verifyInsertThenWork" }], "name": "candidate_insert_reference_work", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/mixed_test.go", "symbol": "verifyMigratorRuntime" }], "name": "candidate_migrator_reference_runtime", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/lifecycle_scenarios_test.go", "symbol": "verifyProcessKillCrossEngineRescue" }], "name": "candidate_process_kill_reference_rescue", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyClaimOrder" }], "name": "claim_order", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/interop_scenarios_test.go", "symbol": "verifyClaimTimeCancellation" }], "name": "claim_time_cancellation", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/resilience_test.go", "symbol": "TestResilienceConformance" }], "name": "claimed_row_decode_isolation", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/lifecycle_scenarios_test.go", "symbol": "verifyClockBoundaries" }], "name": "clock_boundary_scheduling", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyCompletionBatching" }], "name": "completion_batching", "tier": "performance" },
+ { "evidence": [{ "path": "conformance/harness/resilience_test.go", "symbol": "TestResilienceConformance" }], "name": "completion_row_lock_wait", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/resilience_test.go", "symbol": "TestResilienceConformance" }], "name": "completion_transient_failure_retry", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyCooperativeRemoteCancellation" }], "name": "cooperative_remote_cancellation", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "verifyCronScheduleGoldens" }], "name": "cron_schedule_goldens", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyConcurrentCancelRetryRace" }], "name": "cross_language_cancel_retry_race", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyConcurrentUniqueConflicts" }], "name": "cross_language_unique_conflict", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyCustomSchema" }], "name": "custom_schema_candidate_migrate_reference_work", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyCustomSchema" }], "name": "custom_schema_reference_migrate_candidate_work", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/resilience_test.go", "symbol": "TestResilienceConformance" }], "name": "database_unavailable_reconnect", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/lifecycle_scenarios_test.go", "symbol": "verifyDefaultRetrySchedule" }], "name": "default_retry_policy_schedule", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyDeterministicControls" }], "name": "deterministic_retry_clock_rng", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyDifferentialJobCRUD" }], "name": "differential_job_crud", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyDifferentialListCursors" }], "name": "differential_job_list_filters_and_cursors", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyDifferentialQueueCRUD" }], "name": "differential_queue_crud", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyDynamicQueues" }], "name": "dynamic_queue_add_reconfigure_remove", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyErrorHandlerCancel" }], "name": "error_handler_cancel_override", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/retry_test.go", "symbol": "verifyExhaustedJobRetry" }], "name": "exhausted_job_retry", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyExtensionOrder" }], "name": "extension_hook_middleware_order", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyExternalTerminalCompletionRace" }], "name": "external_terminal_completion_race", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/resilience_test.go", "symbol": "TestResilienceConformance" }], "name": "hard_shutdown_soft_stop_classification", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/kinds_test.go", "symbol": "verifyHeterogeneousFleet" }], "name": "heterogeneous_fleet_known_kinds", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyHistoricalMigrations" }], "name": "historical_migration_down_up", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyIgnoredCancellationHardAbort" }], "name": "ignored_cancellation_hard_abort", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyJobCleanerQueueFilters" }], "name": "job_cleaner_queue_filters", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyJobListCursorInterchange" }], "name": "job_list_cursor_interchange", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyJobRowRoundTrip" }], "name": "job_row_round_trip_all_fields", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/kinds_test.go", "symbol": "verifyKindAliasRename" }], "name": "kind_alias_rename", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyLeaderElectionDisabled" }], "name": "leader_election_disabled_both_directions", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "verifyRenewalUnderSlowMaintenance" }], "name": "leadership_renewal_under_slow_maintenance", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "verifySameClientIDTermReplacement" }], "name": "leadership_same_client_id_term_replacement", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyListenerReconnect" }], "name": "listener_backend_disconnect_reconnect", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyLostNotificationPollRecovery" }], "name": "lost_notification_poll_recovery", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "verifyJobCleanerRetention" }], "name": "maintenance_job_cleaner_retention", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "verifyQueueCleaner" }], "name": "maintenance_queue_cleaner_keeps_active_queues", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "verifyReindexer" }], "name": "maintenance_reindexer_skips_artifacts", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "verifyRescuerFullBatch" }], "name": "maintenance_rescuer_full_batch_of_unexpired_jobs", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "verifyRescuerStaleSelection" }], "name": "maintenance_rescuer_stale_selection", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "TestMaintenanceConformance" }], "name": "migration_mixed_case_schema", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/performance_test.go", "symbol": "TestMixedSoak" }], "name": "mixed_connection_pool_bound", "tier": "performance" },
+ { "evidence": [{ "path": "conformance/harness/lifecycle_scenarios_test.go", "symbol": "verifyLeaderDeathFailover" }], "name": "mixed_leader_death_failover_both_directions", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyGracefulLeaderFailover" }], "name": "mixed_leader_failover_both_directions", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyLeadershipRequestLifecycle" }], "name": "mixed_request_resign_terms", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifySkipLockedCompetition" }], "name": "mixed_skip_locked_competition", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/performance_test.go", "symbol": "TestMixedSoak" }], "name": "mixed_soak", "tier": "performance" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyUnknownKind" }], "name": "mixed_unknown_kind_error", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/multi_engine_test.go", "symbol": "TestMultiEngineConformance" }], "name": "multi_engine_competition", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/multi_engine_test.go", "symbol": "verifyDirectedCandidateWork" }], "name": "multi_engine_directed_candidate_work_notification_cancellation", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/multi_engine_test.go", "symbol": "TestMultiEngineConformance" }], "name": "multi_engine_fault_recovery", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyJobListCursorInterchange" }], "name": "multi_engine_job_list_cursor_interchange", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyLeaderElectionDisabled" }], "name": "multi_engine_leader_election_disabled", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/multi_engine_test.go", "symbol": "TestMultiEngineConformance" }], "name": "multi_engine_leader_failover", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/multi_engine_test.go", "symbol": "verifyCrossEngineProcessKillRescue" }], "name": "multi_engine_process_kill_rescue_failover", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/multi_engine_test.go", "symbol": "TestMultiEnginePerformanceGate" }], "name": "multi_engine_release_performance", "tier": "performance" },
+ { "evidence": [{ "path": "conformance/harness/multi_engine_test.go", "symbol": "assertMultiEngineConnectionBounds" }], "name": "multi_engine_resource_bound", "tier": "performance" },
+ { "evidence": [{ "path": "conformance/harness/resumable_test.go", "symbol": "verifyResumableInteroperability" }], "name": "multi_engine_resumable_cursor", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/multi_engine_test.go", "symbol": "TestMultiEngineSoak" }], "name": "multi_engine_soak", "tier": "performance" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyInsertNotificationWakeup" }], "name": "notification_only_wakeups", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/interop_scenarios_test.go", "symbol": "verifyNotificationPayloads" }], "name": "notification_payloads", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyPanicAttemptTrace" }], "name": "panic_attempt_trace", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyPauseResumeNotification" }], "name": "pause_resume_notification", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "verifyPeriodicDueJobAvailable" }], "name": "periodic_due_job_available", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyPeriodicRunOnStart" }], "name": "periodic_run_on_start", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/interop_scenarios_test.go", "symbol": "verifyUniquePeriodicJob" }], "name": "periodic_unique_cross_engine", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyPollOnlyRemoteCancellation" }], "name": "poll_only_remote_cancellation", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/lifecycle_scenarios_test.go", "symbol": "verifyPoolPressure" }], "name": "pool_pressure_completion", "tier": "performance" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyProcessKillRestartAndRescue" }], "name": "process_kill_restart_and_rescue", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/maintenance_test.go", "symbol": "TestMaintenanceConformance" }], "name": "queue_names_and_unknown_queue_control", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/mixed_test.go", "symbol": "verifyInsertThenWork" }], "name": "reference_insert_candidate_work", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/mixed_test.go", "symbol": "verifyMigratorRuntime" }], "name": "reference_migrator_candidate_runtime", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/lifecycle_scenarios_test.go", "symbol": "verifyProcessKillCrossEngineRescue" }], "name": "reference_process_kill_candidate_rescue", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyRefetchedAttemptCancellation" }], "name": "refetched_attempt_cancellation", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/performance_test.go", "symbol": "TestPerformanceGate" }], "name": "release_enqueue_performance", "tier": "performance" },
+ { "evidence": [{ "path": "conformance/harness/performance_test.go", "symbol": "TestPerformanceGate" }], "name": "release_mixed_performance", "tier": "performance" },
+ { "evidence": [{ "path": "conformance/harness/performance_test.go", "symbol": "TestPerformanceGate" }], "name": "release_worker_performance", "tier": "performance" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyRemoteCancelNotification" }], "name": "remote_cancel_notification", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyRemoteQueueSubscriptionEvents" }], "name": "remote_queue_subscription_events", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/kinds_test.go", "symbol": "verifyRescuerUnknownKind" }], "name": "rescuer_unknown_kind_discard", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/lifecycle_scenarios_test.go", "symbol": "verifyReservedMetadata" }], "name": "reserved_metadata_cross_engine", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/resumable_test.go", "symbol": "verifyResumableInteroperability" }], "name": "resumable_cross_engine_cursor", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyResumableRetry" }], "name": "resumable_retry", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/resumable_test.go", "symbol": "verifyResumableValidation" }], "name": "resumable_validation", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/lifecycle_scenarios_test.go", "symbol": "verifyRollingDeployment" }], "name": "rolling_deployment_same_protocol", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/scheduler_test.go", "symbol": "verifySchedulerUniqueConflictDiscard" }], "name": "scheduler_unique_conflict_discard", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/resilience_test.go", "symbol": "TestResilienceConformance" }], "name": "shutdown_after_cancel_attempt", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/yugabyte_test.go", "symbol": "verifySimulatedYugabyte" }], "name": "simulated_yugabyte_polling", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyWorkerOutcomes" }], "name": "single_implementation_worker_outcomes", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifySnoozeTransition" }], "name": "snooze_once_metadata_transition", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/lifecycle_scenarios_test.go", "symbol": "verifyStuckJobDetection" }], "name": "stuck_job_detection", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyTimeoutCancellation" }], "name": "timeout_cancellation", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/mixed_test.go", "symbol": "verifyTransactionAbortRollback" }], "name": "transaction_abort_rollback_visibility", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/mixed_test.go", "symbol": "verifyTransactionCommitVisibility" }], "name": "transaction_commit_visibility", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/mixed_test.go", "symbol": "verifyTransactionRollbackVisibility" }], "name": "transaction_rollback_visibility", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyTransactionalBatchInsertion" }], "name": "transactional_batch_insertion", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyTransactionalCompletion" }], "name": "transactional_completion", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/mixed_test.go", "symbol": "verifyTransactionalCrossLanguageCancel" }], "name": "transactional_cross_language_cancel", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyTransactionalJobCRUD" }], "name": "transactional_crud_commit_rollback", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyTransactionalNotificationWakeups" }], "name": "transactional_insert_notification_commit_only", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyTransactionalQueueOperations" }], "name": "transactional_queue_operations", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyBatchInsertion" }], "name": "typed_batch_insertion", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/interop_scenarios_test.go", "symbol": "verifyUniqueColumnBytes" }], "name": "unique_column_bytes", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/unique_test.go", "symbol": "verifyUniqueKeyGoldens" }], "name": "unique_hash_goldens", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/unique_test.go", "symbol": "verifyUniqueSkipKeepsExistingKind" }], "name": "unique_skip_keeps_existing_kind", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyUnsafeInt64JobIDs" }], "name": "unsafe_int64_job_ids_rpc_list_cursors", "tier": "codec" }
+ ]
+}
diff --git a/conformance/scenarios/insert-only.json b/conformance/scenarios/insert-only.json
new file mode 100644
index 000000000..122a6bff0
--- /dev/null
+++ b/conformance/scenarios/insert-only.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "../schema/scenarios.schema.json",
+ "protocol_revision": 1,
+ "scenarios": [
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyInsertNotificationWakeup" }], "name": "insert_only_insert_notification", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/insert_only_test.go", "symbol": "verifyInsertOnlyInsert" }], "name": "insert_only_insert_reference_work", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/insert_only_test.go", "symbol": "verifyInsertOnlyHandshake" }], "name": "insert_only_profile_handshake", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/insert_only_test.go", "symbol": "verifyInsertOnlyTransactions" }], "name": "insert_only_transactional_insert", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/insert_only_test.go", "symbol": "verifyInsertOnlyBatch" }], "name": "insert_only_typed_batch", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/insert_only_test.go", "symbol": "verifyInsertOnlyUnique" }], "name": "insert_only_unique_insert", "tier": "codec" }
+ ]
+}
diff --git a/conformance/scenarios/sqlite-runtime.json b/conformance/scenarios/sqlite-runtime.json
new file mode 100644
index 000000000..7686a0242
--- /dev/null
+++ b/conformance/scenarios/sqlite-runtime.json
@@ -0,0 +1,42 @@
+{
+ "$schema": "../schema/scenarios.schema.json",
+ "protocol_revision": 1,
+ "scenarios": [
+ { "evidence": [{ "path": "conformance/harness/multi_engine_test.go", "symbol": "verifySQLiteCandidatePair" }], "name": "multi_engine_sqlite_candidate_pairs", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteAttemptedByHistory" }], "name": "sqlite_runtime_attempted_by_ordering", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyClaimOrder" }], "name": "sqlite_runtime_claim_order", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/interop_scenarios_test.go", "symbol": "verifyClaimTimeCancellation" }], "name": "sqlite_runtime_claim_time_cancellation", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteCompetingWorkers" }], "name": "sqlite_runtime_competing_workers", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/resilience_test.go", "symbol": "TestResilienceSQLiteConformance" }], "name": "sqlite_runtime_completion_under_writer_lock", "tier": "chaos" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteCrossLanguageWork" }], "name": "sqlite_runtime_cross_language_work", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/retry_test.go", "symbol": "verifyExhaustedJobRetry" }], "name": "sqlite_runtime_exhausted_job_retry", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteAdvancedRuntime" }], "name": "sqlite_runtime_extensions_resumable_subscriptions", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/runtime_scenarios_test.go", "symbol": "verifyExternalTerminalCompletionRace" }], "name": "sqlite_runtime_external_terminal_completion_race", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/resilience_test.go", "symbol": "TestResilienceSQLiteConformance" }], "name": "sqlite_runtime_go_integer_ranges", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/kinds_test.go", "symbol": "verifyHeterogeneousFleet" }], "name": "sqlite_runtime_heterogeneous_fleet_known_kinds", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/resilience_test.go", "symbol": "TestResilienceSQLiteConformance" }], "name": "sqlite_runtime_invalid_json_columns", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyJobCleanerQueueFilters" }], "name": "sqlite_runtime_job_cleaner_queue_filters", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyJobListCursorInterchange" }], "name": "sqlite_runtime_job_list_cursor_interchange", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/job_rows_test.go", "symbol": "verifySQLiteWorkedJobRows" }, { "path": "conformance/harness/job_rows_test.go", "symbol": "verifySQLiteRuntimeJobRows" }], "name": "sqlite_runtime_job_rows", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/kinds_test.go", "symbol": "verifyKindAliasRename" }], "name": "sqlite_runtime_kind_alias_rename", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyLeaderElectionDisabled" }], "name": "sqlite_runtime_leader_election_disabled", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteLeadershipFailover" }], "name": "sqlite_runtime_leadership_failover", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteLifecycle" }], "name": "sqlite_runtime_lifecycle_shutdown", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/interop_scenarios_test.go", "symbol": "verifyNotificationPayloads" }], "name": "sqlite_runtime_notification_payloads", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyPauseResumeNotification" }], "name": "sqlite_runtime_notification_wakeups", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLitePeriodicScheduler" }], "name": "sqlite_runtime_periodic_scheduler", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/interop_scenarios_test.go", "symbol": "verifyUniquePeriodicJob" }], "name": "sqlite_runtime_periodic_unique", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLitePollOnly" }], "name": "sqlite_runtime_poll_only_recovery", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifyProfileHandshakes" }], "name": "sqlite_runtime_profile_handshake", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteQueues" }], "name": "sqlite_runtime_queue_crud_reconfigure_pause", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyRemoteCancelNotification" }, { "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteCancelNotifications" }], "name": "sqlite_runtime_remote_cancellation", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyRemoteQueueSubscriptionEvents" }], "name": "sqlite_runtime_remote_queue_subscription_events", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/kinds_test.go", "symbol": "verifyRescuerUnknownKind" }], "name": "sqlite_runtime_rescuer_unknown_kind_discard", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/resumable_test.go", "symbol": "verifyResumableInteroperability" }], "name": "sqlite_runtime_resumable_cross_engine_cursor", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/resumable_test.go", "symbol": "verifyResumableValidation" }], "name": "sqlite_runtime_resumable_validation", "tier": "runtime" },
+ { "evidence": [{ "path": "conformance/harness/scheduler_test.go", "symbol": "verifySchedulerUniqueConflictDiscard" }], "name": "sqlite_runtime_scheduler_unique_conflict_discard", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteTransactionalNotification" }], "name": "sqlite_runtime_transactional_notification", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/unique_test.go", "symbol": "verifyUniqueSkipKeepsExistingKind" }], "name": "sqlite_runtime_unique_skip_keeps_existing_kind", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/coordination_scenarios_test.go", "symbol": "verifyUnknownKind" }], "name": "sqlite_runtime_unknown_kind_error", "tier": "mixed" }
+ ]
+}
diff --git a/conformance/scenarios/sqlite-storage.json b/conformance/scenarios/sqlite-storage.json
new file mode 100644
index 000000000..2301eec76
--- /dev/null
+++ b/conformance/scenarios/sqlite-storage.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "../schema/scenarios.schema.json",
+ "protocol_revision": 1,
+ "scenarios": [
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyBatchInsertion" }], "name": "sqlite_batch_atomicity", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyDeterministicControls" }, { "path": "conformance/harness/unique_test.go", "symbol": "verifyUniqueKeyGoldens" }], "name": "sqlite_deterministic_retry_unique", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteCrossLanguageInsertion" }], "name": "sqlite_insert_get_unique_cross_language", "tier": "mixed" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyDifferentialJobCRUD" }], "name": "sqlite_job_crud", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/job_rows_test.go", "symbol": "verifySQLiteJobRows" }], "name": "sqlite_job_rows", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteMigrations" }], "name": "sqlite_migration_cross_language", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifyProfileHandshakes" }], "name": "sqlite_profile_handshake", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteTimestampEncoding" }], "name": "sqlite_timestamp_rounding_ordering", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/sqlite_test.go", "symbol": "verifySQLiteTransactions" }], "name": "sqlite_transactions", "tier": "storage" },
+ { "evidence": [{ "path": "conformance/harness/interop_scenarios_test.go", "symbol": "verifyUniqueColumnBytes" }], "name": "sqlite_unique_column_bytes", "tier": "codec" },
+ { "evidence": [{ "path": "conformance/harness/storage_scenarios_test.go", "symbol": "verifyUnsafeInt64JobIDs" }], "name": "sqlite_unsafe_int64_job_ids_rpc_list_cursors", "tier": "codec" }
+ ]
+}
diff --git a/conformance/schema/adapter-contract.schema.json b/conformance/schema/adapter-contract.schema.json
new file mode 100644
index 000000000..9793d69fb
--- /dev/null
+++ b/conformance/schema/adapter-contract.schema.json
@@ -0,0 +1,54 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$defs": {
+ "json_schema": {
+ "description": "A JSON Schema using the subset the harness validator supports.",
+ "type": ["boolean", "object"]
+ }
+ },
+ "additionalProperties": false,
+ "properties": {
+ "$defs": {
+ "additionalProperties": { "$ref": "#/$defs/json_schema" },
+ "description": "Schemas shared by method params and results.",
+ "type": "object"
+ },
+ "$schema": { "type": "string" },
+ "adapter_version": { "minimum": 1, "type": "integer" },
+ "errors": {
+ "description": "Stable JSON-RPC error codes. Harness assertions use codes, never message text.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "code": { "type": "integer" },
+ "description": { "minLength": 1, "type": "string" },
+ "name": { "pattern": "^[a-z_]+$", "type": "string" }
+ },
+ "required": ["code", "description", "name"],
+ "type": "object"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "methods": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "capability": { "pattern": "^[a-z0-9_]+$", "type": "string" },
+ "description": { "minLength": 1, "type": "string" },
+ "name": { "pattern": "^[a-z0-9_]+$", "type": "string" },
+ "params": { "$ref": "#/$defs/json_schema" },
+ "result": { "$ref": "#/$defs/json_schema" }
+ },
+ "required": ["capability", "description", "name", "params", "result"],
+ "type": "object"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "protocol_revision": { "minimum": 1, "type": "integer" }
+ },
+ "required": ["$defs", "$schema", "adapter_version", "errors", "methods", "protocol_revision"],
+ "title": "River language-neutral conformance adapter contract",
+ "type": "object"
+}
diff --git a/conformance/schema/adapter-profile.schema.json b/conformance/schema/adapter-profile.schema.json
new file mode 100644
index 000000000..0ce821c35
--- /dev/null
+++ b/conformance/schema/adapter-profile.schema.json
@@ -0,0 +1,33 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+ "backend": { "enum": ["postgres", "sqlite"] },
+ "capabilities": {
+ "items": { "pattern": "^[a-z0-9_]+$", "type": "string" },
+ "minItems": 1,
+ "type": "array"
+ },
+ "description": { "minLength": 1, "type": "string" },
+ "extends": { "pattern": "^[a-z0-9_-]+$", "type": "string" },
+ "methods": {
+ "items": { "pattern": "^[a-z0-9_]+$", "type": "string" },
+ "minItems": 1,
+ "type": "array"
+ },
+ "name": { "pattern": "^[a-z0-9_-]+$", "type": "string" },
+ "protocol_revision": { "minimum": 1, "type": "integer" }
+ },
+ "required": [
+ "$schema",
+ "backend",
+ "capabilities",
+ "description",
+ "methods",
+ "name",
+ "protocol_revision"
+ ],
+ "title": "River conformance adapter backend profile",
+ "type": "object"
+}
diff --git a/conformance/schema/candidate.schema.json b/conformance/schema/candidate.schema.json
new file mode 100644
index 000000000..e587b620f
--- /dev/null
+++ b/conformance/schema/candidate.schema.json
@@ -0,0 +1,97 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$defs": {
+ "command": {
+ "description": "Program and arguments, run from the River repository root. Arguments may reference environment variables as ${NAME} or ${NAME:-default}.",
+ "items": { "minLength": 1, "type": "string" },
+ "minItems": 1,
+ "type": "array"
+ },
+ "performance_bound": {
+ "additionalProperties": false,
+ "properties": {
+ "max_p95_ratio": {
+ "description": "Largest allowed candidate p95 latency as a multiple of the reference p95.",
+ "exclusiveMinimum": 0,
+ "type": "number"
+ },
+ "min_throughput_ratio": {
+ "description": "Smallest allowed candidate throughput as a fraction of the reference throughput.",
+ "exclusiveMinimum": 0,
+ "type": "number"
+ }
+ },
+ "required": ["max_p95_ratio", "min_throughput_ratio"],
+ "type": "object"
+ }
+ },
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+ "application_name": {
+ "description": "PostgreSQL application_name of the candidate's connections, and the base of the per-process name the harness passes in RIVER_CONFORMANCE_APPLICATION_NAME. The river-conformance- prefix lets fault injection target conformance adapters only.",
+ "minLength": 1,
+ "pattern": "^river-conformance-[a-zA-Z0-9._-]+$",
+ "type": "string"
+ },
+ "build_command": {
+ "$ref": "#/$defs/command",
+ "description": "Built once per test process before command or restart_command runs."
+ },
+ "command": {
+ "$ref": "#/$defs/command",
+ "description": "Starts one adapter process."
+ },
+ "implementation": {
+ "pattern": "^[a-z][a-z0-9_-]*$",
+ "type": "string"
+ },
+ "performance": {
+ "additionalProperties": false,
+ "description": "Release performance bounds relative to the reference per benchmark mode. Modes that are omitted use the harness defaults (enqueue 2.0x p95 and 40% throughput; worker and mixed 1.25x p95 and 80% throughput).",
+ "properties": {
+ "enqueue": { "$ref": "#/$defs/performance_bound" },
+ "mixed": { "$ref": "#/$defs/performance_bound" },
+ "worker": { "$ref": "#/$defs/performance_bound" }
+ },
+ "type": "object"
+ },
+ "profiles": {
+ "description": "Conformance profiles the adapter serves. Omitted means portable-storage-v1, postgres-full-v1, and sqlite-runtime-v1.",
+ "items": {
+ "enum": ["insert-only-v1", "portable-storage-v1", "postgres-full-v1", "sqlite-runtime-v1"]
+ },
+ "minItems": 1,
+ "type": "array",
+ "uniqueItems": true
+ },
+ "release_build_command": {
+ "$ref": "#/$defs/command",
+ "description": "Replaces build_command for performance tiers."
+ },
+ "release_command": {
+ "$ref": "#/$defs/command",
+ "description": "Replaces command and restart_command for performance tiers."
+ },
+ "restart_command": {
+ "$ref": "#/$defs/command",
+ "description": "Starts a prebuilt adapter process that chaos scenarios may kill. Defaults to command."
+ },
+ "start_options": {
+ "description": "Optional start tuning parameters the adapter honors. The harness sends them only to adapters that declare them.",
+ "items": {
+ "enum": ["elect_interval_ms", "rescuer_interval_ms", "scheduler_interval_ms"]
+ },
+ "type": "array",
+ "uniqueItems": true
+ },
+ "version": {
+ "description": "When present, the handshake implementation_version must equal it.",
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": ["application_name", "command", "implementation"],
+ "title": "River conformance candidate descriptor",
+ "type": "object"
+}
diff --git a/conformance/schema/feature-inventory.schema.json b/conformance/schema/feature-inventory.schema.json
new file mode 100644
index 000000000..df7b60677
--- /dev/null
+++ b/conformance/schema/feature-inventory.schema.json
@@ -0,0 +1,50 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+ "items": {
+ "items": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": { "properties": { "applicability": { "const": "protocol_visible" } } },
+ "then": { "anyOf": [{ "required": ["scenarios"] }, { "required": ["gap"] }] }
+ },
+ {
+ "if": { "properties": { "applicability": { "enum": ["api_equivalent", "driver_specific", "internal", "not_applicable"] } } },
+ "then": { "required": ["rationale"] }
+ }
+ ],
+ "properties": {
+ "applicability": {
+ "enum": ["api_equivalent", "driver_specific", "internal", "not_applicable", "protocol_visible", "unclassified"]
+ },
+ "area": { "pattern": "^[a-z0-9_]+$", "type": "string" },
+ "detail": { "minLength": 1, "type": "string" },
+ "gap": {
+ "description": "Why a protocol-visible item has no shared scenario yet.",
+ "minLength": 1,
+ "type": "string"
+ },
+ "id": { "minLength": 1, "pattern": "^[a-z0-9_]+\\.\\S+$", "type": "string" },
+ "rationale": { "minLength": 1, "type": "string" },
+ "scenarios": {
+ "items": { "pattern": "^[a-z0-9_]+$", "type": "string" },
+ "minItems": 1,
+ "type": "array",
+ "uniqueItems": true
+ },
+ "source": { "minLength": 1, "type": "string" }
+ },
+ "required": ["applicability", "area", "detail", "id", "source"],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "protocol_revision": { "minimum": 1, "type": "integer" }
+ },
+ "required": ["$schema", "items", "protocol_revision"],
+ "title": "River cross-language feature inventory",
+ "type": "object"
+}
diff --git a/conformance/schema/jsonrpc-request.schema.json b/conformance/schema/jsonrpc-request.schema.json
new file mode 100644
index 000000000..52e1c69db
--- /dev/null
+++ b/conformance/schema/jsonrpc-request.schema.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "id": { "type": ["integer", "string"] },
+ "jsonrpc": { "const": "2.0" },
+ "method": { "minLength": 1, "type": "string" },
+ "params": { "type": "object" }
+ },
+ "required": ["id", "jsonrpc", "method"],
+ "title": "River conformance JSON-RPC request",
+ "type": "object"
+}
diff --git a/conformance/schema/maintenance-values.schema.json b/conformance/schema/maintenance-values.schema.json
new file mode 100644
index 000000000..eb015aca2
--- /dev/null
+++ b/conformance/schema/maintenance-values.schema.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+ "cron_cases": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "expression": { "type": "string" },
+ "from": { "format": "date-time", "type": "string" },
+ "name": { "type": "string" },
+ "next": { "items": { "format": "date-time", "type": "string" }, "type": "array" }
+ },
+ "required": ["expression", "from", "name", "next"],
+ "type": "object"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "cron_invalid": { "items": { "type": "string" }, "minItems": 1, "type": "array" },
+ "cron_named_zone_cases": {
+ "description": "Cron cases whose CRON_TZ or TZ prefix names an IANA zone. Implementations without a time zone database may reject these expressions instead.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "expression": { "type": "string" },
+ "from": { "format": "date-time", "type": "string" },
+ "name": { "type": "string" },
+ "next": { "items": { "format": "date-time", "type": "string" }, "type": "array" }
+ },
+ "required": ["expression", "from", "name", "next"],
+ "type": "object"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "protocol_revision": { "minimum": 1, "type": "integer" },
+ "snooze_counters": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "expected_snoozes": { "type": "integer" },
+ "metadata": { "type": "object" },
+ "name": { "type": "string" }
+ },
+ "required": ["expected_snoozes", "metadata", "name"],
+ "type": "object"
+ },
+ "minItems": 1,
+ "type": "array"
+ }
+ },
+ "required": ["$schema", "cron_cases", "cron_invalid", "cron_named_zone_cases", "protocol_revision", "snooze_counters"],
+ "title": "River maintenance protocol values",
+ "type": "object"
+}
diff --git a/conformance/schema/normalized-job.schema.json b/conformance/schema/normalized-job.schema.json
new file mode 100644
index 000000000..28e9dad33
--- /dev/null
+++ b/conformance/schema/normalized-job.schema.json
@@ -0,0 +1,54 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "args": {},
+ "attempt": { "minimum": 0, "type": "integer" },
+ "attempted_at": { "format": "date-time", "type": ["string", "null"] },
+ "attempted_by": { "items": { "type": "string" }, "type": "array" },
+ "created_at": { "format": "date-time", "type": "string" },
+ "errors": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "at": { "format": "date-time", "type": "string" },
+ "attempt": { "minimum": 0, "type": "integer" },
+ "error": { "type": "string" },
+ "trace": { "type": "string" }
+ },
+ "required": ["at", "attempt", "error", "trace"],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "finalized_at": { "format": "date-time", "type": ["string", "null"] },
+ "id": { "minimum": 1, "type": "integer" },
+ "kind": { "type": "string" },
+ "max_attempts": { "minimum": 1, "type": "integer" },
+ "metadata": {
+ "description": "Null when metadata contains numbers outside the normalized JSON decoder's range; use raw_job_exact_json for exact tokens.",
+ "type": ["object", "null"]
+ },
+ "priority": { "maximum": 4, "minimum": 1, "type": "integer" },
+ "queue": { "type": "string" },
+ "scheduled_at": { "format": "date-time", "type": "string" },
+ "state": {
+ "enum": ["available", "cancelled", "completed", "discarded", "pending", "retryable", "running", "scheduled"]
+ },
+ "tags": { "items": { "type": "string" }, "type": "array" },
+ "unique_key": { "pattern": "^[0-9a-f]{64}$", "type": ["string", "null"] },
+ "unique_states": {
+ "items": {
+ "enum": ["available", "cancelled", "completed", "discarded", "pending", "retryable", "running", "scheduled"]
+ },
+ "type": ["array", "null"]
+ }
+ },
+ "required": [
+ "args", "attempt", "attempted_at", "attempted_by", "created_at", "errors",
+ "finalized_at", "id", "kind", "max_attempts", "metadata", "priority",
+ "queue", "scheduled_at", "state", "tags", "unique_key", "unique_states"
+ ],
+ "title": "Normalized River job",
+ "type": "object"
+}
diff --git a/conformance/schema/normalized-queue.schema.json b/conformance/schema/normalized-queue.schema.json
new file mode 100644
index 000000000..1f80519c8
--- /dev/null
+++ b/conformance/schema/normalized-queue.schema.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "created_at": { "format": "date-time", "type": "string" },
+ "metadata": { "type": "object" },
+ "name": { "type": "string" },
+ "paused_at": { "format": "date-time", "type": ["string", "null"] },
+ "updated_at": { "format": "date-time", "type": "string" }
+ },
+ "required": ["created_at", "metadata", "name", "paused_at", "updated_at"],
+ "title": "Normalized River queue",
+ "type": "object"
+}
diff --git a/conformance/schema/protocol-values.schema.json b/conformance/schema/protocol-values.schema.json
new file mode 100644
index 000000000..adfa88b1f
--- /dev/null
+++ b/conformance/schema/protocol-values.schema.json
@@ -0,0 +1,96 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+ "attempt_error": {
+ "additionalProperties": false,
+ "properties": {
+ "at": { "format": "date-time", "type": "string" },
+ "attempt": { "minimum": 0, "type": "integer" },
+ "error": { "type": "string" },
+ "trace": { "type": "string" }
+ },
+ "required": ["at", "attempt", "error", "trace"],
+ "type": "object"
+ },
+ "job_states": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "state": { "enum": ["available", "cancelled", "completed", "discarded", "pending", "retryable", "running", "scheduled"] },
+ "unique_bit": { "maximum": 128, "minimum": 1, "type": "integer" }
+ },
+ "required": ["state", "unique_bit"],
+ "type": "object"
+ },
+ "minItems": 8,
+ "type": "array"
+ },
+ "metadata_keys": { "additionalProperties": { "type": "string" }, "type": "object" },
+ "notifications": {
+ "description": "Payloads derived from the Go payload structs and action constants, in struct field order. Keys of payloads built in SQL are checked against the same structs.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "fields": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "name": { "minLength": 1, "type": "string" },
+ "omitempty": { "type": "boolean" }
+ },
+ "required": ["name", "omitempty"],
+ "type": "object"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "name": { "type": "string" },
+ "payload": { "type": "object" },
+ "source": { "minLength": 1, "type": "string" },
+ "topic": { "type": "string" }
+ },
+ "required": ["fields", "name", "payload", "source", "topic"],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "protocol_revision": { "minimum": 1, "type": "integer" },
+ "reserved_metadata_keys": {
+ "description": "Job metadata keys River reads or writes, extracted from Go source and SQL through the feature inventory.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "applicability": { "enum": ["api_equivalent", "driver_specific", "internal", "not_applicable", "protocol_visible"] },
+ "key": { "minLength": 1, "type": "string" }
+ },
+ "required": ["applicability", "key"],
+ "type": "object"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "retry_cases": {
+ "description": "Bounds on the delay River's default retry policy schedules after error_count failures. Implementations with seedable jitter may use seed; any delay within the bounds conforms.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "error_count": { "minimum": 1, "type": "integer" },
+ "job_id": { "minimum": 1, "type": "integer" },
+ "max_delay_ns": { "minimum": 0, "type": "integer" },
+ "min_delay_ns": { "minimum": 0, "type": "integer" },
+ "now": { "format": "date-time", "type": "string" },
+ "seed": { "minimum": 0, "type": "integer" }
+ },
+ "required": ["error_count", "job_id", "max_delay_ns", "min_delay_ns", "now", "seed"],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "topics": { "additionalProperties": { "type": "string" }, "type": "object" }
+ },
+ "required": ["$schema", "attempt_error", "job_states", "metadata_keys", "notifications", "protocol_revision", "reserved_metadata_keys", "retry_cases", "topics"],
+ "title": "River protocol value goldens",
+ "type": "object"
+}
diff --git a/conformance/schema/protocol.schema.json b/conformance/schema/protocol.schema.json
new file mode 100644
index 000000000..24fee3ffc
--- /dev/null
+++ b/conformance/schema/protocol.schema.json
@@ -0,0 +1,51 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+ "capabilities": {
+ "additionalProperties": {
+ "enum": ["complete", "in_progress", "not_applicable", "planned"]
+ },
+ "description": "Protocol capabilities. postgres-full-v1 adapters advertise exactly the complete ones.",
+ "type": "object"
+ },
+ "capability_decisions": {
+ "additionalProperties": { "minLength": 1, "type": "string" },
+ "description": "Why each capability that is not complete is planned, in progress, or not applicable.",
+ "type": "object"
+ },
+ "implementations": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "properties": {
+ "package": { "minLength": 1, "type": "string" },
+ "registry": {
+ "minLength": 1,
+ "pattern": "^[a-z][a-z0-9._-]*$",
+ "type": "string"
+ },
+ "version": { "minLength": 1, "type": "string" }
+ },
+ "required": ["package", "registry", "version"],
+ "type": "object"
+ },
+ "minProperties": 1,
+ "propertyNames": { "pattern": "^[a-z][a-z0-9_-]*$" },
+ "type": "object"
+ },
+ "migration": {
+ "additionalProperties": false,
+ "properties": {
+ "latest": { "minimum": 1, "type": "integer" },
+ "line": { "minLength": 1, "type": "string" }
+ },
+ "required": ["latest", "line"],
+ "type": "object"
+ },
+ "protocol_revision": { "minimum": 1, "type": "integer" }
+ },
+ "required": ["capabilities", "implementations", "migration", "protocol_revision"],
+ "title": "River protocol compatibility manifest",
+ "type": "object"
+}
diff --git a/conformance/schema/scenarios.schema.json b/conformance/schema/scenarios.schema.json
new file mode 100644
index 000000000..0d031c94c
--- /dev/null
+++ b/conformance/schema/scenarios.schema.json
@@ -0,0 +1,36 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+ "protocol_revision": { "minimum": 1, "type": "integer" },
+ "scenarios": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "evidence": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "path": { "pattern": "^[a-zA-Z0-9_./-]+$", "type": "string" },
+ "symbol": { "minLength": 1, "type": "string" }
+ },
+ "required": ["path", "symbol"],
+ "type": "object"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "name": { "pattern": "^[a-z0-9_]+$", "type": "string" },
+ "tier": { "enum": ["chaos", "codec", "mixed", "performance", "runtime", "storage"] }
+ },
+ "required": ["evidence", "name", "tier"],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": ["$schema", "protocol_revision", "scenarios"],
+ "title": "River conformance scenario inventory",
+ "type": "object"
+}
diff --git a/conformance/schema/unique-keys.schema.json b/conformance/schema/unique-keys.schema.json
new file mode 100644
index 000000000..d5137bc53
--- /dev/null
+++ b/conformance/schema/unique-keys.schema.json
@@ -0,0 +1,80 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+ "cases": {
+ "description": "Goldens every implementation's adapter must reproduce.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "args": {},
+ "expected_error": {
+ "description": "Contract error the adapter must report instead of a key; set only when expected_sha256 is absent.",
+ "enum": ["rejected"]
+ },
+ "expected_sha256": { "pattern": "^[0-9a-f]{64}$", "type": "string" },
+ "expected_state_mask": { "maximum": 255, "minimum": 0, "type": "integer" },
+ "kind": { "minLength": 1, "type": "string" },
+ "name": { "minLength": 1, "type": "string" },
+ "now": { "format": "date-time", "type": "string" },
+ "options": {
+ "additionalProperties": false,
+ "properties": {
+ "by_args": { "type": "boolean" },
+ "by_period_nanos": { "minimum": 0, "type": "integer" },
+ "by_queue": { "type": "boolean" },
+ "by_state": {
+ "items": {
+ "enum": ["available", "cancelled", "completed", "discarded", "pending", "retryable", "running", "scheduled"]
+ },
+ "type": "array"
+ },
+ "exclude_kind": { "type": "boolean" }
+ },
+ "required": ["by_args", "by_period_nanos", "by_queue", "exclude_kind"],
+ "type": "object"
+ },
+ "queue": { "minLength": 1, "type": "string" },
+ "scheduled_at": {
+ "oneOf": [
+ { "format": "date-time", "type": "string" },
+ { "type": "null" }
+ ]
+ },
+ "selected_unique_components": {
+ "items": {
+ "items": { "type": "string" },
+ "minItems": 1,
+ "type": "array"
+ },
+ "type": "array"
+ },
+ "selected_unique_paths": {
+ "oneOf": [
+ { "items": { "minLength": 1, "type": "string" }, "type": "array" },
+ { "type": "null" }
+ ]
+ }
+ },
+ "oneOf": [
+ { "required": ["expected_sha256"] },
+ { "required": ["expected_error"] }
+ ],
+ "required": ["args", "expected_state_mask", "kind", "name", "now", "options", "queue", "scheduled_at", "selected_unique_paths"],
+ "type": "object"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "protocol_revision": { "minimum": 1, "type": "integer" },
+ "typed_only_cases": {
+ "description": "Goldens a producer built on dynamic objects can't reproduce, such as duplicate keys or a map with integer-like keys, which JavaScript objects enumerate first in ascending numeric order. Implementations with typed serializers assert them in their own tests; the shared adapter scenario uses only `cases`.",
+ "items": { "$ref": "#/properties/cases/items" },
+ "type": "array"
+ }
+ },
+ "required": ["$schema", "cases", "protocol_revision", "typed_only_cases"],
+ "title": "River unique-key compatibility goldens",
+ "type": "object"
+}
diff --git a/go.work b/go.work
index 8f7b945da..111c3368c 100644
--- a/go.work
+++ b/go.work
@@ -5,6 +5,7 @@ toolchain go1.26.6
use (
.
./cmd/river
+ ./internal/cmd/riverconformanceadapter
./riverdriver
./riverdriver/riverdatabasesql
./riverdriver/riverdrivertest
diff --git a/internal/cmd/generateconformance/main.go b/internal/cmd/generateconformance/main.go
new file mode 100644
index 000000000..8f46d04ab
--- /dev/null
+++ b/internal/cmd/generateconformance/main.go
@@ -0,0 +1,978 @@
+// Command generateconformance generates language-neutral protocol fixtures
+// from River's Go reference implementation.
+package main
+
+import (
+ "bytes"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "maps"
+ "math"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/riverqueue/river/internal/dbunique"
+ "github.com/riverqueue/river/internal/leadership"
+ "github.com/riverqueue/river/internal/notifier"
+ "github.com/riverqueue/river/internal/retrypolicy"
+ "github.com/riverqueue/river/internal/rivercommon"
+ "github.com/riverqueue/river/riverdriver"
+ "github.com/riverqueue/river/rivershared/uniquestates"
+ "github.com/riverqueue/river/rivertype"
+)
+
+const (
+ featureInventoryPath = "conformance/feature-inventory.json"
+ protocolFixturePath = "conformance/fixtures/protocol_values.json"
+ uniqueFixturePath = "conformance/fixtures/unique_keys.json"
+
+ // rustFixtureDir holds copies of the fixtures inside the publishable
+ // Rust crate, whose tests can't read files outside its package.
+ rustFixtureDir = "rust/riverqueue/tests/fixtures"
+)
+
+// errorNameRejected is the adapter contract error for a request River
+// rejects, such as all-args uniqueness over non-object arguments.
+const errorNameRejected = "rejected"
+
+type allArgs struct {
+ Zeta string `json:"zeta"`
+ Alpha string `json:"alpha"`
+ Maximum int64 `json:"maximum"`
+}
+
+func (allArgs) Kind() string { return "conformance_all_args" }
+
+type mapOrderArgs struct{}
+
+func (mapOrderArgs) Kind() string { return "conformance_all_args" }
+
+func (mapOrderArgs) MarshalJSON() ([]byte, error) { //nolint:unparam // json.Marshaler requires an error result.
+ return []byte(`{"2":2,"10":10,"zero":-0,"😀":1,"":2}`), nil
+}
+
+// rawAllArgs keeps duplicate members and unusual top-level names intact for
+// Go's all-arguments unique-key oracle.
+type rawAllArgs struct{ text string }
+
+func (rawAllArgs) Kind() string { return "conformance_all_args" }
+
+func (args rawAllArgs) MarshalJSON() ([]byte, error) { //nolint:unparam // json.Marshaler requires an error result.
+ return []byte(args.text), nil
+}
+
+type nestedOrderArgs struct {
+ Nested struct {
+ // Deliberately non-alphabetical: nested struct wire order is significant.
+ Z int `json:"z"`
+ A int `json:"a"`
+ } `json:"nested"`
+}
+
+func (nestedOrderArgs) Kind() string { return "conformance_all_args" }
+
+type numericBoundaryArgs struct {
+ Exponent float64 `json:"exponent"`
+ Fraction float64 `json:"fraction"`
+ Maximum int64 `json:"maximum"`
+ Minimum int64 `json:"minimum"`
+ UnsignedMaximum uint64 `json:"unsigned_maximum"`
+}
+
+func (numericBoundaryArgs) Kind() string { return "conformance_numeric_boundaries" }
+
+type selectedAccount struct {
+ ID string `json:"id,omitempty" river:"unique"`
+ Ignored string `json:"ignored,omitempty"`
+ Region string `json:"region,omitempty" river:"unique"`
+}
+
+type selectedArgs struct {
+ Account selectedAccount `json:"account,omitzero"`
+ Ignored bool `json:"ignored,omitempty"`
+ Label string `json:"label,omitempty" river:"unique"`
+ PathKey string `json:"path/key,omitempty" river:"unique"`
+}
+
+func (selectedArgs) Kind() string { return "conformance_selected_args" }
+
+type dottedSelectedUser struct {
+ ID string `json:"id,omitempty" river:"unique"`
+}
+
+type dottedSelectedArgs struct {
+ At string `json:"@user,omitempty" river:"unique"`
+ Bang string `json:"!x,omitempty" river:"unique"`
+ Brace string `json:"{x},omitempty" river:"unique"`
+ Bracket string `json:"[x],omitempty" river:"unique"`
+ Colon string `json:":id,omitempty" river:"unique"`
+ //nolint:tagliatelle // literal dotted names distinguish them from nested paths
+ Literal string `json:"user.id,omitempty" river:"unique"`
+ Symbols string `json:"a*b?c#d|e,omitempty" river:"unique"`
+ User dottedSelectedUser `json:"user"`
+ Unicode string `json:"é,omitempty" river:"unique"`
+}
+
+func (dottedSelectedArgs) Kind() string { return "conformance_dotted_selected_args" }
+
+type simpleArgs struct {
+ ID int64 `json:"id"`
+}
+
+func (simpleArgs) Kind() string { return "conformance_simple" }
+
+// collectionsArgs exercises nested values, arrays, and nulls whose wire
+// order and representation are preserved in hashed arguments.
+type collectionsArgs struct {
+ Empty []string `json:"empty"`
+ Labels map[string]string `json:"labels"`
+ Matrix [][]int `json:"matrix"`
+ Missing []string `json:"missing"`
+ Objects []collectionsItem `json:"objects"`
+ Pointer *string `json:"pointer"`
+}
+
+type collectionsItem struct {
+ // Deliberately non-alphabetical: nested struct wire order is significant.
+ Zulu string `json:"zulu"`
+ Alpha *int `json:"alpha"`
+}
+
+func (collectionsArgs) Kind() string { return "conformance_all_args" }
+
+type emptyArgs struct{}
+
+func (emptyArgs) Kind() string { return "conformance_all_args" }
+
+// escapingArgs exercises encoding/json string and key escaping, including
+// keys that gjson reports unescaped and sjson rewrites while hashing.
+type escapingArgs struct {
+ Angle string `json:"a"`
+ Controls string `json:"controls"`
+ HTML string `json:"html"`
+ Keys map[string]int `json:"keys"`
+ Separators string `json:"separators"`
+ Unicode string `json:"unicode"`
+ UnicodeAmp string `json:"é&"`
+}
+
+func (escapingArgs) Kind() string { return "conformance_all_args" }
+
+// selectedNullArgs selects an explicitly null field, which is retained in the
+// hashed arguments, while omitted selected fields are skipped.
+type selectedNullArgs struct {
+ Account selectedAccount `json:"account,omitzero"`
+ Label *string `json:"label" river:"unique"`
+ PathKey string `json:"path/key,omitempty" river:"unique"`
+}
+
+func (selectedNullArgs) Kind() string { return "conformance_selected_args" }
+
+// timeArgs exercises encoding/json time formatting, which trims fractional
+// seconds to their shortest form.
+type timeArgs struct {
+ Fraction time.Time `json:"fraction"`
+ Micros time.Time `json:"micros"`
+ Millis time.Time `json:"millis"`
+ Whole time.Time `json:"whole"`
+}
+
+func (timeArgs) Kind() string { return "conformance_all_args" }
+
+// typedFloatArgs exercises encoding/json float formatting: 'f' notation
+// between 1e-6 and 1e21, exponent notation outside it, and shortest
+// round-trip digits for both 64- and 32-bit floats.
+type typedFloatArgs struct {
+ BelowLarge float64 `json:"below_large"`
+ Large float64 `json:"large"`
+ LargeBoundary float64 `json:"large_boundary"`
+ Largest float64 `json:"largest"`
+ Negative float64 `json:"negative"`
+ NegativeZero float64 `json:"negative_zero"`
+ One float64 `json:"one"`
+ Single float32 `json:"single"`
+ SingleLarge float32 `json:"single_large"`
+ SingleSmall float32 `json:"single_small"`
+ Small float64 `json:"small"`
+ SmallBoundary float64 `json:"small_boundary"`
+ Smallest float64 `json:"smallest"`
+ Tenth float64 `json:"tenth"`
+}
+
+func (typedFloatArgs) Kind() string { return "conformance_all_args" }
+
+type fixture struct {
+ Schema string `json:"$schema"`
+ Cases []fixtureCase `json:"cases"`
+ ProtocolRevision int `json:"protocol_revision"`
+
+ // TypedOnlyCases are goldens for typed arguments whose encoded byte
+ // order a producer built on dynamic objects can't reproduce, such as a
+ // map with integer-like keys, which JavaScript objects enumerate first in
+ // ascending numeric order. Implementations with typed serializers assert
+ // them in their own tests; the shared adapter scenario uses only Cases.
+ TypedOnlyCases []fixtureCase `json:"typed_only_cases"`
+}
+
+type fixtureCase struct {
+ Args json.RawMessage `json:"args"`
+ // ExpectedError is the contract error name an implementation must report
+ // instead of a key, as Go does for all-args uniqueness over arguments
+ // that don't encode a JSON object. ExpectedSHA256 is empty when it's set.
+ ExpectedError string `json:"expected_error,omitempty"`
+ ExpectedSHA256 string `json:"expected_sha256,omitempty"`
+ ExpectedStateMask byte `json:"expected_state_mask"`
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ Now time.Time `json:"now"`
+ Options fixtureOptions `json:"options"`
+ Queue string `json:"queue"`
+ ScheduledAt *time.Time `json:"scheduled_at"`
+ SelectedUniqueComponents [][]string `json:"selected_unique_components,omitempty"`
+ SelectedUniquePath []string `json:"selected_unique_paths"`
+}
+
+type fixtureOptions struct {
+ ByArgs bool `json:"by_args"`
+ ByPeriodNanos int64 `json:"by_period_nanos"`
+ ByQueue bool `json:"by_queue"`
+ ByState []rivertype.JobState `json:"by_state,omitempty"`
+ ExcludeKind bool `json:"exclude_kind"`
+}
+
+type referenceCase struct {
+ args rivertype.JobArgs
+ expectedError string
+ name string
+ now time.Time
+ opts dbunique.UniqueOpts
+ queue string
+ scheduledAt *time.Time
+ selectedUniquePaths []string
+ typedOnly bool
+}
+
+type staticClock struct{ now time.Time }
+
+func (clock staticClock) Now() time.Time { return clock.now }
+func (staticClock) NowOrNil() *time.Time { return nil }
+
+type protocolFixture struct {
+ Schema string `json:"$schema"`
+ AttemptError rivertype.AttemptError `json:"attempt_error"`
+ JobStates []protocolState `json:"job_states"`
+ MetadataKeys map[string]string `json:"metadata_keys"`
+ Notifications []protocolNotification `json:"notifications"`
+ ProtocolRevision int `json:"protocol_revision"`
+ ReservedMetadataKeys []reservedMetadataKey `json:"reserved_metadata_keys"`
+ RetryCases []protocolRetryCase `json:"retry_cases"`
+ Topics map[string]notifier.NotificationTopic `json:"topics"`
+}
+
+type protocolNotification struct {
+ Fields []jsonField `json:"fields"`
+ Name string `json:"name"`
+ Payload json.RawMessage `json:"payload"`
+ Source string `json:"source"`
+ Topic string `json:"topic"`
+}
+
+// protocolRetryCase bounds the delay River's default retry policy schedules
+// after error_count failures, from internal/retrypolicy.DelayBounds.
+// Implementations with seedable jitter may use seed; the bounds hold for any.
+type protocolRetryCase struct {
+ ErrorCount uint32 `json:"error_count"`
+ JobID int64 `json:"job_id"`
+ MaxDelayNS int64 `json:"max_delay_ns"`
+ MinDelayNS int64 `json:"min_delay_ns"`
+ Now time.Time `json:"now"`
+ Seed uint64 `json:"seed"`
+}
+
+// reservedMetadataKey is a job metadata key River itself reads or writes,
+// as extracted from Go source and SQL into the feature inventory.
+type reservedMetadataKey struct {
+ Applicability string `json:"applicability"`
+ Key string `json:"key"`
+}
+
+type protocolState struct {
+ State rivertype.JobState `json:"state"`
+ Bit byte `json:"unique_bit"`
+}
+
+func main() {
+ check := flag.Bool("check", false, "check generated fixtures without writing")
+ flag.Parse()
+
+ now := time.Date(2026, time.January, 2, 3, 4, 5, 678_900_000, time.UTC)
+ scheduledAt := now.Add(2*time.Hour + 17*time.Minute)
+ validCustomStates := []rivertype.JobState{
+ rivertype.JobStateAvailable,
+ rivertype.JobStateCompleted,
+ rivertype.JobStatePending,
+ rivertype.JobStateRunning,
+ rivertype.JobStateScheduled,
+ }
+ dottedSelectedPaths := []string{`\@user`, `\!x`, `\{x\}`, `\[x\]`, `\:id`, "user.id", `user\.id`, `a\*b\?c\#d\|e`, "é"}
+ references := []referenceCase{
+ {
+ args: selectedArgs{},
+ name: "all_selected_fields_omitted",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true},
+ queue: "default",
+ selectedUniquePaths: []string{"account.id", "account.region", "label", "path/key"},
+ },
+ {
+ args: selectedArgs{Account: selectedAccount{ID: "acct", Ignored: "irrelevant", Region: "west"}, PathKey: "slash"},
+ name: "selected_siblings_and_slash_key",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true},
+ queue: "default",
+ selectedUniquePaths: []string{"account.id", "account.region", "label", "path/key"},
+ },
+ {
+ args: nestedOrderArgs{Nested: struct {
+ Z int `json:"z"`
+ A int `json:"a"`
+ }{Z: 1, A: 2}},
+ name: "nested_struct_wire_order",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true},
+ queue: "default",
+ },
+ {
+ args: allArgs{
+ Alpha: "&\u2028line",
+ Maximum: 9_007_199_254_740_991,
+ Zeta: "quoted \\\"value\\\" and \\\\ slash",
+ },
+ name: "all_args_sorted_and_escaped",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true},
+ queue: "default",
+ },
+ {
+ args: mapOrderArgs{},
+ name: "map_order_and_negative_zero",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true},
+ queue: "default",
+ },
+ {
+ args: rawAllArgs{`{"":0,"a.b":1,"@x":2,":lead":3,"!bang":4,"[open":5,"{brace":6,"a\\b":7}`},
+ name: "all_args_literal_path_syntax",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true},
+ queue: "default",
+ },
+ {
+ args: rawAllArgs{`{"a\"b":1,"line\n":2,"é":3,"a",
+ Controls: "\b\f\n\r\t\x00\x01\x1f\x7f",
+ HTML: `&`,
+ Keys: map[string]int{"": 1, "a&b": 2, "é": 3, "é<": 4},
+ Separators: "line\u2028paragraph\u2029end",
+ Unicode: "é😀/\\",
+ UnicodeAmp: "unicode key",
+ },
+ name: "typed_escaping",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true},
+ queue: "default",
+ },
+ {
+ args: selectedNullArgs{},
+ name: "selected_explicit_null",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true},
+ queue: "default",
+ selectedUniquePaths: []string{"account.id", "account.region", "label", "path/key"},
+ },
+ {
+ args: timeArgs{
+ Fraction: time.Date(2026, time.January, 2, 3, 4, 5, 500_000_000, time.UTC),
+ Micros: time.Date(2026, time.January, 2, 3, 4, 5, 123_456_000, time.UTC),
+ Millis: time.Date(2026, time.January, 2, 3, 4, 5, 120_000_000, time.UTC),
+ Whole: time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC),
+ },
+ name: "typed_time_values",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true},
+ queue: "default",
+ },
+ {
+ args: typedFloatArgs{
+ BelowLarge: math.Nextafter(1e21, 0),
+ Large: 1e20,
+ LargeBoundary: 1e21,
+ Largest: math.MaxFloat64,
+ Negative: -1.5e-9,
+ NegativeZero: math.Copysign(0, -1),
+ One: 1,
+ Single: 1.1,
+ SingleLarge: 1e21,
+ SingleSmall: 1e-7,
+ Small: 1e-7,
+ SmallBoundary: 1e-6,
+ Smallest: math.SmallestNonzeroFloat64,
+ Tenth: 0.1,
+ },
+ name: "typed_float_formatting",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true},
+ queue: "default",
+ },
+ {
+ args: simpleArgs{ID: 42},
+ name: "period_from_now",
+ now: now,
+ opts: dbunique.UniqueOpts{ByPeriod: 90 * time.Minute},
+ queue: "default",
+ },
+ {
+ args: simpleArgs{ID: 42},
+ name: "period_from_schedule",
+ now: now,
+ opts: dbunique.UniqueOpts{ByPeriod: time.Hour},
+ queue: "default",
+ scheduledAt: &scheduledAt,
+ },
+ {
+ // A process clock outside UTC must produce the same period as UTC.
+ args: simpleArgs{ID: 42},
+ name: "period_from_non_utc_now",
+ now: now.In(time.FixedZone("UTC-5", -5*60*60)),
+ opts: dbunique.UniqueOpts{ByPeriod: time.Hour},
+ queue: "default",
+ },
+ {
+ // A half-hour offset puts the local wall-clock hour in a different
+ // UTC hour, so a local truncation would pick the wrong period.
+ args: simpleArgs{ID: 42},
+ name: "period_from_non_utc_schedule",
+ now: now,
+ opts: dbunique.UniqueOpts{ByPeriod: time.Hour},
+ queue: "default",
+ scheduledAt: new(scheduledAt.In(time.FixedZone("UTC+5:30", 5*60*60+30*60))),
+ },
+ {
+ args: simpleArgs{ID: 42},
+ name: "queue_without_kind",
+ now: now,
+ opts: dbunique.UniqueOpts{ByQueue: true, ExcludeKind: true},
+ queue: "priority_emails",
+ },
+ {
+ args: simpleArgs{ID: 42},
+ name: "all_dimensions_custom_states",
+ now: now,
+ opts: dbunique.UniqueOpts{ByArgs: true, ByPeriod: time.Minute, ByQueue: true, ByState: validCustomStates},
+ queue: "priority_emails",
+ scheduledAt: &scheduledAt,
+ },
+ }
+
+ generated := fixture{
+ Schema: "../schema/unique-keys.schema.json",
+ ProtocolRevision: 1,
+ }
+ for _, reference := range references {
+ encodedArgs, err := json.Marshal(reference.args)
+ if err != nil {
+ fatal(err)
+ }
+ states := rivertype.UniqueOptsByStateDefault()
+ if len(reference.opts.ByState) > 0 {
+ states = reference.opts.ByState
+ }
+ key, err := dbunique.UniqueKey(staticClock{now: reference.now}, &reference.opts, &rivertype.JobInsertParams{
+ Args: reference.args,
+ EncodedArgs: encodedArgs,
+ Kind: reference.args.Kind(),
+ Queue: reference.queue,
+ ScheduledAt: reference.scheduledAt,
+ UniqueStates: uniquestates.UniqueStatesToBitmask(states),
+ })
+ switch {
+ case reference.expectedError != "" && err == nil:
+ fatal(fmt.Errorf("unique fixture %s: expected an error", reference.name))
+ case reference.expectedError == "" && err != nil:
+ fatal(fmt.Errorf("unique fixture %s: %w", reference.name, err))
+ }
+ generatedCase := fixtureCase{
+ Args: encodedArgs,
+ ExpectedError: reference.expectedError,
+ ExpectedSHA256: hex.EncodeToString(key),
+ ExpectedStateMask: uniquestates.UniqueStatesToBitmask(states),
+ Kind: reference.args.Kind(),
+ Name: reference.name,
+ Now: reference.now,
+ Options: fixtureOptions{
+ ByArgs: reference.opts.ByArgs,
+ ByPeriodNanos: reference.opts.ByPeriod.Nanoseconds(),
+ ByQueue: reference.opts.ByQueue,
+ ByState: reference.opts.ByState,
+ ExcludeKind: reference.opts.ExcludeKind,
+ },
+ Queue: reference.queue,
+ ScheduledAt: reference.scheduledAt,
+ SelectedUniqueComponents: makeSelectedComponents(reference.selectedUniquePaths),
+ SelectedUniquePath: reference.selectedUniquePaths,
+ }
+ if reference.typedOnly {
+ generated.TypedOnlyCases = append(generated.TypedOnlyCases, generatedCase)
+ } else {
+ generated.Cases = append(generated.Cases, generatedCase)
+ }
+ }
+
+ for _, fixture := range []struct {
+ path string
+ value any
+ }{
+ {maintenanceFixturePath, makeMaintenanceFixture()},
+ {protocolFixturePath, makeProtocolFixture(now)},
+ {uniqueFixturePath, generated},
+ } {
+ writeGenerated(*check, fixture.path, fixture.value)
+ writeGenerated(*check, filepath.Join(rustFixtureDir, filepath.Base(fixture.path)), fixture.value)
+ }
+}
+
+// makeSelectedComponents keeps the fixture independent of gjson's escaped
+// path spelling. Each inner slice is one path of decoded JSON field names.
+func makeSelectedComponents(paths []string) [][]string {
+ if len(paths) == 0 {
+ return nil
+ }
+ components := make([][]string, 0, len(paths))
+ for _, path := range paths {
+ var parts []string
+ var part strings.Builder
+ for index := 0; index < len(path); index++ {
+ switch path[index] {
+ case '\\':
+ index++
+ if index < len(path) {
+ part.WriteByte(path[index])
+ }
+ case '.':
+ parts = append(parts, part.String())
+ part.Reset()
+ default:
+ part.WriteByte(path[index])
+ }
+ }
+ parts = append(parts, part.String())
+ components = append(components, parts)
+ }
+ return components
+}
+
+func makeProtocolFixture(now time.Time) protocolFixture {
+ states := rivertype.JobStates()
+ fixture := protocolFixture{
+ Schema: "../schema/protocol-values.schema.json",
+ AttemptError: rivertype.AttemptError{
+ At: now,
+ Attempt: 3,
+ Error: "worker failed: escaped \"detail\"",
+ Trace: "frame one\nframe two",
+ },
+ MetadataKeys: map[string]string{
+ "output": rivertype.MetadataKeyOutput,
+ "periodic_job_id": rivercommon.MetadataKeyPeriodicJobID,
+ "rescue_count": rivercommon.MetadataKeyRescueCount,
+ "resumable_cursor": rivercommon.MetadataKeyResumableCursor,
+ "resumable_step": rivercommon.MetadataKeyResumableStep,
+ "unique_nonce": riverdriver.UniqueInsertMetadataKey,
+ },
+ ProtocolRevision: 1,
+ Topics: map[string]notifier.NotificationTopic{
+ "control": notifier.NotificationTopicControl,
+ "insert": notifier.NotificationTopicInsert,
+ "leadership": notifier.NotificationTopicLeadership,
+ },
+ }
+ for _, state := range states {
+ fixture.JobStates = append(fixture.JobStates, protocolState{
+ Bit: uniquestates.UniqueStatesToBitmask([]rivertype.JobState{state}),
+ State: state,
+ })
+ }
+ notifications, err := makeProtocolNotifications()
+ if err != nil {
+ fatal(err)
+ }
+ fixture.Notifications = notifications
+ reserved, err := readReservedMetadataKeys()
+ if err != nil {
+ fatal(err)
+ }
+ fixture.ReservedMetadataKeys = reserved
+ for _, testCase := range []struct {
+ errorCount uint32
+ jobID int64
+ seed uint64
+ }{
+ {errorCount: 1, jobID: 42, seed: 0},
+ {errorCount: 2, jobID: 42, seed: 123},
+ {errorCount: 3, jobID: 9_007_199_254_740_991, seed: math.MaxUint64},
+ {errorCount: 11, jobID: 1, seed: 456},
+ {errorCount: 309, jobID: 42, seed: 789},
+ {errorCount: 310, jobID: 42, seed: 123},
+ } {
+ minDelay, maxDelay := retrypolicy.DelayBounds(int(testCase.errorCount))
+ fixture.RetryCases = append(fixture.RetryCases, protocolRetryCase{
+ ErrorCount: testCase.errorCount,
+ JobID: testCase.jobID,
+ MaxDelayNS: maxDelay.Nanoseconds(),
+ MinDelayNS: minDelay.Nanoseconds(),
+ Now: now,
+ Seed: testCase.seed,
+ })
+ }
+ return fixture
+}
+
+// makeProtocolNotifications derives notification payload goldens from the Go
+// payload structs and action constants, and checks that the payloads the SQL
+// queries build use the same keys.
+func makeProtocolNotifications() ([]protocolNotification, error) {
+ controlFields, err := sourceStructJSONFields("producer.go", "controlEventPayload")
+ if err != nil {
+ return nil, err
+ }
+ insertFields, err := sourceStructJSONFields("producer.go", "insertPayload")
+ if err != nil {
+ return nil, err
+ }
+ leadershipFields, err := sourceStructJSONFields("internal/leadership/elector.go", "DBNotification")
+ if err != nil {
+ return nil, err
+ }
+ controlActions, err := sourceStringConstants("producer.go", "controlAction")
+ if err != nil {
+ return nil, err
+ }
+ leadershipActions, err := sourceStringConstants("internal/leadership/elector.go", "DBNotificationKind")
+ if err != nil {
+ return nil, err
+ }
+ examples := map[string]any{
+ "job_id": 42,
+ "leader_id": "client-1",
+ "metadata": map[string]any{"owner": "candidate"},
+ "queue": "priority",
+ }
+ var notifications []protocolNotification
+ for _, constant := range slices.Sorted(maps.Keys(controlActions)) {
+ action := controlActions[constant]
+ values := map[string]any{"action": action, "queue": examples["queue"]}
+ switch action {
+ case "cancel":
+ values["job_id"] = examples["job_id"]
+ case "metadata_changed":
+ values["metadata"] = examples["metadata"]
+ }
+ notification, err := newProtocolNotification(action, string(notifier.NotificationTopicControl), "producer.go:controlEventPayload", controlFields, values)
+ if err != nil {
+ return nil, err
+ }
+ notifications = append(notifications, notification)
+ }
+ insert, err := newProtocolNotification("insert", string(notifier.NotificationTopicInsert), "producer.go:insertPayload", insertFields, map[string]any{"queue": examples["queue"]})
+ if err != nil {
+ return nil, err
+ }
+ notifications = append(notifications, insert)
+ for _, constant := range slices.Sorted(maps.Keys(leadershipActions)) {
+ action := leadershipActions[constant]
+ leaderID := ""
+ if action == string(leadership.DBNotificationKindResigned) {
+ leaderID = "client-1"
+ }
+ notification, err := newProtocolNotification(action, string(notifier.NotificationTopicLeadership), "internal/leadership/elector.go:DBNotification", leadershipFields, map[string]any{"action": action, "leader_id": leaderID})
+ if err != nil {
+ return nil, err
+ }
+ notifications = append(notifications, notification)
+ }
+
+ // Some notifications are built in SQL rather than Go. Their keys must
+ // match the payload structs consumers decode them into.
+ for _, check := range []struct {
+ name string
+ path string
+ query string
+ }{
+ {name: "cancel", path: "riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql", query: "JobCancel"},
+ {name: "resigned", path: "riverdriver/riverpgxv5/internal/dbsqlc/river_leader.sql", query: "LeaderResign"},
+ } {
+ keys, err := sqlNotificationKeys(check.path, check.query)
+ if err != nil {
+ return nil, err
+ }
+ index := slices.IndexFunc(notifications, func(notification protocolNotification) bool { return notification.Name == check.name })
+ if index < 0 {
+ return nil, fmt.Errorf("no %s notification to compare with %s", check.name, check.query)
+ }
+ var payload map[string]any
+ if err := json.Unmarshal(notifications[index].Payload, &payload); err != nil {
+ return nil, err
+ }
+ if expected := slices.Sorted(maps.Keys(payload)); !slices.Equal(expected, keys) {
+ return nil, fmt.Errorf("%s notification keys %v from %s differ from the Go payload keys %v", check.name, keys, check.query, expected)
+ }
+ notifications[index].Source += "; " + check.path + ":" + check.query
+ }
+ slices.SortFunc(notifications, func(a, b protocolNotification) int { return strings.Compare(a.Name, b.Name) })
+ return notifications, nil
+}
+
+// newProtocolNotification encodes values in the struct's field order,
+// omitting empty omitempty fields as encoding/json does.
+func newProtocolNotification(name, topic, source string, fields []jsonField, values map[string]any) (protocolNotification, error) {
+ var payload bytes.Buffer
+ payload.WriteByte('{')
+ for _, field := range fields {
+ value, ok := values[field.Name]
+ if !ok && !field.OmitEmpty {
+ return protocolNotification{}, fmt.Errorf("%s notification has no value for required field %s", name, field.Name)
+ }
+ if !ok {
+ continue
+ }
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ return protocolNotification{}, err
+ }
+ if payload.Len() > 1 {
+ payload.WriteByte(',')
+ }
+ key, err := json.Marshal(field.Name)
+ if err != nil {
+ return protocolNotification{}, err
+ }
+ payload.Write(key)
+ payload.WriteByte(':')
+ payload.Write(encoded)
+ }
+ payload.WriteByte('}')
+ for key := range values {
+ if !slices.ContainsFunc(fields, func(field jsonField) bool { return field.Name == key }) {
+ return protocolNotification{}, fmt.Errorf("%s notification value %s is not a payload field", name, key)
+ }
+ }
+ return protocolNotification{Fields: fields, Name: name, Payload: payload.Bytes(), Source: source, Topic: topic}, nil
+}
+
+// readReservedMetadataKeys returns the metadata keys the feature inventory
+// extracted from Go source and SQL, with their applicability.
+func readReservedMetadataKeys() ([]reservedMetadataKey, error) {
+ contents, err := os.ReadFile(featureInventoryPath)
+ if err != nil {
+ return nil, err
+ }
+ var inventory struct {
+ Items []struct {
+ Applicability string `json:"applicability"`
+ Area string `json:"area"`
+ ID string `json:"id"`
+ } `json:"items"`
+ }
+ if err := json.Unmarshal(contents, &inventory); err != nil {
+ return nil, fmt.Errorf("decode %s: %w", featureInventoryPath, err)
+ }
+ var keys []reservedMetadataKey
+ for _, item := range inventory.Items {
+ if item.Area == "metadata_key" {
+ keys = append(keys, reservedMetadataKey{
+ Applicability: item.Applicability,
+ Key: strings.TrimPrefix(item.ID, "metadata_key."),
+ })
+ }
+ }
+ if len(keys) == 0 {
+ return nil, fmt.Errorf("%s lists no metadata keys", featureInventoryPath)
+ }
+ slices.SortFunc(keys, func(a, b reservedMetadataKey) int { return strings.Compare(a.Key, b.Key) })
+ return keys, nil
+}
+
+func writeGenerated(check bool, path string, value any) {
+ contents, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ fatal(err)
+ }
+ contents = append(contents, '\n')
+ if check {
+ actual, err := os.ReadFile(path)
+ if err != nil {
+ fatal(err)
+ }
+ if !bytes.Equal(actual, contents) {
+ fatal(fmt.Errorf("generated file is stale: %s (run make generate/conformance)", path))
+ }
+ return
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ fatal(err)
+ }
+ //nolint:gosec // Generated repository artifacts are intentionally world-readable.
+ if err := os.WriteFile(path, contents, 0o644); err != nil {
+ fatal(err)
+ }
+}
+
+func fatal(err error) {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+}
diff --git a/internal/cmd/generateconformance/maintenance.go b/internal/cmd/generateconformance/maintenance.go
new file mode 100644
index 000000000..00f869d12
--- /dev/null
+++ b/internal/cmd/generateconformance/maintenance.go
@@ -0,0 +1,200 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+ _ "time/tzdata" // named cron zones resolve the same on every host
+
+ "github.com/robfig/cron/v3"
+ "github.com/tidwall/gjson"
+)
+
+const maintenanceFixturePath = "conformance/fixtures/maintenance_values.json"
+
+// cronNextCount is the number of successive occurrences recorded per cron
+// case. Each occurrence is computed from the previous one, like the periodic
+// job enqueuer advancing its schedule.
+const cronNextCount = 5
+
+type maintenanceFixture struct {
+ Schema string `json:"$schema"`
+ CronCases []cronCase `json:"cron_cases"`
+ CronInvalid []string `json:"cron_invalid"`
+ CronNamedZones []cronCase `json:"cron_named_zone_cases"`
+ ProtocolRevision int `json:"protocol_revision"`
+ SnoozeCounters []snoozeCounterCase `json:"snooze_counters"`
+}
+
+type cronCase struct {
+ Expression string `json:"expression"`
+ From time.Time `json:"from"`
+ Name string `json:"name"`
+ Next []time.Time `json:"next"`
+}
+
+type cronCaseInput struct {
+ expression string
+ from time.Time
+ name string
+}
+
+type snoozeCounterCase struct {
+ ExpectedSnoozes int64 `json:"expected_snoozes"`
+ Metadata json.RawMessage `json:"metadata"`
+ Name string `json:"name"`
+}
+
+// makeMaintenanceFixture records Go's standard cron semantics (robfig/cron
+// `ParseStandard`, as documented for River periodic jobs) and the snooze
+// counter coercion used by the job executor.
+func makeMaintenanceFixture() maintenanceFixture {
+ fixture := maintenanceFixture{
+ Schema: "../schema/maintenance-values.schema.json",
+ ProtocolRevision: 1,
+ }
+
+ utcFrom := time.Date(2026, time.January, 2, 3, 4, 5, 678_900_000, time.UTC)
+ eastern := time.FixedZone("", -5*60*60)
+ kolkata := time.FixedZone("", 5*60*60+30*60)
+ for _, testCase := range []cronCaseInput{
+ {expression: "* * * * *", from: utcFrom, name: "every_minute"},
+ {expression: "30 * * * *", from: utcFrom, name: "half_past_every_hour"},
+ {expression: "0 9 * * 1", from: utcFrom, name: "monday_numeric_weekday"},
+ {expression: "0 9 * * mon", from: utcFrom, name: "monday_named_weekday"},
+ {expression: "0 0 * * 0", from: utcFrom, name: "sunday_is_zero"},
+ {expression: "0 0 * * SUN", from: utcFrom, name: "weekday_names_ignore_case"},
+ {expression: "*/15 9-17 * * mon-fri", from: utcFrom, name: "business_hours_steps"},
+ {expression: "0 0 1 * *", from: utcFrom, name: "first_of_month"},
+ {expression: "0 0 1 jan,JUL *", from: utcFrom, name: "named_months"},
+ {expression: "0 0 29 2 *", from: utcFrom, name: "leap_day"},
+ {expression: "0 0 30 2 *", from: utcFrom, name: "impossible_date_never_runs"},
+ {expression: "0 12 1,15 * 5", from: utcFrom, name: "day_of_month_or_weekday"},
+ {expression: "0 12 * * 5", from: utcFrom, name: "wildcard_day_of_month_and_weekday"},
+ {expression: "0 12 ? * 5", from: utcFrom, name: "question_mark_wildcard"},
+ {expression: "0 12 */2 * 5", from: utcFrom, name: "stepped_day_of_month_or_weekday"},
+ {expression: "0 12 */1 * 5", from: utcFrom, name: "unit_step_keeps_wildcard"},
+ {expression: "5/15 * * * *", from: utcFrom, name: "start_with_step"},
+ {expression: "0-10/5 * * * *", from: utcFrom, name: "range_with_step"},
+ {expression: "59 23 31 12 *", from: utcFrom, name: "year_end"},
+ {expression: "@hourly", from: utcFrom, name: "descriptor_hourly"},
+ {expression: "@daily", from: utcFrom, name: "descriptor_daily"},
+ {expression: "@midnight", from: utcFrom, name: "descriptor_midnight"},
+ {expression: "@weekly", from: utcFrom, name: "descriptor_weekly"},
+ {expression: "@monthly", from: utcFrom, name: "descriptor_monthly"},
+ {expression: "@yearly", from: utcFrom, name: "descriptor_yearly"},
+ {expression: "@annually", from: utcFrom, name: "descriptor_annually"},
+ {expression: "@every 1h30m", from: utcFrom, name: "every_compound_duration"},
+ {expression: "@every 1.5h", from: utcFrom, name: "every_fractional_duration"},
+ {expression: "@every 90s", from: utcFrom, name: "every_seconds"},
+ {expression: "@every 500ms", from: utcFrom, name: "every_rounds_up_to_one_second"},
+ {expression: "@every 1500ms", from: utcFrom, name: "every_truncates_subseconds"},
+ {expression: "0 9 * * *", from: time.Date(2026, time.March, 7, 8, 0, 0, 0, eastern), name: "reference_time_offset"},
+ {expression: "30 0 * * *", from: time.Date(2026, time.March, 7, 23, 45, 0, 0, kolkata), name: "reference_time_half_hour_offset"},
+ {expression: "CRON_TZ=UTC 0 9 * * *", from: time.Date(2026, time.March, 7, 8, 0, 0, 0, eastern), name: "cron_tz_utc_prefix"},
+ {expression: "TZ=UTC 0 9 * * *", from: time.Date(2026, time.March, 7, 8, 0, 0, 0, eastern), name: "tz_utc_prefix"},
+ {expression: " 0 9 * * 1 ", from: utcFrom, name: "extra_whitespace"},
+ } {
+ fixture.CronCases = append(fixture.CronCases, makeCronCase(testCase))
+ }
+
+ // IANA zones named in `CRON_TZ=`/`TZ=` prefixes, including daylight
+ // saving transitions. Kept apart from `cron_cases` because an
+ // implementation may need an optional time zone database for them.
+ for _, testCase := range []cronCaseInput{
+ {expression: "CRON_TZ=America/New_York 0 9 * * *", from: time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC), name: "new_york_across_dst_start"},
+ {expression: "CRON_TZ=America/New_York 30 2 * * *", from: time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC), name: "new_york_skipped_wall_time"},
+ {expression: "CRON_TZ=America/New_York 30 1 * * *", from: time.Date(2026, time.October, 30, 12, 0, 0, 0, time.UTC), name: "new_york_repeated_wall_time"},
+ {expression: "CRON_TZ=America/New_York 0 * * * *", from: time.Date(2026, time.November, 1, 4, 30, 0, 0, time.UTC), name: "new_york_hourly_across_dst_end"},
+ {expression: "CRON_TZ=Europe/London 0 0 * * *", from: time.Date(2026, time.October, 23, 12, 0, 0, 0, time.UTC), name: "london_across_dst_end"},
+ {expression: "CRON_TZ=America/Santiago 0 0 * * *", from: time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC), name: "santiago_skipped_midnight"},
+ {expression: "CRON_TZ=America/Santiago 0 12 * * *", from: time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC), name: "santiago_day_after_skipped_midnight"},
+ {expression: "CRON_TZ=America/Santiago 30 23 * * *", from: time.Date(2026, time.April, 2, 12, 0, 0, 0, time.UTC), name: "santiago_repeated_hour_before_midnight"},
+ {expression: "TZ=Asia/Kolkata 0 9 * * mon", from: time.Date(2026, time.January, 2, 3, 4, 5, 0, eastern), name: "kolkata_tz_prefix"},
+ } {
+ fixture.CronNamedZones = append(fixture.CronNamedZones, makeCronCase(testCase))
+ }
+
+ for _, expression := range []string{
+ "",
+ "* * * *",
+ "* * * * * *",
+ "0 9 * * 7",
+ "60 * * * *",
+ "* 24 * * *",
+ "* * 0 * *",
+ "* * 32 * *",
+ "* * * 0 *",
+ "* * * 13 *",
+ "-1 * * * *",
+ "5-1 * * * *",
+ "1-2-3 * * * *",
+ "1/2/3 * * * *",
+ "*/0 * * * *",
+ "*/x * * * *",
+ "0 9 * * funday",
+ "@every",
+ "@every 5x",
+ "@reboot",
+ "CRON_TZ=Nowhere/Invalid 0 9 * * *",
+ } {
+ if _, err := cron.ParseStandard(expression); err == nil {
+ fatal(fmt.Errorf("cron expression unexpectedly parsed: %q", expression))
+ }
+ fixture.CronInvalid = append(fixture.CronInvalid, expression)
+ }
+
+ for _, testCase := range []struct {
+ metadata string
+ name string
+ }{
+ {metadata: `{}`, name: "absent"},
+ {metadata: `{"snoozes":2}`, name: "integer"},
+ {metadata: `{"snoozes":2.9}`, name: "fraction_truncates"},
+ {metadata: `{"snoozes":-2.5}`, name: "negative_fraction_truncates_toward_zero"},
+ {metadata: `{"snoozes":1e3}`, name: "exponent"},
+ {metadata: `{"snoozes":9007199254740993}`, name: "beyond_float_precision"},
+ {metadata: `{"snoozes":"4"}`, name: "numeric_string"},
+ {metadata: `{"snoozes":"-7"}`, name: "negative_numeric_string"},
+ {metadata: `{"snoozes":"4.5"}`, name: "fractional_string_is_zero"},
+ {metadata: `{"snoozes":" 5"}`, name: "padded_string_is_zero"},
+ {metadata: `{"snoozes":"abc"}`, name: "non_numeric_string_is_zero"},
+ {metadata: `{"snoozes":true}`, name: "true_is_one"},
+ {metadata: `{"snoozes":false}`, name: "false_is_zero"},
+ {metadata: `{"snoozes":null}`, name: "null_is_zero"},
+ {metadata: `{"snoozes":[3]}`, name: "array_is_zero"},
+ {metadata: `{"snoozes":{"count":3}}`, name: "object_is_zero"},
+ } {
+ // Mirrors the job executor's snooze bookkeeping.
+ fixture.SnoozeCounters = append(fixture.SnoozeCounters, snoozeCounterCase{
+ ExpectedSnoozes: gjson.GetBytes([]byte(testCase.metadata), "snoozes").Int() + 1,
+ Metadata: json.RawMessage(testCase.metadata),
+ Name: testCase.name,
+ })
+ }
+
+ return fixture
+}
+
+// makeCronCase records the occurrences Go computes for one cron case.
+func makeCronCase(testCase cronCaseInput) cronCase {
+ schedule, err := cron.ParseStandard(testCase.expression)
+ if err != nil {
+ fatal(err)
+ }
+ next := make([]time.Time, 0, cronNextCount)
+ current := testCase.from
+ for range cronNextCount {
+ current = schedule.Next(current)
+ if current.IsZero() {
+ break
+ }
+ next = append(next, current)
+ }
+ return cronCase{
+ Expression: testCase.expression,
+ From: testCase.from,
+ Name: testCase.name,
+ Next: next,
+ }
+}
diff --git a/internal/cmd/generateconformance/source.go b/internal/cmd/generateconformance/source.go
new file mode 100644
index 000000000..b7543384d
--- /dev/null
+++ b/internal/cmd/generateconformance/source.go
@@ -0,0 +1,144 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "reflect"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+)
+
+// jsonField is one field of a Go struct's JSON encoding.
+type jsonField struct {
+ Name string `json:"name"`
+ OmitEmpty bool `json:"omitempty"`
+}
+
+// sourceStructJSONFields returns a struct's JSON fields in declaration order
+// by parsing its source file, so payload shapes of unexported notification
+// structs are derived from Go rather than restated by hand.
+func sourceStructJSONFields(path, typeName string) ([]jsonField, error) {
+ file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution)
+ if err != nil {
+ return nil, err
+ }
+ var fields []jsonField
+ found := false
+ ast.Inspect(file, func(node ast.Node) bool {
+ spec, ok := node.(*ast.TypeSpec)
+ if !ok || spec.Name.Name != typeName {
+ return true
+ }
+ structType, ok := spec.Type.(*ast.StructType)
+ if !ok {
+ return false
+ }
+ found = true
+ for _, field := range structType.Fields.List {
+ if field.Tag == nil {
+ continue
+ }
+ tag, err := strconv.Unquote(field.Tag.Value)
+ if err != nil {
+ continue
+ }
+ name, options, _ := strings.Cut(reflect.StructTag(tag).Get("json"), ",")
+ if name == "" || name == "-" {
+ continue
+ }
+ fields = append(fields, jsonField{Name: name, OmitEmpty: slices.Contains(strings.Split(options, ","), "omitempty")})
+ }
+ return false
+ })
+ if !found {
+ return nil, fmt.Errorf("struct %s not found in %s", typeName, path)
+ }
+ if len(fields) == 0 {
+ return nil, fmt.Errorf("struct %s in %s has no JSON fields", typeName, path)
+ }
+ return fields, nil
+}
+
+// sourceStringConstants returns the values of string constants declared with
+// the named type in a source file, keyed by constant name.
+func sourceStringConstants(path, typeName string) (map[string]string, error) {
+ file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution)
+ if err != nil {
+ return nil, err
+ }
+ constants := make(map[string]string)
+ for _, declaration := range file.Decls {
+ general, ok := declaration.(*ast.GenDecl)
+ if !ok || general.Tok != token.CONST {
+ continue
+ }
+ for _, spec := range general.Specs {
+ value, ok := spec.(*ast.ValueSpec)
+ if !ok || len(value.Values) != len(value.Names) {
+ continue
+ }
+ identifier, ok := value.Type.(*ast.Ident)
+ if !ok || identifier.Name != typeName {
+ continue
+ }
+ for index, name := range value.Names {
+ literal, ok := value.Values[index].(*ast.BasicLit)
+ if !ok || literal.Kind != token.STRING {
+ continue
+ }
+ unquoted, err := strconv.Unquote(literal.Value)
+ if err != nil {
+ return nil, err
+ }
+ constants[name.Name] = unquoted
+ }
+ }
+ }
+ if len(constants) == 0 {
+ return nil, fmt.Errorf("no %s string constants in %s", typeName, path)
+ }
+ return constants, nil
+}
+
+var jsonBuildObjectPattern = regexp.MustCompile(`json_build_object\(([^)]*)\)`)
+
+// sqlNotificationKeys returns the keys of the json_build_object payload that a
+// named sqlc query passes to pg_notify.
+func sqlNotificationKeys(path, queryName string) ([]string, error) {
+ contents, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ _, query, found := strings.Cut(string(contents), "-- name: "+queryName+" ")
+ if !found {
+ return nil, fmt.Errorf("query %s not found in %s", queryName, path)
+ }
+ query, _, _ = strings.Cut(query, "-- name: ")
+ if !strings.Contains(query, "pg_notify(") {
+ return nil, fmt.Errorf("query %s in %s sends no notification", queryName, path)
+ }
+ match := jsonBuildObjectPattern.FindStringSubmatch(query)
+ if match == nil {
+ return nil, fmt.Errorf("query %s in %s builds no JSON payload", queryName, path)
+ }
+ arguments := strings.Split(match[1], ",")
+ if len(arguments)%2 != 0 {
+ return nil, fmt.Errorf("query %s in %s has an odd json_build_object argument list", queryName, path)
+ }
+ keys := make([]string, 0, len(arguments)/2)
+ for index := 0; index < len(arguments); index += 2 {
+ key := strings.TrimSpace(arguments[index])
+ if !strings.HasPrefix(key, "'") || !strings.HasSuffix(key, "'") {
+ return nil, errors.New("json_build_object keys must be literals")
+ }
+ keys = append(keys, strings.Trim(key, "'"))
+ }
+ slices.Sort(keys)
+ return keys, nil
+}
diff --git a/internal/cmd/generatefeatureinventory/extract.go b/internal/cmd/generatefeatureinventory/extract.go
new file mode 100644
index 000000000..4335c3d98
--- /dev/null
+++ b/internal/cmd/generatefeatureinventory/extract.go
@@ -0,0 +1,791 @@
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "go/types"
+ "os"
+ "path"
+ "path/filepath"
+ "reflect"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+
+ "github.com/riverqueue/river"
+ "github.com/riverqueue/river/rivertype"
+)
+
+// goFile is one parsed Go source file.
+type goFile struct {
+ file *ast.File
+ path string // repository-relative, slash separated
+}
+
+// goPackage is the parsed non-test Go files of one directory.
+type goPackage struct {
+ dir string // repository-relative, slash separated
+ files []*goFile
+ name string
+}
+
+// parseGoPackage parses every non-test Go file directly inside dir, which is
+// relative to root. It fails if the directory has no such files.
+func parseGoPackage(root, dir string) (*goPackage, error) {
+ entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(dir)))
+ if err != nil {
+ return nil, fmt.Errorf("read package directory %s: %w", dir, err)
+ }
+
+ pkg := &goPackage{dir: dir}
+ fset := token.NewFileSet()
+ for _, entry := range entries {
+ name := entry.Name()
+ if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
+ continue
+ }
+ relPath := path.Join(dir, name)
+ file, err := parser.ParseFile(fset, filepath.Join(root, filepath.FromSlash(relPath)), nil, parser.SkipObjectResolution)
+ if err != nil {
+ return nil, fmt.Errorf("parse %s: %w", relPath, err)
+ }
+ if pkg.name == "" {
+ pkg.name = file.Name.Name
+ }
+ pkg.files = append(pkg.files, &goFile{file: file, path: relPath})
+ }
+ if len(pkg.files) == 0 {
+ return nil, fmt.Errorf("no Go files found in %s", dir)
+ }
+ return pkg, nil
+}
+
+// funcFile returns the file declaring top-level function name.
+func (p *goPackage) funcFile(name string) (string, error) {
+ for _, file := range p.files {
+ for _, decl := range file.file.Decls {
+ if funcDecl, ok := decl.(*ast.FuncDecl); ok && funcDecl.Recv == nil && funcDecl.Name.Name == name {
+ return file.path, nil
+ }
+ }
+ }
+ return "", fmt.Errorf("function %s.%s not found in %s", p.name, name, p.dir)
+}
+
+// methodFile returns the file declaring method name on receiver type recv, or
+// "" if the package does not declare it directly.
+func (p *goPackage) methodFile(recv, name string) string {
+ for _, file := range p.files {
+ for _, decl := range file.file.Decls {
+ funcDecl, ok := decl.(*ast.FuncDecl)
+ if !ok || funcDecl.Recv == nil || funcDecl.Name.Name != name {
+ continue
+ }
+ if receiverTypeName(funcDecl.Recv.List[0].Type) == recv {
+ return file.path
+ }
+ }
+ }
+ return ""
+}
+
+// stringConsts returns every constant declared with a string literal value.
+func (p *goPackage) stringConsts() []*stringConst {
+ consts := make([]*stringConst, 0, len(p.files))
+ for _, file := range p.files {
+ consts = append(consts, fileStringConsts(p.name, file)...)
+ }
+ return consts
+}
+
+// typeSpec finds the declaration of type name.
+func (p *goPackage) typeSpec(name string) (*ast.TypeSpec, *goFile, error) {
+ for _, file := range p.files {
+ for _, decl := range file.file.Decls {
+ genDecl, ok := decl.(*ast.GenDecl)
+ if !ok || genDecl.Tok != token.TYPE {
+ continue
+ }
+ for _, spec := range genDecl.Specs {
+ if typeSpec := spec.(*ast.TypeSpec); typeSpec.Name.Name == name { //nolint:forcetypeassert // TYPE declarations only contain TypeSpecs
+ return typeSpec, file, nil
+ }
+ }
+ }
+ }
+ return nil, nil, fmt.Errorf("type %s.%s not found in %s", p.name, name, p.dir)
+}
+
+// stringConst is a constant declared with a string literal value.
+type stringConst struct {
+ name string
+ path string
+ pkg string
+ typeName string
+ value string
+}
+
+func fileStringConsts(pkgName string, file *goFile) []*stringConst {
+ var consts []*stringConst
+ for _, decl := range file.file.Decls {
+ genDecl, ok := decl.(*ast.GenDecl)
+ if !ok || genDecl.Tok != token.CONST {
+ continue
+ }
+ for _, spec := range genDecl.Specs {
+ valueSpec := spec.(*ast.ValueSpec) //nolint:forcetypeassert // CONST declarations only contain ValueSpecs
+ typeName := ""
+ if ident, ok := valueSpec.Type.(*ast.Ident); ok {
+ typeName = ident.Name
+ }
+ for i, name := range valueSpec.Names {
+ if i >= len(valueSpec.Values) {
+ continue
+ }
+ value, ok := stringLiteral(valueSpec.Values[i])
+ if !ok {
+ continue
+ }
+ consts = append(consts, &stringConst{
+ name: name.Name,
+ path: file.path,
+ pkg: pkgName,
+ typeName: typeName,
+ value: value,
+ })
+ }
+ }
+ }
+ return consts
+}
+
+// extractAll derives every inventory item from the repository at root. The
+// result is sorted by ID and contains no duplicate IDs.
+func extractAll(root string) ([]*extractedItem, error) {
+ rootPkg, err := parseGoPackage(root, ".")
+ if err != nil {
+ return nil, err
+ }
+
+ extractors := []func() ([]*extractedItem, error){
+ func() ([]*extractedItem, error) {
+ return extractStructFields(rootPkg, "config", reflect.TypeFor[river.Config]())
+ },
+ func() ([]*extractedItem, error) {
+ return extractStructFields(rootPkg, "insert_opts", reflect.TypeFor[river.InsertOpts]())
+ },
+ func() ([]*extractedItem, error) {
+ return extractStructFields(rootPkg, "unique_opts", reflect.TypeFor[river.UniqueOpts]())
+ },
+ func() ([]*extractedItem, error) {
+ return extractStructFields(rootPkg, "queue_config", reflect.TypeFor[river.QueueConfig]())
+ },
+ func() ([]*extractedItem, error) {
+ return extractStructFields(rootPkg, "periodic_job_opts", reflect.TypeFor[river.PeriodicJobOpts]())
+ },
+ func() ([]*extractedItem, error) {
+ return extractMethods(rootPkg, "client", reflect.TypeFor[*river.Client[pgx.Tx]]())
+ },
+ func() ([]*extractedItem, error) {
+ return extractMethods(rootPkg, "job_list_params", reflect.TypeFor[*river.JobListParams]())
+ },
+ func() ([]*extractedItem, error) {
+ return extractMethods(rootPkg, "job_delete_many_params", reflect.TypeFor[*river.JobDeleteManyParams]())
+ },
+ func() ([]*extractedItem, error) {
+ return extractMethods(rootPkg, "queue_list_params", reflect.TypeFor[*river.QueueListParams]())
+ },
+ func() ([]*extractedItem, error) { return extractJobStates(root) },
+ func() ([]*extractedItem, error) { return extractEventKinds(rootPkg) },
+ func() ([]*extractedItem, error) { return extractMetadataKeys(root) },
+ func() ([]*extractedItem, error) { return extractNotificationTopics(root) },
+ func() ([]*extractedItem, error) { return extractNotificationPayloads(root, rootPkg) },
+ func() ([]*extractedItem, error) { return extractDriverInterfaces(root) },
+ func() ([]*extractedItem, error) { return extractExtensionInterfaces(root) },
+ func() ([]*extractedItem, error) { return extractMigrations(root) },
+ func() ([]*extractedItem, error) { return extractRootFunctions(rootPkg) },
+ func() ([]*extractedItem, error) { return extractRootInterfaces(rootPkg) },
+ func() ([]*extractedItem, error) { return extractRivertypeFields(root) },
+ }
+
+ var items []*extractedItem
+ for _, extractor := range extractors {
+ areaItems, err := extractor()
+ if err != nil {
+ return nil, err
+ }
+ if len(areaItems) == 0 {
+ return nil, errors.New("an extractor produced no items; a source was probably renamed")
+ }
+ items = append(items, areaItems...)
+ }
+
+ sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
+ for i := 1; i < len(items); i++ {
+ if items[i].ID == items[i-1].ID {
+ return nil, fmt.Errorf("duplicate extracted item ID %s (%s and %s)", items[i].ID, items[i-1].Source, items[i].Source)
+ }
+ }
+ return items, nil
+}
+
+// extractStructFields returns one item per exported field of a struct type,
+// located in pkg for the source reference.
+func extractStructFields(pkg *goPackage, area string, structType reflect.Type) ([]*extractedItem, error) {
+ _, file, err := pkg.typeSpec(structType.Name())
+ if err != nil {
+ return nil, err
+ }
+
+ var items []*extractedItem
+ for field := range structType.Fields() {
+ if !field.IsExported() {
+ continue
+ }
+ items = append(items, &extractedItem{
+ Area: area,
+ Detail: reflectTypeString(field.Type),
+ ID: area + "." + field.Name,
+ Source: fmt.Sprintf("%s:%s.%s.%s", file.path, pkg.name, structType.Name(), field.Name),
+ })
+ }
+ return items, nil
+}
+
+// extractMethods returns one item per exported method in the method set of a
+// pointer type.
+func extractMethods(pkg *goPackage, area string, ptrType reflect.Type) ([]*extractedItem, error) {
+ elemName := genericBaseName(ptrType.Elem().Name())
+ _, typeFile, err := pkg.typeSpec(elemName)
+ if err != nil {
+ return nil, err
+ }
+
+ items := make([]*extractedItem, 0, ptrType.NumMethod())
+ for method := range ptrType.Methods() {
+ file := pkg.methodFile(elemName, method.Name)
+ if file == "" {
+ file = typeFile.path
+ }
+ items = append(items, &extractedItem{
+ Area: area,
+ Detail: reflectFuncSignature(method.Type, 1),
+ ID: area + "." + method.Name,
+ Source: fmt.Sprintf("%s:%s.%s.%s", file, pkg.name, elemName, method.Name),
+ })
+ }
+ return items, nil
+}
+
+func extractJobStates(root string) ([]*extractedItem, error) {
+ pkg, err := parseGoPackage(root, "rivertype")
+ if err != nil {
+ return nil, err
+ }
+ if _, err := pkg.funcFile("JobStates"); err != nil {
+ return nil, err
+ }
+
+ constsByValue := make(map[string]*stringConst)
+ for _, constDecl := range pkg.stringConsts() {
+ if constDecl.typeName == "JobState" {
+ constsByValue[constDecl.value] = constDecl
+ }
+ }
+
+ items := make([]*extractedItem, 0, len(rivertype.JobStates()))
+ for _, state := range rivertype.JobStates() {
+ constDecl, ok := constsByValue[string(state)]
+ if !ok {
+ return nil, fmt.Errorf("no rivertype.JobState constant declares %q", state)
+ }
+ items = append(items, &extractedItem{
+ Area: "job_state",
+ Detail: constDecl.name,
+ ID: "job_state." + string(state),
+ Source: fmt.Sprintf("%s:%s.%s", constDecl.path, pkg.name, constDecl.name),
+ })
+ }
+ return items, nil
+}
+
+func extractEventKinds(rootPkg *goPackage) ([]*extractedItem, error) {
+ return constItems(rootPkg, "event_kind", "event_kind.", func(constDecl *stringConst) bool {
+ return constDecl.typeName == "EventKind" && strings.HasPrefix(constDecl.name, "EventKind")
+ })
+}
+
+func extractNotificationTopics(root string) ([]*extractedItem, error) {
+ pkg, err := parseGoPackage(root, "internal/notifier")
+ if err != nil {
+ return nil, err
+ }
+ return constItems(pkg, "notification_topic", "notification_topic.", func(constDecl *stringConst) bool {
+ return constDecl.typeName == "NotificationTopic" && strings.HasPrefix(constDecl.name, "NotificationTopic")
+ })
+}
+
+// constItems returns an item for every string constant in pkg matching
+// include, identified by the constant's value.
+func constItems(pkg *goPackage, area, idPrefix string, include func(*stringConst) bool) ([]*extractedItem, error) {
+ var items []*extractedItem
+ for _, constDecl := range pkg.stringConsts() {
+ if !include(constDecl) {
+ continue
+ }
+ items = append(items, &extractedItem{
+ Area: area,
+ Detail: constDecl.name,
+ ID: idPrefix + constDecl.value,
+ Source: fmt.Sprintf("%s:%s.%s", constDecl.path, pkg.name, constDecl.name),
+ })
+ }
+ if len(items) == 0 {
+ return nil, fmt.Errorf("no %s constants found in %s", area, pkg.dir)
+ }
+ return items, nil
+}
+
+func extractNotificationPayloads(root string, rootPkg *goPackage) ([]*extractedItem, error) {
+ leadershipPkg, err := parseGoPackage(root, "internal/leadership")
+ if err != nil {
+ return nil, err
+ }
+
+ var items []*extractedItem
+ for _, payload := range []struct {
+ id string
+ pkg *goPackage
+ typeName string
+ }{
+ {id: "notification_payload.control", pkg: rootPkg, typeName: "controlEventPayload"},
+ {id: "notification_payload.insert", pkg: rootPkg, typeName: "insertPayload"},
+ {id: "notification_payload.leadership", pkg: leadershipPkg, typeName: "DBNotification"},
+ } {
+ item, err := payloadStructItem(payload.pkg, payload.id, payload.typeName)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+
+ controlActions, err := constItems(rootPkg, "notification_payload", "notification_payload.control.action.", func(constDecl *stringConst) bool {
+ return constDecl.typeName == "controlAction"
+ })
+ if err != nil {
+ return nil, err
+ }
+ leadershipActions, err := constItems(leadershipPkg, "notification_payload", "notification_payload.leadership.action.", func(constDecl *stringConst) bool {
+ return constDecl.typeName == "DBNotificationKind"
+ })
+ if err != nil {
+ return nil, err
+ }
+ sqlPayloads, err := extractSQLNotificationPayloads(root)
+ if err != nil {
+ return nil, err
+ }
+
+ items = append(items, controlActions...)
+ items = append(items, leadershipActions...)
+ return append(items, sqlPayloads...), nil
+}
+
+// payloadStructItem describes a JSON payload struct as its sorted JSON fields.
+func payloadStructItem(pkg *goPackage, id, typeName string) (*extractedItem, error) {
+ typeSpec, file, err := pkg.typeSpec(typeName)
+ if err != nil {
+ return nil, err
+ }
+ structType, ok := typeSpec.Type.(*ast.StructType)
+ if !ok {
+ return nil, fmt.Errorf("%s.%s is not a struct", pkg.name, typeName)
+ }
+
+ var fields []string
+ for _, field := range structType.Fields.List {
+ tagName, tagOptions := "", ""
+ if field.Tag != nil {
+ tag, err := strconv.Unquote(field.Tag.Value)
+ if err != nil {
+ return nil, fmt.Errorf("unquote tag in %s.%s: %w", pkg.name, typeName, err)
+ }
+ tagName, tagOptions, _ = strings.Cut(reflect.StructTag(tag).Get("json"), ",")
+ }
+ for _, name := range field.Names {
+ if !name.IsExported() || tagName == "-" {
+ continue
+ }
+ jsonName := tagName
+ if jsonName == "" {
+ jsonName = name.Name
+ }
+ description := jsonName + " " + types.ExprString(field.Type)
+ if strings.Contains(","+tagOptions+",", ",omitempty,") {
+ description += " omitempty"
+ }
+ fields = append(fields, description)
+ }
+ }
+ if len(fields) == 0 {
+ return nil, fmt.Errorf("%s.%s has no JSON fields", pkg.name, typeName)
+ }
+ sort.Strings(fields)
+
+ return &extractedItem{
+ Area: "notification_payload",
+ Detail: strings.Join(fields, "; "),
+ ID: id,
+ Source: fmt.Sprintf("%s:%s.%s", file.path, pkg.name, typeName),
+ }, nil
+}
+
+// extractDriverInterfaces returns one item per method or embedded interface
+// of every exported interface in riverdriver.
+func extractDriverInterfaces(root string) ([]*extractedItem, error) {
+ pkg, err := parseGoPackage(root, "riverdriver")
+ if err != nil {
+ return nil, err
+ }
+ return interfaceItems(pkg, "driver", "driver.",
+ []string{"Driver", "Executor", "ExecutorTx", "Listener"},
+ func(string) bool { return true },
+ )
+}
+
+// extractExtensionInterfaces returns one item per method or embedded interface
+// of the extension seam in riverpilot and the hook, middleware, and plugin
+// interfaces in rivertype.
+func extractExtensionInterfaces(root string) ([]*extractedItem, error) {
+ pilotPkg, err := parseGoPackage(root, "rivershared/riverpilot")
+ if err != nil {
+ return nil, err
+ }
+ pilotItems, err := interfaceItems(pilotPkg, "extension", "extension.riverpilot.",
+ []string{"Pilot", "PilotJobRescuer", "PilotPeriodicJob"},
+ func(string) bool { return true },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ typePkg, err := parseGoPackage(root, "rivertype")
+ if err != nil {
+ return nil, err
+ }
+ typeItems, err := interfaceItems(typePkg, "extension", "extension.rivertype.",
+ []string{"Hook", "HookInsertBegin", "HookMetricEmit", "HookPeriodicJobsStart", "HookWorkBegin", "HookWorkEnd", "JobInsertMiddleware", "Middleware", "Plugin", "WorkerMiddleware"},
+ func(name string) bool {
+ return strings.HasPrefix(name, "Hook") || strings.HasSuffix(name, "Middleware") || name == "Plugin"
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return append(pilotItems, typeItems...), nil
+}
+
+// interfaceItems returns items for exported interfaces in pkg accepted by
+// include. Every name in required must be present.
+func interfaceItems(pkg *goPackage, area, idPrefix string, required []string, include func(string) bool) ([]*extractedItem, error) {
+ var (
+ found = make(map[string]struct{})
+ items []*extractedItem
+ )
+ for _, file := range pkg.files {
+ for _, decl := range file.file.Decls {
+ genDecl, ok := decl.(*ast.GenDecl)
+ if !ok || genDecl.Tok != token.TYPE {
+ continue
+ }
+ for _, spec := range genDecl.Specs {
+ typeSpec := spec.(*ast.TypeSpec) //nolint:forcetypeassert // TYPE declarations only contain TypeSpecs
+ interfaceType, ok := typeSpec.Type.(*ast.InterfaceType)
+ if !ok || !typeSpec.Name.IsExported() || !include(typeSpec.Name.Name) {
+ continue
+ }
+ found[typeSpec.Name.Name] = struct{}{}
+ for _, method := range interfaceType.Methods.List {
+ if len(method.Names) == 0 {
+ embedded := types.ExprString(method.Type)
+ items = append(items, &extractedItem{
+ Area: area,
+ Detail: "embeds " + embedded,
+ ID: idPrefix + typeSpec.Name.Name + "." + embedded,
+ Source: fmt.Sprintf("%s:%s.%s", file.path, pkg.name, typeSpec.Name.Name),
+ })
+ continue
+ }
+ for _, name := range method.Names {
+ items = append(items, &extractedItem{
+ Area: area,
+ Detail: types.ExprString(method.Type),
+ ID: idPrefix + typeSpec.Name.Name + "." + name.Name,
+ Source: fmt.Sprintf("%s:%s.%s.%s", file.path, pkg.name, typeSpec.Name.Name, name.Name),
+ })
+ }
+ }
+ }
+ }
+ }
+ for _, name := range required {
+ if _, ok := found[name]; !ok {
+ return nil, fmt.Errorf("interface %s.%s not found in %s", pkg.name, name, pkg.dir)
+ }
+ }
+ return items, nil
+}
+
+// extractRootFunctions returns one item per exported top-level function of
+// the root package, such as worker registration helpers and the functions a
+// worker calls to snooze, cancel, or record output.
+func extractRootFunctions(rootPkg *goPackage) ([]*extractedItem, error) {
+ var items []*extractedItem
+ for _, file := range rootPkg.files {
+ for _, decl := range file.file.Decls {
+ funcDecl, ok := decl.(*ast.FuncDecl)
+ if !ok || funcDecl.Recv != nil || !funcDecl.Name.IsExported() {
+ continue
+ }
+ items = append(items, &extractedItem{
+ Area: "function",
+ Detail: funcSignature(funcDecl.Type),
+ ID: "function." + funcDecl.Name.Name,
+ Source: fmt.Sprintf("%s:%s.%s", file.path, rootPkg.name, funcDecl.Name.Name),
+ })
+ }
+ }
+ if len(items) == 0 {
+ return nil, fmt.Errorf("no exported functions found in %s", rootPkg.dir)
+ }
+ return items, nil
+}
+
+// extractRootInterfaces returns one item per method or embedded interface of
+// every exported interface in the root package, such as the optional
+// interfaces job args and workers implement.
+func extractRootInterfaces(rootPkg *goPackage) ([]*extractedItem, error) {
+ return interfaceItems(rootPkg, "interface", "interface.",
+ []string{"JobArgs", "JobArgsWithKindAliases", "Worker"},
+ func(string) bool { return true },
+ )
+}
+
+// extractRivertypeFields returns one item per exported field of every exported
+// struct type in rivertype, such as the job row and attempt error shapes every
+// implementation reads and writes.
+func extractRivertypeFields(root string) ([]*extractedItem, error) {
+ pkg, err := parseGoPackage(root, "rivertype")
+ if err != nil {
+ return nil, err
+ }
+
+ var (
+ found = make(map[string]struct{})
+ items []*extractedItem
+ )
+ for _, file := range pkg.files {
+ for _, decl := range file.file.Decls {
+ genDecl, ok := decl.(*ast.GenDecl)
+ if !ok || genDecl.Tok != token.TYPE {
+ continue
+ }
+ for _, spec := range genDecl.Specs {
+ typeSpec := spec.(*ast.TypeSpec) //nolint:forcetypeassert // TYPE declarations only contain TypeSpecs
+ structType, ok := typeSpec.Type.(*ast.StructType)
+ if !ok || !typeSpec.Name.IsExported() {
+ continue
+ }
+ found[typeSpec.Name.Name] = struct{}{}
+ for _, field := range structType.Fields.List {
+ names := field.Names
+ if len(names) == 0 {
+ // An embedded field is named after its type.
+ names = []*ast.Ident{ast.NewIdent(receiverTypeName(field.Type))}
+ }
+ for _, name := range names {
+ if !name.IsExported() {
+ continue
+ }
+ items = append(items, &extractedItem{
+ Area: "rivertype_field",
+ Detail: types.ExprString(field.Type),
+ ID: "rivertype_field." + typeSpec.Name.Name + "." + name.Name,
+ Source: fmt.Sprintf("%s:%s.%s.%s", file.path, pkg.name, typeSpec.Name.Name, name.Name),
+ })
+ }
+ }
+ }
+ }
+ }
+ for _, name := range []string{"AttemptError", "JobRow", "Queue"} {
+ if _, ok := found[name]; !ok {
+ return nil, fmt.Errorf("struct %s.%s not found in %s", pkg.name, name, pkg.dir)
+ }
+ }
+ return items, nil
+}
+
+var migrationFilePattern = regexp.MustCompile(`^(\d+)_([a-z0-9_]+)\.(up|down)\.sql$`)
+
+// extractMigrations returns one item per main-line migration version for each
+// backend, with a content digest so edits to a shipped migration are visible.
+func extractMigrations(root string) ([]*extractedItem, error) {
+ var items []*extractedItem
+ for _, backend := range []struct {
+ dir string
+ name string
+ }{
+ {dir: "riverdriver/riverpgxv5/migration/main", name: "postgres"},
+ {dir: "riverdriver/riversqlite/migration/main", name: "sqlite"},
+ } {
+ entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(backend.dir)))
+ if err != nil {
+ return nil, fmt.Errorf("read migrations %s: %w", backend.dir, err)
+ }
+
+ type migration struct {
+ digests map[string]string
+ name string
+ }
+ migrations := make(map[string]*migration)
+ for _, entry := range entries {
+ match := migrationFilePattern.FindStringSubmatch(entry.Name())
+ if entry.IsDir() || match == nil {
+ continue
+ }
+ version, name, direction := match[1], match[2], match[3]
+ contents, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(backend.dir), entry.Name()))
+ if err != nil {
+ return nil, fmt.Errorf("read migration %s/%s: %w", backend.dir, entry.Name(), err)
+ }
+ current, ok := migrations[version]
+ if !ok {
+ current = &migration{digests: make(map[string]string), name: name}
+ migrations[version] = current
+ }
+ if current.name != name {
+ return nil, fmt.Errorf("migration %s/%s has conflicting names %q and %q", backend.dir, version, current.name, name)
+ }
+ digest := sha256.Sum256(contents)
+ current.digests[direction] = hex.EncodeToString(digest[:])[:12]
+ }
+ if len(migrations) == 0 {
+ return nil, fmt.Errorf("no migrations found in %s", backend.dir)
+ }
+
+ for _, version := range sortedKeys(migrations) {
+ current := migrations[version]
+ var detail strings.Builder
+ detail.WriteString(current.name)
+ for _, direction := range []string{"up", "down"} {
+ digest, ok := current.digests[direction]
+ if !ok {
+ return nil, fmt.Errorf("migration %s/%s_%s is missing its %s file", backend.dir, version, current.name, direction)
+ }
+ detail.WriteString(" " + direction + ":" + digest)
+ }
+ items = append(items, &extractedItem{
+ Area: "migration",
+ Detail: detail.String(),
+ ID: "migration." + backend.name + "." + version,
+ Source: backend.dir + "/" + version + "_" + current.name + ".{up,down}.sql",
+ })
+ }
+ }
+ return items, nil
+}
+
+// funcSignature renders a function declaration's type, including any type
+// parameters.
+func funcSignature(funcType *ast.FuncType) string {
+ signature := types.ExprString(funcType)
+ if funcType.TypeParams == nil || len(funcType.TypeParams.List) == 0 {
+ return signature
+ }
+ params := make([]string, 0, len(funcType.TypeParams.List))
+ for _, field := range funcType.TypeParams.List {
+ names := make([]string, 0, len(field.Names))
+ for _, name := range field.Names {
+ names = append(names, name.Name)
+ }
+ params = append(params, strings.Join(names, ", ")+" "+types.ExprString(field.Type))
+ }
+ return "func[" + strings.Join(params, ", ") + "]" + strings.TrimPrefix(signature, "func")
+}
+
+// genericBaseName strips instantiation arguments from a reflected type name.
+func genericBaseName(name string) string {
+ base, _, _ := strings.Cut(name, "[")
+ return base
+}
+
+// receiverTypeName returns the base type name of a method receiver.
+func receiverTypeName(expr ast.Expr) string {
+ switch typed := expr.(type) {
+ case *ast.StarExpr:
+ return receiverTypeName(typed.X)
+ case *ast.IndexExpr:
+ return receiverTypeName(typed.X)
+ case *ast.IndexListExpr:
+ return receiverTypeName(typed.X)
+ case *ast.Ident:
+ return typed.Name
+ }
+ return ""
+}
+
+// reflectFuncSignature renders a function type, skipping the first skip
+// parameters (such as a method receiver).
+func reflectFuncSignature(funcType reflect.Type, skip int) string {
+ params := make([]string, 0, funcType.NumIn())
+ for i := skip; i < funcType.NumIn(); i++ {
+ if funcType.IsVariadic() && i == funcType.NumIn()-1 {
+ params = append(params, "..."+reflectTypeString(funcType.In(i).Elem()))
+ continue
+ }
+ params = append(params, reflectTypeString(funcType.In(i)))
+ }
+ results := make([]string, 0, funcType.NumOut())
+ for out := range funcType.Outs() {
+ results = append(results, reflectTypeString(out))
+ }
+
+ signature := "func(" + strings.Join(params, ", ") + ")"
+ switch len(results) {
+ case 0:
+ case 1:
+ signature += " " + results[0]
+ default:
+ signature += " (" + strings.Join(results, ", ") + ")"
+ }
+ return signature
+}
+
+// reflectTypeString renders a reflected type, replacing the transaction type
+// used to instantiate generic River types with its type parameter name.
+func reflectTypeString(typ reflect.Type) string {
+ return strings.NewReplacer("github.com/jackc/pgx/v5.Tx", "TTx", "pgx.Tx", "TTx").Replace(typ.String())
+}
+
+// stringLiteral returns the value of a Go string literal expression.
+func stringLiteral(expr ast.Expr) (string, bool) {
+ basicLit, ok := expr.(*ast.BasicLit)
+ if !ok || basicLit.Kind != token.STRING {
+ return "", false
+ }
+ value, err := strconv.Unquote(basicLit.Value)
+ if err != nil {
+ return "", false
+ }
+ return value, true
+}
diff --git a/internal/cmd/generatefeatureinventory/inventory.go b/internal/cmd/generatefeatureinventory/inventory.go
new file mode 100644
index 000000000..112d58a71
--- /dev/null
+++ b/internal/cmd/generatefeatureinventory/inventory.go
@@ -0,0 +1,441 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "slices"
+ "sort"
+ "strings"
+)
+
+// applicability classifies how an inventory item relates to cross-language
+// compatibility.
+type applicability string
+
+const (
+ applicabilityAPIEquivalent applicability = "api_equivalent"
+ applicabilityDriverSpecific applicability = "driver_specific"
+ applicabilityInternal applicability = "internal"
+ applicabilityNotApplicable applicability = "not_applicable"
+ applicabilityProtocolVisible applicability = "protocol_visible"
+ applicabilityUnclassified applicability = "unclassified"
+)
+
+// applicabilityOrder is the display order of applicabilities in the matrix
+// summary. It also serves as the set of valid values.
+func applicabilityOrder() []applicability {
+ return []applicability{
+ applicabilityProtocolVisible,
+ applicabilityAPIEquivalent,
+ applicabilityDriverSpecific,
+ applicabilityInternal,
+ applicabilityNotApplicable,
+ applicabilityUnclassified,
+ }
+}
+
+// areaInfo describes one inventory area and its matrix section.
+type areaInfo struct {
+ description string
+ name string
+}
+
+// areaInfos returns every known area in matrix order.
+func areaInfos() []areaInfo {
+ return []areaInfo{
+ {name: "config", description: "Exported fields of `river.Config`."},
+ {name: "insert_opts", description: "Exported fields of `river.InsertOpts`."},
+ {name: "unique_opts", description: "Exported fields of `river.UniqueOpts`."},
+ {name: "queue_config", description: "Exported fields of `river.QueueConfig`."},
+ {name: "periodic_job_opts", description: "Exported fields of `river.PeriodicJobOpts`."},
+ {name: "client", description: "Exported methods of `*river.Client[TTx]`."},
+ {name: "job_list_params", description: "Exported builder methods of `*river.JobListParams`."},
+ {name: "job_delete_many_params", description: "Exported builder methods of `*river.JobDeleteManyParams`."},
+ {name: "queue_list_params", description: "Exported builder methods of `*river.QueueListParams`."},
+ {name: "job_state", description: "Values of `rivertype.JobStates()`."},
+ {name: "event_kind", description: "Exported `river.EventKind*` constants."},
+ {name: "metadata_key", description: "Reserved job metadata keys written or read by River, from Go constants, Go metadata helpers, and driver SQL."},
+ {name: "notification_topic", description: "Notification topics declared by `internal/notifier`."},
+ {name: "notification_payload", description: "Notification payload shapes and action values, from Go payload structs and `pg_notify` SQL."},
+ {name: "driver", description: "Methods of the exported `riverdriver` interfaces."},
+ {name: "extension", description: "Methods of the extension interfaces in `rivershared/riverpilot` and the hook, middleware, and plugin interfaces in `rivertype`."},
+ {name: "interface", description: "Methods of the exported interfaces in the `river` package, such as the optional interfaces job args and workers implement."},
+ {name: "function", description: "Exported functions of the `river` package."},
+ {name: "rivertype_field", description: "Exported fields of the exported structs in `rivertype`."},
+ {name: "migration", description: "Main-line migrations for PostgreSQL and SQLite."},
+ }
+}
+
+// extractedItem is an item as derived from Go and SQL sources. It carries only
+// the generated fields of an inventory item.
+type extractedItem struct {
+ Area string
+ Detail string
+ ID string
+ Source string
+}
+
+// inventory is the checked-in feature inventory document.
+type inventory struct {
+ Schema string `json:"$schema"`
+ Items []*inventoryItem `json:"items"`
+ ProtocolRevision int `json:"protocol_revision"`
+}
+
+// inventoryItem is one entry in the feature inventory. Area, Detail, ID, and
+// Source are generated; Applicability, Gap, Rationale, and Scenarios are
+// maintained by people and preserved across regeneration.
+type inventoryItem struct {
+ Applicability applicability `json:"applicability"`
+ Area string `json:"area"`
+ Detail string `json:"detail"`
+ // Gap explains why a protocol-visible item has no shared scenario yet.
+ // It keeps an uncovered item visible in the matrix instead of hiding it
+ // behind a weaker classification.
+ Gap string `json:"gap,omitempty"`
+ ID string `json:"id"`
+ Rationale string `json:"rationale,omitempty"`
+ Scenarios []string `json:"scenarios,omitempty"`
+ Source string `json:"source"`
+}
+
+// mergeReport describes the effect of merging extracted items into an
+// existing inventory.
+type mergeReport struct {
+ Added []string
+ Removed []string
+}
+
+// scenarioOwner is the registry binding for one executable scenario.
+type scenarioOwner struct {
+ Owner string
+ Tier string
+}
+
+const (
+ inventorySchemaRef = "schema/feature-inventory.schema.json"
+ defaultProtocolRevision = 1
+)
+
+// decodeInventory parses an inventory document.
+func decodeInventory(data []byte) (*inventory, error) {
+ var inv inventory
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&inv); err != nil {
+ return nil, fmt.Errorf("decode inventory: %w", err)
+ }
+ return &inv, nil
+}
+
+// encodeInventory renders an inventory in its canonical form.
+func encodeInventory(inv *inventory) ([]byte, error) {
+ var buf bytes.Buffer
+ encoder := json.NewEncoder(&buf)
+ encoder.SetEscapeHTML(false)
+ encoder.SetIndent("", " ")
+ if err := encoder.Encode(inv); err != nil {
+ return nil, fmt.Errorf("encode inventory: %w", err)
+ }
+ return buf.Bytes(), nil
+}
+
+// mergeInventory combines freshly extracted items with an existing inventory.
+// Generated fields always come from extraction, human-maintained fields are kept
+// for IDs that still exist, new IDs are added as unclassified, and IDs that are
+// no longer extracted are dropped. The result is sorted by ID.
+func mergeInventory(existing *inventory, extracted []*extractedItem) (*inventory, *mergeReport) {
+ existingByID := make(map[string]*inventoryItem)
+ protocolRevision := defaultProtocolRevision
+ if existing != nil {
+ for _, item := range existing.Items {
+ if _, ok := existingByID[item.ID]; !ok {
+ existingByID[item.ID] = item
+ }
+ }
+ if existing.ProtocolRevision > 0 {
+ protocolRevision = existing.ProtocolRevision
+ }
+ }
+
+ report := &mergeReport{}
+ extractedIDs := make(map[string]struct{}, len(extracted))
+ merged := &inventory{
+ Items: make([]*inventoryItem, 0, len(extracted)),
+ ProtocolRevision: protocolRevision,
+ Schema: inventorySchemaRef,
+ }
+ for _, extractedItem := range extracted {
+ extractedIDs[extractedItem.ID] = struct{}{}
+ item := &inventoryItem{
+ Applicability: applicabilityUnclassified,
+ Area: extractedItem.Area,
+ Detail: extractedItem.Detail,
+ ID: extractedItem.ID,
+ Source: extractedItem.Source,
+ }
+ if previous, ok := existingByID[extractedItem.ID]; ok {
+ item.Applicability = previous.Applicability
+ item.Gap = previous.Gap
+ item.Rationale = previous.Rationale
+ item.Scenarios = normalizeScenarios(previous.Scenarios)
+ } else {
+ report.Added = append(report.Added, extractedItem.ID)
+ }
+ merged.Items = append(merged.Items, item)
+ }
+ for id := range existingByID {
+ if _, ok := extractedIDs[id]; !ok {
+ report.Removed = append(report.Removed, id)
+ }
+ }
+
+ sort.Slice(merged.Items, func(i, j int) bool { return merged.Items[i].ID < merged.Items[j].ID })
+ sort.Strings(report.Added)
+ sort.Strings(report.Removed)
+ return merged, report
+}
+
+// normalizeScenarios sorts and deduplicates scenario IDs.
+func normalizeScenarios(scenarios []string) []string {
+ if len(scenarios) == 0 {
+ return nil
+ }
+ normalized := slices.Clone(scenarios)
+ sort.Strings(normalized)
+ return slices.Compact(normalized)
+}
+
+// diffInventory compares a checked-in inventory against extracted items and
+// returns a problem for every missing, stale, duplicate, or out-of-date item.
+func diffInventory(existing *inventory, extracted []*extractedItem) []string {
+ var (
+ duplicates []string
+ fileByID = make(map[string]*inventoryItem, len(existing.Items))
+ problems []string
+ )
+ for _, item := range existing.Items {
+ if _, ok := fileByID[item.ID]; ok {
+ duplicates = append(duplicates, item.ID)
+ continue
+ }
+ fileByID[item.ID] = item
+ }
+ if len(duplicates) > 0 {
+ problems = append(problems, "duplicate item IDs: "+strings.Join(sortedUnique(duplicates), ", "))
+ }
+
+ var (
+ changed []string
+ missing []string
+ seen = make(map[string]struct{}, len(extracted))
+ stale []string
+ )
+ for _, extractedItem := range extracted {
+ seen[extractedItem.ID] = struct{}{}
+ item, ok := fileByID[extractedItem.ID]
+ if !ok {
+ missing = append(missing, extractedItem.ID)
+ continue
+ }
+ var fields []string
+ if item.Area != extractedItem.Area {
+ fields = append(fields, fmt.Sprintf("area %q != %q", item.Area, extractedItem.Area))
+ }
+ if item.Detail != extractedItem.Detail {
+ fields = append(fields, fmt.Sprintf("detail %q != %q", item.Detail, extractedItem.Detail))
+ }
+ if item.Source != extractedItem.Source {
+ fields = append(fields, fmt.Sprintf("source %q != %q", item.Source, extractedItem.Source))
+ }
+ if len(fields) > 0 {
+ changed = append(changed, extractedItem.ID+" ("+strings.Join(fields, "; ")+")")
+ }
+ }
+ for id := range fileByID {
+ if _, ok := seen[id]; !ok {
+ stale = append(stale, id)
+ }
+ }
+
+ sort.Strings(changed)
+ sort.Strings(missing)
+ sort.Strings(stale)
+ if len(missing) > 0 {
+ problems = append(problems, "extracted items missing from the inventory: "+strings.Join(missing, ", "))
+ }
+ if len(stale) > 0 {
+ problems = append(problems, "stale inventory items no longer extracted: "+strings.Join(stale, ", "))
+ }
+ for _, change := range changed {
+ problems = append(problems, "generated fields differ for "+change)
+ }
+ return problems
+}
+
+// validateClassifications checks the human-maintained fields of every item.
+// knownScenarios is the set of scenario IDs declared by the scenario catalogs
+// and registry maps each executable scenario ID to its owning test.
+func validateClassifications(inv *inventory, knownScenarios map[string]struct{}, registry map[string]scenarioOwner) []string {
+ validApplicability := make(map[applicability]struct{})
+ for _, value := range applicabilityOrder() {
+ validApplicability[value] = struct{}{}
+ }
+
+ var (
+ invalid []string
+ missingRationale []string
+ missingScenarios []string
+ unclassified []string
+ unknownScenarios = make(map[string][]string)
+ unregistered = make(map[string][]string)
+ )
+ for _, item := range inv.Items {
+ switch _, ok := validApplicability[item.Applicability]; {
+ case !ok:
+ invalid = append(invalid, fmt.Sprintf("%s (%q)", item.ID, item.Applicability))
+ case item.Applicability == applicabilityUnclassified:
+ unclassified = append(unclassified, item.ID)
+ case item.Applicability == applicabilityProtocolVisible:
+ if len(item.Scenarios) == 0 && strings.TrimSpace(item.Gap) == "" {
+ missingScenarios = append(missingScenarios, item.ID)
+ }
+ default:
+ if strings.TrimSpace(item.Rationale) == "" {
+ missingRationale = append(missingRationale, item.ID)
+ }
+ }
+ for _, scenario := range item.Scenarios {
+ if _, ok := knownScenarios[scenario]; !ok {
+ unknownScenarios[scenario] = append(unknownScenarios[scenario], item.ID)
+ }
+ if _, ok := registry[scenario]; !ok {
+ unregistered[scenario] = append(unregistered[scenario], item.ID)
+ }
+ }
+ }
+
+ var problems []string
+ if len(invalid) > 0 {
+ problems = append(problems, "invalid applicability: "+strings.Join(invalid, ", "))
+ }
+ if len(unclassified) > 0 {
+ problems = append(problems, "unclassified items (set applicability and rationale/scenarios): "+strings.Join(unclassified, ", "))
+ }
+ if len(missingScenarios) > 0 {
+ problems = append(problems, "protocol_visible items without scenarios or a recorded gap: "+strings.Join(missingScenarios, ", "))
+ }
+ if len(missingRationale) > 0 {
+ problems = append(problems, "non-protocol items without a rationale: "+strings.Join(missingRationale, ", "))
+ }
+ for _, scenario := range sortedKeys(unknownScenarios) {
+ problems = append(problems, fmt.Sprintf("scenario %q is not declared in conformance/scenarios/*.json (referenced by %s)", scenario, strings.Join(unknownScenarios[scenario], ", ")))
+ }
+ for _, scenario := range sortedKeys(unregistered) {
+ problems = append(problems, fmt.Sprintf("scenario %q has no owner in the harness scenario registry (referenced by %s)", scenario, strings.Join(unregistered[scenario], ", ")))
+ }
+ return problems
+}
+
+// renderMatrix renders the feature matrix from a fixed header and the
+// inventory. Output is deterministic for a given input.
+func renderMatrix(header string, inv *inventory, registry map[string]scenarioOwner) string {
+ var sb strings.Builder
+ sb.WriteString(strings.TrimRight(header, "\n"))
+ sb.WriteString("\n")
+
+ itemsByArea := make(map[string][]*inventoryItem)
+ for _, item := range inv.Items {
+ itemsByArea[item.Area] = append(itemsByArea[item.Area], item)
+ }
+ areas := areaInfos()
+ knownAreas := make(map[string]struct{}, len(areas))
+ for _, area := range areas {
+ knownAreas[area.name] = struct{}{}
+ }
+ for _, name := range sortedKeys(itemsByArea) {
+ if _, ok := knownAreas[name]; !ok {
+ areas = append(areas, areaInfo{name: name})
+ }
+ }
+
+ sb.WriteString("\n## Summary\n\n")
+ sb.WriteString("| Area |")
+ for _, value := range applicabilityOrder() {
+ sb.WriteString(" `" + string(value) + "` |")
+ }
+ sb.WriteString(" Total |\n|---|")
+ for range applicabilityOrder() {
+ sb.WriteString("---:|")
+ }
+ sb.WriteString("---:|\n")
+ for _, area := range areas {
+ items := itemsByArea[area.name]
+ if len(items) == 0 {
+ continue
+ }
+ counts := make(map[applicability]int)
+ for _, item := range items {
+ counts[item.Applicability]++
+ }
+ sb.WriteString("| [`" + area.name + "`](#" + area.name + ") |")
+ for _, value := range applicabilityOrder() {
+ fmt.Fprintf(&sb, " %d |", counts[value])
+ }
+ fmt.Fprintf(&sb, " %d |\n", len(items))
+ }
+
+ for _, area := range areas {
+ items := itemsByArea[area.name]
+ if len(items) == 0 {
+ continue
+ }
+ sb.WriteString("\n## " + area.name + "\n\n")
+ if area.description != "" {
+ sb.WriteString(area.description + "\n\n")
+ }
+ sb.WriteString("| Item | Applicability | Scenarios (owner test) | Notes |\n|---|---|---|---|\n")
+ for _, item := range items {
+ scenarios := make([]string, 0, len(item.Scenarios))
+ for _, scenario := range item.Scenarios {
+ owner := "unregistered"
+ if binding, ok := registry[scenario]; ok {
+ owner = binding.Owner
+ }
+ scenarios = append(scenarios, "`"+scenario+"` ("+owner+")")
+ }
+ if item.Gap != "" {
+ scenarios = append(scenarios, "**Gap:** "+markdownCell(item.Gap))
+ }
+ fmt.Fprintf(&sb, "| `%s` | %s | %s | %s |\n",
+ item.ID,
+ item.Applicability,
+ strings.Join(scenarios, "
"),
+ markdownCell(item.Rationale),
+ )
+ }
+ }
+ return sb.String()
+}
+
+// markdownCell makes text safe for a single Markdown table cell.
+func markdownCell(text string) string {
+ text = strings.Join(strings.Fields(text), " ")
+ return strings.ReplaceAll(text, "|", `\|`)
+}
+
+func sortedKeys[V any](values map[string]V) []string {
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+func sortedUnique(values []string) []string {
+ sorted := slices.Clone(values)
+ sort.Strings(sorted)
+ return slices.Compact(sorted)
+}
diff --git a/internal/cmd/generatefeatureinventory/main.go b/internal/cmd/generatefeatureinventory/main.go
new file mode 100644
index 000000000..d8eee4363
--- /dev/null
+++ b/internal/cmd/generatefeatureinventory/main.go
@@ -0,0 +1,335 @@
+// Command generatefeatureinventory maintains River's cross-language feature
+// inventory, a drift gate between the Go implementation and the conformance
+// program.
+//
+// It derives an inventory of Go-visible River features (configuration fields,
+// insert options, client methods, exported functions and interfaces, job
+// states, event kinds, reserved metadata keys, notification topics and
+// payloads, driver and extension interfaces, `rivertype` struct fields,
+// migrations, and query parameter builders) from Go reflection, Go syntax
+// trees, and driver SQL. Each derived item is merged into
+// conformance/feature-inventory.json, where people classify it for
+// cross-language compatibility and link it to executable conformance
+// scenarios. conformance/feature-matrix.md is rendered from the result.
+//
+// Without flags the command rewrites both files, adding new items as
+// "unclassified" and dropping items that no longer exist, then exits non-zero
+// if any item still needs attention. With -check it writes nothing and fails
+// if either file is out of date or any classification is incomplete, so CI
+// fails whenever a Go feature is added or removed without being classified.
+package main
+
+import (
+ "bytes"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+const (
+ harnessDir = "conformance/harness"
+ inventoryPath = "conformance/feature-inventory.json"
+ matrixPath = "conformance/feature-matrix.md"
+ scenarioCatalogDir = "conformance/scenarios"
+)
+
+//go:embed matrix_header.md
+var matrixHeader string
+
+func main() {
+ check := flag.Bool("check", false, "check the inventory and matrix without writing")
+ root := flag.String("root", ".", "repository root")
+ flag.Parse()
+
+ problems, err := run(*root, *check)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "generatefeatureinventory:", err)
+ os.Exit(1)
+ }
+ if len(problems) > 0 {
+ fmt.Fprintln(os.Stderr, "generatefeatureinventory: feature inventory needs attention:")
+ for _, problem := range problems {
+ fmt.Fprintln(os.Stderr, " - "+problem)
+ }
+ if *check {
+ fmt.Fprintln(os.Stderr, "Run `go run ./internal/cmd/generatefeatureinventory`, then classify any unclassified items in "+inventoryPath+".")
+ }
+ os.Exit(1)
+ }
+}
+
+// run extracts the inventory from the repository at root and either checks or
+// rewrites the checked-in files. It returns problems that need a developer's
+// attention; an error means the command itself could not complete.
+func run(root string, check bool) ([]string, error) {
+ extracted, err := extractAll(root)
+ if err != nil {
+ return nil, err
+ }
+ knownScenarios, err := loadScenarioCatalogs(root)
+ if err != nil {
+ return nil, err
+ }
+ registry, err := loadScenarioRegistry(root)
+ if err != nil {
+ return nil, err
+ }
+
+ existingBytes, err := os.ReadFile(filepath.Join(root, inventoryPath))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, fmt.Errorf("read %s: %w", inventoryPath, err)
+ }
+ var existing *inventory
+ if existingBytes != nil {
+ if existing, err = decodeInventory(existingBytes); err != nil {
+ return nil, fmt.Errorf("%s: %w", inventoryPath, err)
+ }
+ }
+
+ if check {
+ return checkFiles(root, existing, existingBytes, extracted, knownScenarios, registry)
+ }
+
+ merged, report := mergeInventory(existing, extracted)
+ inventoryBytes, err := encodeInventory(merged)
+ if err != nil {
+ return nil, err
+ }
+ if err := writeFile(filepath.Join(root, inventoryPath), inventoryBytes); err != nil {
+ return nil, err
+ }
+ if err := writeFile(filepath.Join(root, matrixPath), []byte(renderMatrix(matrixHeader, merged, registry))); err != nil {
+ return nil, err
+ }
+ if len(report.Added) > 0 {
+ fmt.Fprintln(os.Stderr, "added (unclassified): "+strings.Join(report.Added, ", "))
+ }
+ if len(report.Removed) > 0 {
+ fmt.Fprintln(os.Stderr, "removed: "+strings.Join(report.Removed, ", "))
+ }
+ return validateClassifications(merged, knownScenarios, registry), nil
+}
+
+// checkFiles verifies the checked-in inventory and matrix without writing.
+func checkFiles(root string, existing *inventory, existingBytes []byte, extracted []*extractedItem, knownScenarios map[string]struct{}, registry map[string]scenarioOwner) ([]string, error) {
+ if existing == nil {
+ return []string{inventoryPath + " does not exist"}, nil
+ }
+
+ problems := diffInventory(existing, extracted)
+ problems = append(problems, validateClassifications(existing, knownScenarios, registry)...)
+
+ canonical, err := encodeInventory(existing)
+ if err != nil {
+ return nil, err
+ }
+ sortedByID := sort.SliceIsSorted(existing.Items, func(i, j int) bool { return existing.Items[i].ID < existing.Items[j].ID })
+ if !bytes.Equal(canonical, existingBytes) || !sortedByID || existing.Schema != inventorySchemaRef {
+ problems = append(problems, inventoryPath+" is not in canonical form (sorted by ID, normalized formatting)")
+ }
+
+ // Render the matrix from the merged inventory so that a stale matrix is
+ // reported even when the inventory itself also has problems.
+ merged, _ := mergeInventory(existing, extracted)
+ matrixBytes, err := os.ReadFile(filepath.Join(root, matrixPath))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, fmt.Errorf("read %s: %w", matrixPath, err)
+ }
+ if string(matrixBytes) != renderMatrix(matrixHeader, merged, registry) {
+ problems = append(problems, matrixPath+" is out of date")
+ }
+ return problems, nil
+}
+
+// loadScenarioCatalogs returns every scenario ID declared by the scenario
+// catalogs.
+func loadScenarioCatalogs(root string) (map[string]struct{}, error) {
+ matches, err := filepath.Glob(filepath.Join(root, filepath.FromSlash(scenarioCatalogDir), "*.json"))
+ if err != nil {
+ return nil, fmt.Errorf("glob %s: %w", scenarioCatalogDir, err)
+ }
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("no scenario catalogs found in %s", scenarioCatalogDir)
+ }
+
+ scenarios := make(map[string]struct{})
+ for _, match := range matches {
+ contents, err := os.ReadFile(match)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", match, err)
+ }
+ var catalog struct {
+ Scenarios []struct {
+ Name string `json:"name"`
+ } `json:"scenarios"`
+ }
+ if err := json.Unmarshal(contents, &catalog); err != nil {
+ return nil, fmt.Errorf("decode %s: %w", match, err)
+ }
+ for _, scenario := range catalog.Scenarios {
+ scenarios[scenario.Name] = struct{}{}
+ }
+ }
+ return scenarios, nil
+}
+
+// loadScenarioRegistry parses the harness test files for the executable
+// scenario registry.
+func loadScenarioRegistry(root string) (map[string]scenarioOwner, error) {
+ matches, err := filepath.Glob(filepath.Join(root, filepath.FromSlash(harnessDir), "*_test.go"))
+ if err != nil {
+ return nil, fmt.Errorf("glob %s: %w", harnessDir, err)
+ }
+ sort.Strings(matches)
+
+ sources := make(map[string][]byte, len(matches))
+ for _, match := range matches {
+ contents, err := os.ReadFile(match)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", match, err)
+ }
+ sources[path.Join(harnessDir, filepath.Base(match))] = contents
+ }
+ return parseScenarioRegistry(sources)
+}
+
+// parseScenarioRegistry extracts scenario bindings from Go sources keyed by
+// path. It collects string constants from every file (for owner names) and
+// every `map[string]scenarioBinding` composite literal whose entries use
+// string keys.
+func parseScenarioRegistry(sources map[string][]byte) (map[string]scenarioOwner, error) {
+ fset := token.NewFileSet()
+ files := make([]*goFile, 0, len(sources))
+ for _, filePath := range sortedKeys(sources) {
+ file, err := parser.ParseFile(fset, filePath, sources[filePath], parser.SkipObjectResolution)
+ if err != nil {
+ return nil, fmt.Errorf("parse %s: %w", filePath, err)
+ }
+ files = append(files, &goFile{file: file, path: filePath})
+ }
+
+ constants := make(map[string]string)
+ for _, file := range files {
+ for _, constDecl := range fileStringConsts(file.file.Name.Name, file) {
+ constants[constDecl.name] = constDecl.value
+ }
+ }
+
+ var (
+ inspectErr error
+ registry = make(map[string]scenarioOwner)
+ )
+ for _, file := range files {
+ ast.Inspect(file.file, func(node ast.Node) bool {
+ if inspectErr != nil {
+ return false
+ }
+ compositeLit, ok := node.(*ast.CompositeLit)
+ if !ok || !isScenarioBindingMap(compositeLit.Type) {
+ return true
+ }
+ for _, elt := range compositeLit.Elts {
+ keyValue, isKeyValue := elt.(*ast.KeyValueExpr)
+ if !isKeyValue {
+ continue
+ }
+ id, isString := stringLiteral(keyValue.Key)
+ if !isString {
+ continue
+ }
+ binding, err := parseScenarioBinding(keyValue.Value, constants)
+ if err != nil {
+ inspectErr = fmt.Errorf("%s: scenario %q: %w", file.path, id, err)
+ return false
+ }
+ if _, exists := registry[id]; exists {
+ inspectErr = fmt.Errorf("%s: scenario %q is registered more than once", file.path, id)
+ return false
+ }
+ registry[id] = binding
+ }
+ return false
+ })
+ if inspectErr != nil {
+ return nil, inspectErr
+ }
+ }
+ if len(registry) == 0 {
+ return nil, errors.New("no map[string]scenarioBinding registry entries found in " + harnessDir)
+ }
+ return registry, nil
+}
+
+// isScenarioBindingMap reports whether expr is `map[string]scenarioBinding`.
+func isScenarioBindingMap(expr ast.Expr) bool {
+ mapType, ok := expr.(*ast.MapType)
+ if !ok {
+ return false
+ }
+ key, keyOK := mapType.Key.(*ast.Ident)
+ value, valueOK := mapType.Value.(*ast.Ident)
+ return keyOK && valueOK && key.Name == "string" && value.Name == "scenarioBinding"
+}
+
+// parseScenarioBinding reads the owner and tier of one scenarioBinding
+// literal. Owners may be string literals or string constants.
+func parseScenarioBinding(expr ast.Expr, constants map[string]string) (scenarioOwner, error) {
+ compositeLit, ok := expr.(*ast.CompositeLit)
+ if !ok {
+ return scenarioOwner{}, errors.New("binding is not a composite literal")
+ }
+
+ var binding scenarioOwner
+ for _, elt := range compositeLit.Elts {
+ keyValue, isKeyValue := elt.(*ast.KeyValueExpr)
+ if !isKeyValue {
+ return scenarioOwner{}, errors.New("binding fields must be keyed")
+ }
+ field, isIdent := keyValue.Key.(*ast.Ident)
+ if !isIdent {
+ continue
+ }
+ value, isLiteral := stringLiteral(keyValue.Value)
+ if !isLiteral {
+ ident, isConstIdent := keyValue.Value.(*ast.Ident)
+ if !isConstIdent {
+ return scenarioOwner{}, fmt.Errorf("field %s is not a string literal or constant", field.Name)
+ }
+ var isKnown bool
+ if value, isKnown = constants[ident.Name]; !isKnown {
+ return scenarioOwner{}, fmt.Errorf("field %s references unknown constant %s", field.Name, ident.Name)
+ }
+ }
+ switch field.Name {
+ case "owner":
+ binding.Owner = value
+ case "tier":
+ binding.Tier = value
+ }
+ }
+ if binding.Owner == "" {
+ return scenarioOwner{}, errors.New("binding has no owner")
+ }
+ return binding, nil
+}
+
+func writeFile(filePath string, contents []byte) error {
+ if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
+ return fmt.Errorf("create directory for %s: %w", filePath, err)
+ }
+ //nolint:gosec // Generated repository artifacts are intentionally world-readable.
+ if err := os.WriteFile(filePath, contents, 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", filePath, err)
+ }
+ return nil
+}
diff --git a/internal/cmd/generatefeatureinventory/main_test.go b/internal/cmd/generatefeatureinventory/main_test.go
new file mode 100644
index 000000000..f34021bb7
--- /dev/null
+++ b/internal/cmd/generatefeatureinventory/main_test.go
@@ -0,0 +1,484 @@
+package main
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDiffInventory(t *testing.T) {
+ t.Parallel()
+
+ extracted := []*extractedItem{
+ {Area: "config", Detail: "time.Duration", ID: "config.JobTimeout", Source: "client.go:river.Config.JobTimeout"},
+ {Area: "config", Detail: "string", ID: "config.Schema", Source: "client.go:river.Config.Schema"},
+ }
+ fileItem := func(id, detail string) *inventoryItem {
+ return &inventoryItem{
+ Applicability: applicabilityProtocolVisible,
+ Area: "config",
+ Detail: detail,
+ ID: id,
+ Scenarios: []string{"scenario"},
+ Source: "client.go:river.Config." + strings.TrimPrefix(id, "config."),
+ }
+ }
+
+ testCases := []struct {
+ items []*inventoryItem
+ name string
+ problems []string
+ }{
+ {
+ items: []*inventoryItem{
+ fileItem("config.JobTimeout", "time.Duration"),
+ fileItem("config.JobTimeout", "time.Duration"),
+ fileItem("config.Schema", "string"),
+ },
+ name: "DuplicateItem",
+ problems: []string{"duplicate item IDs: config.JobTimeout"},
+ },
+ {
+ items: []*inventoryItem{
+ fileItem("config.JobTimeout", "int64"),
+ fileItem("config.Schema", "string"),
+ },
+ name: "GeneratedFieldChanged",
+ problems: []string{`generated fields differ for config.JobTimeout (detail "int64" != "time.Duration")`},
+ },
+ {
+ items: []*inventoryItem{fileItem("config.JobTimeout", "time.Duration")},
+ name: "MissingItem",
+ problems: []string{"extracted items missing from the inventory: config.Schema"},
+ },
+ {
+ items: []*inventoryItem{
+ fileItem("config.JobTimeout", "time.Duration"),
+ fileItem("config.Schema", "string"),
+ },
+ name: "UpToDate",
+ },
+ {
+ items: []*inventoryItem{
+ fileItem("config.JobTimeout", "time.Duration"),
+ fileItem("config.Removed", "bool"),
+ fileItem("config.Schema", "string"),
+ },
+ name: "StaleItem",
+ problems: []string{"stale inventory items no longer extracted: config.Removed"},
+ },
+ }
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ require.Equal(t, tt.problems, diffInventory(&inventory{Items: tt.items}, extracted))
+ })
+ }
+}
+
+func TestEncodeInventory(t *testing.T) {
+ t.Parallel()
+
+ t.Run("RoundTripsCanonically", func(t *testing.T) {
+ t.Parallel()
+
+ inv := &inventory{
+ Items: []*inventoryItem{{
+ Applicability: applicabilityNotApplicable,
+ Area: "config",
+ Detail: "bool",
+ ID: "config.TestOnly",
+ Rationale: "Go test-suite switch & shared.",
+ Source: "client.go:river.Config.TestOnly",
+ }},
+ ProtocolRevision: 1,
+ Schema: inventorySchemaRef,
+ }
+
+ encoded, err := encodeInventory(inv)
+ require.NoError(t, err)
+ require.Contains(t, string(encoded), " & shared")
+ require.True(t, strings.HasPrefix(string(encoded), "{\n \"$schema\": "))
+
+ decoded, err := decodeInventory(encoded)
+ require.NoError(t, err)
+ require.Equal(t, inv, decoded)
+ })
+
+ t.Run("RejectsUnknownFields", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := decodeInventory([]byte(`{"items": [{"id": "config.ID", "unknown": true}]}`))
+ require.ErrorContains(t, err, "unknown")
+ })
+}
+
+func TestExtractAll(t *testing.T) {
+ t.Parallel()
+
+ t.Run("RepositoryContainsKnownItems", func(t *testing.T) {
+ t.Parallel()
+
+ items, err := extractAll("../../..")
+ require.NoError(t, err)
+
+ byID := make(map[string]*extractedItem, len(items))
+ for i, item := range items {
+ if i > 0 {
+ require.Less(t, items[i-1].ID, item.ID, "items must be sorted and unique")
+ }
+ require.True(t, strings.HasPrefix(item.ID, item.Area+"."), "ID %s must start with its area %s", item.ID, item.Area)
+ require.NotEmpty(t, item.Detail, item.ID)
+ require.NotEmpty(t, item.Source, item.ID)
+ byID[item.ID] = item
+ }
+
+ for _, id := range []string{
+ "client.Insert",
+ "config.JobTimeout",
+ "driver.Executor.JobInsertFastMany",
+ "event_kind.job_completed",
+ "extension.riverpilot.Pilot.JobGetAvailable",
+ "extension.rivertype.HookWorkBegin.WorkBegin",
+ "function.JobSnooze",
+ "interface.JobArgsWithKindAliases.KindAliases",
+ "interface.Worker.Timeout",
+ "job_list_params.After",
+ "job_state.available",
+ "metadata_key.cancel_attempted_at",
+ "metadata_key.output",
+ "metadata_key.river:log",
+ "metadata_key.river:periodic_job_id",
+ "metadata_key.river:rescue_count",
+ "metadata_key.river:resumable_cursor",
+ "metadata_key.river:resumable_step",
+ "metadata_key.river:unique_nonce",
+ "metadata_key.snoozes",
+ "metadata_key.unique_key_conflict",
+ "migration.postgres.006",
+ "migration.sqlite.006",
+ "notification_payload.control",
+ "notification_payload.control.action.cancel",
+ "notification_payload.insert",
+ "notification_payload.leadership",
+ "notification_payload.sql.job_cancel",
+ "notification_topic.river_control",
+ "rivertype_field.AttemptError.At",
+ "rivertype_field.JobRow.Kind",
+ } {
+ require.Contains(t, byID, id)
+ }
+
+ require.Equal(t, "time.Duration", byID["config.JobTimeout"].Detail)
+ require.Equal(t, "client.go:river.Config.JobTimeout", byID["config.JobTimeout"].Source)
+ require.Equal(t, "func(context.Context, TTx, river.JobArgs, *river.InsertOpts) (*rivertype.JobInsertResult, error)", byID["client.InsertTx"].Detail)
+ require.Equal(t, "action=cancel; job_id; queue", byID["notification_payload.sql.job_cancel"].Detail)
+ require.Contains(t, byID["notification_payload.control"].Detail, "job_id int64 omitempty")
+ require.Equal(t, "func[T JobArgs](workers *Workers, worker Worker[T])", byID["function.AddWorker"].Detail)
+ require.Equal(t, "worker.go:river.AddWorker", byID["function.AddWorker"].Source)
+ require.Equal(t, "func() []string", byID["interface.JobArgsWithKindAliases.KindAliases"].Detail)
+ require.Equal(t, "time.Time", byID["rivertype_field.AttemptError.At"].Detail)
+ require.Equal(t, "rivertype/river_type.go:rivertype.JobRow.Kind", byID["rivertype_field.JobRow.Kind"].Source)
+ })
+}
+
+func TestMergeInventory(t *testing.T) {
+ t.Parallel()
+
+ extracted := []*extractedItem{
+ {Area: "config", Detail: "string", ID: "config.Schema", Source: "client.go:river.Config.Schema"},
+ {Area: "config", Detail: "time.Duration", ID: "config.JobTimeout", Source: "client.go:river.Config.JobTimeout"},
+ }
+
+ t.Run("AddsNewItemsAsUnclassified", func(t *testing.T) {
+ t.Parallel()
+
+ merged, report := mergeInventory(nil, extracted)
+ require.Equal(t, []string{"config.JobTimeout", "config.Schema"}, report.Added)
+ require.Empty(t, report.Removed)
+ require.Equal(t, defaultProtocolRevision, merged.ProtocolRevision)
+ require.Equal(t, inventorySchemaRef, merged.Schema)
+ require.Len(t, merged.Items, 2)
+ for _, item := range merged.Items {
+ require.Equal(t, applicabilityUnclassified, item.Applicability)
+ }
+ })
+
+ t.Run("DropsStaleItems", func(t *testing.T) {
+ t.Parallel()
+
+ existing := &inventory{Items: []*inventoryItem{{ID: "config.Removed", Applicability: applicabilityInternal, Rationale: "gone"}}}
+
+ merged, report := mergeInventory(existing, extracted)
+ require.Equal(t, []string{"config.Removed"}, report.Removed)
+ for _, item := range merged.Items {
+ require.NotEqual(t, "config.Removed", item.ID)
+ }
+ })
+
+ t.Run("PreservesHumanFieldsAndRewritesGeneratedFields", func(t *testing.T) {
+ t.Parallel()
+
+ existing := &inventory{
+ Items: []*inventoryItem{{
+ Applicability: applicabilityProtocolVisible,
+ Area: "stale_area",
+ Detail: "stale detail",
+ ID: "config.JobTimeout",
+ Rationale: "kept",
+ Scenarios: []string{"timeout_cancellation", "a_scenario", "timeout_cancellation"},
+ Source: "stale.go:Stale",
+ }},
+ ProtocolRevision: 7,
+ }
+
+ merged, report := mergeInventory(existing, extracted)
+ require.Equal(t, []string{"config.Schema"}, report.Added)
+ require.Equal(t, 7, merged.ProtocolRevision)
+ require.Equal(t, &inventoryItem{
+ Applicability: applicabilityProtocolVisible,
+ Area: "config",
+ Detail: "time.Duration",
+ ID: "config.JobTimeout",
+ Rationale: "kept",
+ Scenarios: []string{"a_scenario", "timeout_cancellation"},
+ Source: "client.go:river.Config.JobTimeout",
+ }, merged.Items[0])
+ require.Equal(t, "config.Schema", merged.Items[1].ID)
+ require.Equal(t, applicabilityUnclassified, merged.Items[1].Applicability)
+ })
+}
+
+func TestParseScenarioRegistry(t *testing.T) {
+ t.Parallel()
+
+ t.Run("CollectsEveryBindingMap", func(t *testing.T) {
+ t.Parallel()
+
+ registry, err := parseScenarioRegistry(map[string][]byte{
+ "conformance/harness/a_test.go": []byte(`package harness_test
+
+const scenarioOwnerMixed = "TestMixedConformance"
+
+type scenarioBinding struct {
+ owner string
+ profile string
+ tier string
+}
+
+var scenarioRegistry = map[string]scenarioBinding{
+ "timeout_cancellation": {owner: scenarioOwnerMixed, tier: "runtime"},
+}
+`),
+ "conformance/harness/b_test.go": []byte(`package harness_test
+
+const scenarioOwnerExtra = "TestExtraConformance"
+
+func init() {
+ extra := map[string]scenarioBinding{
+ "extra_scenario": {owner: scenarioOwnerExtra, profile: "p", tier: "mixed"},
+ "literal_owner": {owner: "TestLiteral", tier: "codec"},
+ }
+ for id, binding := range extra {
+ scenarioRegistry[id] = binding
+ }
+}
+`),
+ })
+ require.NoError(t, err)
+ require.Equal(t, map[string]scenarioOwner{
+ "extra_scenario": {Owner: "TestExtraConformance", Tier: "mixed"},
+ "literal_owner": {Owner: "TestLiteral", Tier: "codec"},
+ "timeout_cancellation": {Owner: "TestMixedConformance", Tier: "runtime"},
+ }, registry)
+ })
+
+ t.Run("RejectsDuplicateAndUnknownOwners", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := parseScenarioRegistry(map[string][]byte{
+ "a_test.go": []byte(`package harness_test
+
+var a = map[string]scenarioBinding{"dup": {owner: "A"}}
+var b = map[string]scenarioBinding{"dup": {owner: "B"}}
+`),
+ })
+ require.ErrorContains(t, err, `scenario "dup" is registered more than once`)
+
+ _, err = parseScenarioRegistry(map[string][]byte{
+ "a_test.go": []byte(`package harness_test
+
+var a = map[string]scenarioBinding{"x": {owner: missingOwner}}
+`),
+ })
+ require.ErrorContains(t, err, "unknown constant missingOwner")
+ })
+}
+
+func TestRenderMatrix(t *testing.T) {
+ t.Parallel()
+
+ registry := map[string]scenarioOwner{"timeout_cancellation": {Owner: "TestMixedConformance", Tier: "runtime"}}
+ extracted := []*extractedItem{
+ {Area: "migration", Detail: "x", ID: "migration.postgres.001", Source: "m"},
+ {Area: "config", Detail: "time.Duration", ID: "config.JobTimeout", Source: "c"},
+ {Area: "config", Detail: "*slog.Logger", ID: "config.Logger", Source: "c"},
+ }
+ existing := &inventory{Items: []*inventoryItem{
+ {ID: "config.JobTimeout", Applicability: applicabilityProtocolVisible, Scenarios: []string{"timeout_cancellation", "planned_scenario"}},
+ {ID: "config.Logger", Applicability: applicabilityAPIEquivalent, Rationale: "Uses the | native\nlogger."},
+ }}
+
+ t.Run("Deterministic", func(t *testing.T) {
+ t.Parallel()
+
+ merged, _ := mergeInventory(existing, extracted)
+ reversed := make([]*extractedItem, len(extracted))
+ for i, item := range extracted {
+ reversed[len(extracted)-1-i] = item
+ }
+ mergedReversed, _ := mergeInventory(existing, reversed)
+
+ first := renderMatrix("# Header\n", merged, registry)
+ require.Equal(t, first, renderMatrix("# Header\n", merged, registry))
+ require.Equal(t, first, renderMatrix("# Header\n", mergedReversed, registry))
+ })
+
+ t.Run("RendersSectionsAndOwners", func(t *testing.T) {
+ t.Parallel()
+
+ merged, _ := mergeInventory(existing, extracted)
+ matrix := renderMatrix("# Header\n", merged, registry)
+
+ require.True(t, strings.HasPrefix(matrix, "# Header\n\n## Summary\n"))
+ require.Contains(t, matrix, "| [`config`](#config) | 1 | 1 | 0 | 0 | 0 | 0 | 2 |")
+ require.Contains(t, matrix, "| `config.JobTimeout` | protocol_visible | `planned_scenario` (unregistered)
`timeout_cancellation` (TestMixedConformance) | |")
+ require.Contains(t, matrix, "| `config.Logger` | api_equivalent | | Uses the \\| native logger. |")
+ require.Contains(t, matrix, "| `migration.postgres.001` | unclassified | | |")
+ require.Less(t, strings.Index(matrix, "## config"), strings.Index(matrix, "## migration"))
+ })
+}
+
+func TestSQLMetadataKeyUses(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ body string
+ keys []string
+ name string
+ }{
+ {
+ body: `SET metadata = river_job.metadata || jsonb_build_object('river:rescue_count', coalesce((metadata ->> 'x')::int, 0) + 1, 'second', 'value')`,
+ keys: []string{"river:rescue_count", "second"},
+ name: "JSONBBuildObject",
+ },
+ {
+ body: `SET metadata = river_job.metadata || '{"unique_key_conflict": "scheduler_discarded"}'::jsonb`,
+ keys: []string{"unique_key_conflict"},
+ name: "JSONLiteral",
+ },
+ {
+ body: `SET metadata = jsonb_patch(json(metadata), json('{"b": 1, "a": 2}'))`,
+ keys: []string{"a", "b"},
+ name: "JSONPatch",
+ },
+ {
+ body: `SET metadata = jsonb_set(metadata, '{cancel_attempted_at}'::text[], @x::jsonb, true)`,
+ keys: []string{"cancel_attempted_at"},
+ name: "PostgresJSONBSet",
+ },
+ {
+ body: `SET metadata = jsonb_set(metadata, '$."river:rescue_count"', 1), other = jsonb_set(metadata, '$.cancel_attempted_at', 2)`,
+ keys: []string{"cancel_attempted_at", "river:rescue_count"},
+ name: "SQLiteJSONBSet",
+ },
+ {
+ body: `SELECT metadata FROM river_job`,
+ name: "Unrelated",
+ },
+ }
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ uses, err := sqlMetadataKeyUses(&sqlQuery{body: tt.body, name: "Query", path: "q.sql"})
+ require.NoError(t, err)
+
+ var keys []string
+ for _, use := range uses {
+ require.Equal(t, "q.sql:Query", use.source)
+ keys = append(keys, use.key)
+ }
+ require.ElementsMatch(t, tt.keys, keys)
+ })
+ }
+}
+
+func TestValidateClassifications(t *testing.T) {
+ t.Parallel()
+
+ knownScenarios := map[string]struct{}{"timeout_cancellation": {}, "declared_only": {}}
+ registry := map[string]scenarioOwner{"timeout_cancellation": {Owner: "TestMixedConformance"}}
+
+ testCases := []struct {
+ item *inventoryItem
+ name string
+ problems []string
+ }{
+ {
+ item: &inventoryItem{ID: "x.a", Applicability: "sometimes", Rationale: "r"},
+ name: "InvalidApplicability",
+ problems: []string{`invalid applicability: x.a ("sometimes")`},
+ },
+ {
+ item: &inventoryItem{ID: "x.a", Applicability: applicabilityNotApplicable},
+ name: "MissingRationale",
+ problems: []string{"non-protocol items without a rationale: x.a"},
+ },
+ {
+ item: &inventoryItem{ID: "x.a", Applicability: applicabilityAPIEquivalent, Rationale: "r"},
+ name: "NonProtocolWithRationale",
+ },
+ {
+ item: &inventoryItem{ID: "x.a", Applicability: applicabilityProtocolVisible},
+ name: "ProtocolVisibleWithoutScenarios",
+ problems: []string{"protocol_visible items without scenarios or a recorded gap: x.a"},
+ },
+ {
+ item: &inventoryItem{ID: "x.a", Applicability: applicabilityProtocolVisible, Gap: "no shared scenario yet"},
+ name: "ProtocolVisibleWithGap",
+ },
+ {
+ item: &inventoryItem{ID: "x.a", Applicability: applicabilityProtocolVisible, Scenarios: []string{"timeout_cancellation"}},
+ name: "ProtocolVisibleWithScenario",
+ },
+ {
+ item: &inventoryItem{ID: "x.a", Applicability: applicabilityUnclassified},
+ name: "Unclassified",
+ problems: []string{"unclassified items (set applicability and rationale/scenarios): x.a"},
+ },
+ {
+ item: &inventoryItem{ID: "x.a", Applicability: applicabilityProtocolVisible, Scenarios: []string{"planned"}},
+ name: "UnknownScenario",
+ problems: []string{
+ `scenario "planned" is not declared in conformance/scenarios/*.json (referenced by x.a)`,
+ `scenario "planned" has no owner in the harness scenario registry (referenced by x.a)`,
+ },
+ },
+ {
+ item: &inventoryItem{ID: "x.a", Applicability: applicabilityProtocolVisible, Scenarios: []string{"declared_only"}},
+ name: "UnregisteredScenario",
+ problems: []string{`scenario "declared_only" has no owner in the harness scenario registry (referenced by x.a)`},
+ },
+ }
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ problems := validateClassifications(&inventory{Items: []*inventoryItem{tt.item}}, knownScenarios, registry)
+ require.Equal(t, tt.problems, problems)
+ })
+ }
+}
diff --git a/internal/cmd/generatefeatureinventory/matrix_header.md b/internal/cmd/generatefeatureinventory/matrix_header.md
new file mode 100644
index 000000000..c72753aa1
--- /dev/null
+++ b/internal/cmd/generatefeatureinventory/matrix_header.md
@@ -0,0 +1,71 @@
+# Backend feature matrix
+
+
+
+This matrix is rendered from [`feature-inventory.json`](feature-inventory.json),
+which lists every Go-visible River feature the generator derives from the Go
+implementation: configuration and option fields, client and query-builder
+methods, job states, event kinds, reserved metadata keys, notification topics
+and payloads, driver and extension interfaces, and main-line migrations. Each
+item carries one applicability:
+
+- `protocol_visible`: affects persisted rows, SQL, notifications, timing, or
+ other behavior another implementation can observe. Lists at least one
+ executable scenario or records the gap that no shared scenario covers it
+ yet.
+- `api_equivalent`: a language API surface every implementation provides in its
+ own idiom. Scenarios are listed where an adapter operation exercises it.
+- `driver_specific`: a detail of Go's internal driver seam.
+- `internal`: Go-internal mechanics with no cross-language contract.
+- `not_applicable`: a Go-only concept other implementations need not provide.
+- `unclassified`: newly discovered and not yet reviewed.
+
+A row's status is its applicability plus the executable scenarios it lists;
+owner tests come from the registry in `harness/scenario_registry_test.go`. The
+matrix makes no broader completeness claim. `go run
+./internal/cmd/generatefeatureinventory -check` fails when a feature is added
+or removed without classification, when a protocol-visible item has neither
+a scenario nor a recorded gap, when a scenario is not declared and registered, or when this file is
+stale.
+
+## Scope decisions
+
+- PostgreSQL is the only backend with custom-schema, `SKIP LOCKED` competition,
+ backend fault-injection, process-kill rescue, performance, and soak
+ scenarios.
+- Fetching has no kind filter. Every client fetches any available job in the
+ queues it works; a job whose kind has no registered worker fails with a
+ retryable unknown-kind error (`mixed_unknown_kind_error`,
+ `sqlite_runtime_unknown_kind_error`).
+- Fast insertion (`InsertManyFast`) isn't part of the shared contract. Ports
+ don't offer it yet, so batches go through ordinary typed insertion.
+- SQLite `portable-storage-v1` covers main-line migrations; deterministic
+ retry and unique-key controls; typed insertion; job
+ get/list/update/cancel/retry/delete; cross-language cursor ordering;
+ millisecond timestamp storage; and transaction commit, rollback, batch
+ atomicity, and visibility. Every selected candidate is exercised in both
+ directions with Go against one WAL database.
+- SQLite `sqlite-runtime-v1` additionally covers work in both directions,
+ competing workers, queue CRUD, dynamic reconfiguration and pause/resume,
+ transactional and ordinary notification wakeups, cancellation, leadership
+ and failover, scheduler and periodic work, poll-only recovery, resumable
+ retries, hook and middleware ordering, local subscriptions, cross-client
+ pause/resume subscription delivery, and graceful lifecycle behavior.
+- SQLite custom schemas, PostgreSQL aborted-transaction behavior, `SKIP
+ LOCKED`, backend fault injection, rescue, cleaner and reindex maintenance,
+ performance, and soak are outside the SQLite profiles.
+- Subscriber lag counters, job and queue cleaners, and reindexing are claimed
+ only through scenarios listed on the corresponding items below; the version 1
+ process adapter does not expose lag observations.
+- Rust uses builders, typed async workers, cancellation tokens, and explicit
+ transaction connections rather than reproducing Go API shapes.
+- JavaScript uses `bigint`, Temporal instants, promises, `AbortSignal`, and
+ optional worker-thread execution rather than narrowing protocol values to
+ JavaScript numbers or reproducing Go goroutine APIs. Shared scenarios
+ exercise job IDs above `Number.MAX_SAFE_INTEGER`, including JSON-RPC
+ requests, responses, list filters, and cursors.
+- `riverqueue::__private` is a hidden extension module for crates released
+ in lockstep with `riverqueue`. It is not a stable API compatibility promise.
+- The Rust crates and JavaScript packages are unpublished preview packages
+ until the release process is complete.
diff --git a/internal/cmd/generatefeatureinventory/metadata.go b/internal/cmd/generatefeatureinventory/metadata.go
new file mode 100644
index 000000000..4ae8dab90
--- /dev/null
+++ b/internal/cmd/generatefeatureinventory/metadata.go
@@ -0,0 +1,565 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "go/types"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "reflect"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// metadataKeyCollector accumulates reserved metadata keys along with every
+// mechanism and source that uses them.
+type metadataKeyCollector struct {
+ kinds map[string]map[string]struct{}
+ sources map[string]map[string]struct{}
+}
+
+func newMetadataKeyCollector() *metadataKeyCollector {
+ return &metadataKeyCollector{
+ kinds: make(map[string]map[string]struct{}),
+ sources: make(map[string]map[string]struct{}),
+ }
+}
+
+func (c *metadataKeyCollector) add(key, kind, source string) {
+ if c.kinds[key] == nil {
+ c.kinds[key] = make(map[string]struct{})
+ c.sources[key] = make(map[string]struct{})
+ }
+ c.kinds[key][kind] = struct{}{}
+ c.sources[key][source] = struct{}{}
+}
+
+func (c *metadataKeyCollector) items() []*extractedItem {
+ items := make([]*extractedItem, 0, len(c.kinds))
+ for _, key := range sortedKeys(c.kinds) {
+ items = append(items, &extractedItem{
+ Area: "metadata_key",
+ Detail: strings.Join(sortedKeys(c.kinds[key]), ", "),
+ ID: "metadata_key." + key,
+ Source: strings.Join(sortedKeys(c.sources[key]), ", "),
+ })
+ }
+ return items
+}
+
+type metadataKeyUse struct {
+ key string
+ kind string
+ source string
+}
+
+// sqlQuery is one named sqlc query.
+type sqlQuery struct {
+ body string
+ name string
+ path string
+}
+
+const (
+ pgxDBSQLCDir = "riverdriver/riverpgxv5/internal/dbsqlc"
+ sqliteDBSQLCDir = "riverdriver/riversqlite/internal/dbsqlc"
+)
+
+// metadataScanRoots are the library directories scanned for metadata keys in
+// Go code. The root package is scanned non-recursively because its
+// subdirectories are separate packages covered elsewhere or not library code.
+func metadataScanRoots() []struct {
+ dir string
+ recursive bool
+} {
+ return []struct {
+ dir string
+ recursive bool
+ }{
+ {dir: ".", recursive: false},
+ {dir: "internal", recursive: true},
+ {dir: "riverdriver", recursive: true},
+ {dir: "riverlog", recursive: true},
+ {dir: "rivershared", recursive: true},
+ {dir: "rivertype", recursive: true},
+ }
+}
+
+// metadataScanExcluded are directories below the scan roots that are not
+// library code.
+func metadataScanExcluded() map[string]struct{} {
+ return map[string]struct{}{
+ "internal/cmd": {},
+ "riverdriver/riverdrivertest": {},
+ }
+}
+
+// extractMetadataKeys returns one item per reserved metadata key found in Go
+// constants, Go metadata helper calls and struct tags, and driver SQL.
+func extractMetadataKeys(root string) ([]*extractedItem, error) {
+ files, err := metadataGoFiles(root)
+ if err != nil {
+ return nil, err
+ }
+
+ collector := newMetadataKeyCollector()
+
+ // Constants are collected first so that helper calls referencing them by
+ // name can be resolved to their values.
+ constsByName := make(map[string][]string)
+ numConsts := 0
+ for _, file := range files {
+ for _, constDecl := range fileStringConsts(file.file.Name.Name, file) {
+ if !strings.Contains(constDecl.name, "MetadataKey") && !strings.Contains(constDecl.name, "metadataKey") {
+ continue
+ }
+ collector.add(constDecl.value, "go:const", fmt.Sprintf("%s:%s.%s", file.path, constDecl.pkg, constDecl.name))
+ qualified := constDecl.pkg + "." + constDecl.name
+ constsByName[qualified] = append(constsByName[qualified], constDecl.value)
+ numConsts++
+ }
+ }
+ if numConsts == 0 {
+ return nil, errors.New("no metadata key constants found")
+ }
+
+ numUses := 0
+ for _, file := range files {
+ uses, err := goMetadataKeyUses(file, constsByName)
+ if err != nil {
+ return nil, err
+ }
+ for _, use := range uses {
+ collector.add(use.key, use.kind, use.source)
+ }
+ numUses += len(uses)
+ }
+ if numUses == 0 {
+ return nil, errors.New("no metadata key uses found in Go helper calls or struct tags")
+ }
+
+ numSQL := 0
+ for _, dir := range []string{pgxDBSQLCDir, sqliteDBSQLCDir} {
+ queries, err := sqlQueries(root, dir)
+ if err != nil {
+ return nil, err
+ }
+ for _, query := range queries {
+ uses, err := sqlMetadataKeyUses(query)
+ if err != nil {
+ return nil, err
+ }
+ for _, use := range uses {
+ collector.add(use.key, use.kind, use.source)
+ }
+ numSQL += len(uses)
+ }
+ }
+ if numSQL == 0 {
+ return nil, errors.New("no metadata keys found in driver SQL")
+ }
+
+ return collector.items(), nil
+}
+
+// metadataGoFiles parses the non-test Go files under the metadata scan roots.
+func metadataGoFiles(root string) ([]*goFile, error) {
+ excluded := metadataScanExcluded()
+ fset := token.NewFileSet()
+
+ var files []*goFile
+ for _, scanRoot := range metadataScanRoots() {
+ start := filepath.Join(root, filepath.FromSlash(scanRoot.dir))
+ err := filepath.WalkDir(start, func(filePath string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ relPath, err := filepath.Rel(root, filePath)
+ if err != nil {
+ return err
+ }
+ relPath = filepath.ToSlash(relPath)
+ if entry.IsDir() {
+ if filePath == start {
+ return nil
+ }
+ name := entry.Name()
+ if !scanRoot.recursive || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") || name == "testdata" || name == "node_modules" {
+ return filepath.SkipDir
+ }
+ if _, ok := excluded[relPath]; ok {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(relPath, ".go") || strings.HasSuffix(relPath, "_test.go") {
+ return nil
+ }
+ file, err := parser.ParseFile(fset, filePath, nil, parser.SkipObjectResolution)
+ if err != nil {
+ return fmt.Errorf("parse %s: %w", relPath, err)
+ }
+ files = append(files, &goFile{file: file, path: relPath})
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("scan %s: %w", scanRoot.dir, err)
+ }
+ }
+ sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path })
+ return files, nil
+}
+
+// goMetadataKeyUses finds metadata keys used with gjson/sjson helpers on
+// metadata values, metadata update map indexes, and `river:` struct tags.
+func goMetadataKeyUses(file *goFile, constsByName map[string][]string) ([]*metadataKeyUse, error) {
+ pkgName := file.file.Name.Name
+
+ resolveKey := func(expr ast.Expr) (string, bool, error) {
+ if value, ok := stringLiteral(expr); ok {
+ return value, true, nil
+ }
+ var qualified string
+ switch typed := expr.(type) {
+ case *ast.Ident:
+ qualified = pkgName + "." + typed.Name
+ case *ast.SelectorExpr:
+ pkgIdent, ok := typed.X.(*ast.Ident)
+ if !ok {
+ return "", false, nil
+ }
+ qualified = pkgIdent.Name + "." + typed.Sel.Name
+ default:
+ return "", false, nil
+ }
+ if !strings.Contains(qualified, "MetadataKey") && !strings.Contains(qualified, "metadataKey") {
+ return "", false, nil
+ }
+ values := constsByName[qualified]
+ if len(values) != 1 {
+ return "", false, fmt.Errorf("%s: cannot resolve metadata key constant %s", file.path, qualified)
+ }
+ return values[0], true, nil
+ }
+
+ var (
+ inspectErr error
+ uses []*metadataKeyUse
+ )
+ inspect := func(symbol string, node ast.Node) {
+ ast.Inspect(node, func(node ast.Node) bool {
+ if inspectErr != nil {
+ return false
+ }
+ switch typed := node.(type) {
+ case *ast.CallExpr:
+ selector, isSelector := typed.Fun.(*ast.SelectorExpr)
+ if !isSelector || len(typed.Args) < 2 {
+ return true
+ }
+ pkgIdent, isIdent := selector.X.(*ast.Ident)
+ if !isIdent {
+ return true
+ }
+ helper := pkgIdent.Name + "." + selector.Sel.Name
+ switch helper {
+ case "gjson.GetBytes", "sjson.DeleteBytes", "sjson.SetBytes", "sjson.SetRawBytes":
+ default:
+ return true
+ }
+ if !strings.Contains(strings.ToLower(types.ExprString(typed.Args[0])), "metadata") {
+ return true
+ }
+ key, ok, err := resolveKey(typed.Args[1])
+ if err != nil {
+ inspectErr = err
+ return false
+ }
+ if ok {
+ uses = append(uses, &metadataKeyUse{key: key, kind: "go:" + helper, source: file.path + ":" + symbol})
+ }
+ case *ast.IndexExpr:
+ if !strings.Contains(strings.ToLower(types.ExprString(typed.X)), "metadataupdates") {
+ return true
+ }
+ key, ok, err := resolveKey(typed.Index)
+ if err != nil {
+ inspectErr = err
+ return false
+ }
+ if ok {
+ uses = append(uses, &metadataKeyUse{key: key, kind: "go:metadata_updates_index", source: file.path + ":" + symbol})
+ }
+ case *ast.Field:
+ if typed.Tag == nil {
+ return true
+ }
+ tag, err := strconv.Unquote(typed.Tag.Value)
+ if err != nil {
+ return true
+ }
+ jsonName, _, _ := strings.Cut(reflect.StructTag(tag).Get("json"), ",")
+ if strings.HasPrefix(jsonName, "river:") {
+ uses = append(uses, &metadataKeyUse{key: jsonName, kind: "go:json_tag", source: file.path + ":" + symbol})
+ }
+ }
+ return true
+ })
+ }
+
+ for _, decl := range file.file.Decls {
+ switch typed := decl.(type) {
+ case *ast.FuncDecl:
+ symbol := pkgName + "." + typed.Name.Name
+ if typed.Recv != nil {
+ symbol = pkgName + "." + receiverTypeName(typed.Recv.List[0].Type) + "." + typed.Name.Name
+ }
+ inspect(symbol, typed)
+ case *ast.GenDecl:
+ for _, spec := range typed.Specs {
+ symbol := pkgName
+ if typeSpec, ok := spec.(*ast.TypeSpec); ok {
+ symbol = pkgName + "." + typeSpec.Name.Name
+ }
+ inspect(symbol, spec)
+ }
+ }
+ }
+ if inspectErr != nil {
+ return nil, inspectErr
+ }
+ return uses, nil
+}
+
+var (
+ sqlJSONBBuildObjectPattern = regexp.MustCompile(`\bmetadata\s*(?:=|\|\|)\s*jsonb_build_object\s*\(`)
+ sqlJSONBLiteralPattern = regexp.MustCompile(`\bmetadata\s*\|\|\s*'(\{[^']*\})'\s*::\s*jsonb`)
+ sqlJSONBPatchPattern = regexp.MustCompile(`jsonb_patch\s*\(\s*json\s*\(\s*metadata\s*\)\s*,\s*json\s*\(\s*'(\{[^']*\})'`)
+ sqlJSONBSetPathPattern = regexp.MustCompile(`jsonb_set\s*\(\s*metadata\s*,\s*'\{([^}']+)\}'`)
+ sqlJSONBSetSQLitePattern = regexp.MustCompile(`jsonb_set\s*\(\s*metadata\s*,\s*'\$\.(?:"([^"']+)"|([A-Za-z0-9_:]+))'`)
+ sqlNamePattern = regexp.MustCompile(`(?m)^--\s*name:\s*(\w+)`)
+ sqlPGNotifyPattern = regexp.MustCompile(`\bpg_notify\s*\(`)
+)
+
+// sqlMetadataKeyUses finds metadata keys written by one query.
+func sqlMetadataKeyUses(query *sqlQuery) ([]*metadataKeyUse, error) {
+ source := query.path + ":" + query.name
+ var uses []*metadataKeyUse
+
+ for _, match := range sqlJSONBSetPathPattern.FindAllStringSubmatch(query.body, -1) {
+ uses = append(uses, &metadataKeyUse{key: match[1], kind: "sql:jsonb_set", source: source})
+ }
+ for _, match := range sqlJSONBSetSQLitePattern.FindAllStringSubmatch(query.body, -1) {
+ uses = append(uses, &metadataKeyUse{key: match[1] + match[2], kind: "sql:jsonb_set", source: source})
+ }
+ for _, loc := range sqlJSONBBuildObjectPattern.FindAllStringIndex(query.body, -1) {
+ args, err := sqlCallArgs(query.body, loc[1]-1)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", source, err)
+ }
+ for i := 0; i < len(args); i += 2 {
+ key, ok := sqlStringLiteral(args[i])
+ if !ok {
+ return nil, fmt.Errorf("%s: non-literal jsonb_build_object key %q", source, args[i])
+ }
+ uses = append(uses, &metadataKeyUse{key: key, kind: "sql:jsonb_build_object", source: source})
+ }
+ }
+ for _, pattern := range []*regexp.Regexp{sqlJSONBLiteralPattern, sqlJSONBPatchPattern} {
+ for _, match := range pattern.FindAllStringSubmatch(query.body, -1) {
+ var object map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(match[1]), &object); err != nil {
+ return nil, fmt.Errorf("%s: decode metadata literal %s: %w", source, match[1], err)
+ }
+ for _, key := range sortedKeys(object) {
+ uses = append(uses, &metadataKeyUse{key: key, kind: "sql:json_literal", source: source})
+ }
+ }
+ }
+ return uses, nil
+}
+
+// extractSQLNotificationPayloads returns one item per `pg_notify` call whose
+// payload is a `json_build_object` in the PostgreSQL driver queries.
+func extractSQLNotificationPayloads(root string) ([]*extractedItem, error) {
+ queries, err := sqlQueries(root, pgxDBSQLCDir)
+ if err != nil {
+ return nil, err
+ }
+
+ var items []*extractedItem
+ for _, query := range queries {
+ source := query.path + ":" + query.name
+ var payloads []string
+ for _, loc := range sqlPGNotifyPattern.FindAllStringIndex(query.body, -1) {
+ notifyArgs, err := sqlCallArgs(query.body, loc[1]-1)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", source, err)
+ }
+ for _, notifyArg := range notifyArgs {
+ start := strings.Index(notifyArg, "json_build_object(")
+ if start < 0 {
+ continue
+ }
+ objectArgs, err := sqlCallArgs(notifyArg, start+len("json_build_object"))
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", source, err)
+ }
+ if len(objectArgs)%2 != 0 {
+ return nil, fmt.Errorf("%s: json_build_object has an odd number of arguments", source)
+ }
+ fields := make([]string, 0, len(objectArgs)/2)
+ for i := 0; i < len(objectArgs); i += 2 {
+ key, ok := sqlStringLiteral(objectArgs[i])
+ if !ok {
+ return nil, fmt.Errorf("%s: non-literal json_build_object key %q", source, objectArgs[i])
+ }
+ if value, ok := sqlStringLiteral(objectArgs[i+1]); ok {
+ key += "=" + value
+ }
+ fields = append(fields, key)
+ }
+ sort.Strings(fields)
+ payloads = append(payloads, strings.Join(fields, "; "))
+ }
+ }
+ for i, payload := range payloads {
+ id := "notification_payload.sql." + snakeCase(query.name)
+ if len(payloads) > 1 {
+ id += "." + strconv.Itoa(i+1)
+ }
+ items = append(items, &extractedItem{
+ Area: "notification_payload",
+ Detail: payload,
+ ID: id,
+ Source: source,
+ })
+ }
+ }
+ if len(items) == 0 {
+ return nil, fmt.Errorf("no pg_notify json_build_object payloads found in %s", pgxDBSQLCDir)
+ }
+ return items, nil
+}
+
+// sqlQueries splits every .sql file in dir into named sqlc queries, with
+// whole-line comments removed from each body.
+func sqlQueries(root, dir string) ([]*sqlQuery, error) {
+ matches, err := filepath.Glob(filepath.Join(root, filepath.FromSlash(dir), "*.sql"))
+ if err != nil {
+ return nil, fmt.Errorf("glob %s: %w", dir, err)
+ }
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("no SQL files found in %s", dir)
+ }
+ sort.Strings(matches)
+
+ var queries []*sqlQuery
+ for _, match := range matches {
+ contents, err := os.ReadFile(match)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", match, err)
+ }
+ relPath := path.Join(dir, filepath.Base(match))
+ text := string(contents)
+ locs := sqlNamePattern.FindAllStringSubmatchIndex(text, -1)
+ for i, loc := range locs {
+ end := len(text)
+ if i+1 < len(locs) {
+ end = locs[i+1][0]
+ }
+ var body strings.Builder
+ for line := range strings.SplitSeq(text[loc[1]:end], "\n") {
+ if strings.HasPrefix(strings.TrimSpace(line), "--") {
+ continue
+ }
+ body.WriteString(line + "\n")
+ }
+ queries = append(queries, &sqlQuery{
+ body: body.String(),
+ name: text[loc[2]:loc[3]],
+ path: relPath,
+ })
+ }
+ }
+ return queries, nil
+}
+
+// sqlCallArgs splits the top-level arguments of the SQL call whose opening
+// parenthesis is at openIndex, respecting nested parentheses and quotes.
+func sqlCallArgs(text string, openIndex int) ([]string, error) {
+ if openIndex >= len(text) || text[openIndex] != '(' {
+ return nil, fmt.Errorf("expected '(' at offset %d", openIndex)
+ }
+
+ var (
+ args []string
+ depth = 0
+ argStart = openIndex + 1
+ quote byte
+ )
+ for i := openIndex + 1; i < len(text); i++ {
+ char := text[i]
+ if quote != 0 {
+ if char == quote {
+ quote = 0
+ }
+ continue
+ }
+ switch char {
+ case '\'', '"':
+ quote = char
+ case '(':
+ depth++
+ case ')':
+ if depth == 0 {
+ if arg := strings.TrimSpace(text[argStart:i]); arg != "" || len(args) > 0 {
+ args = append(args, arg)
+ }
+ return args, nil
+ }
+ depth--
+ case ',':
+ if depth == 0 {
+ args = append(args, strings.TrimSpace(text[argStart:i]))
+ argStart = i + 1
+ }
+ }
+ }
+ return nil, fmt.Errorf("unterminated call starting at offset %d", openIndex)
+}
+
+// sqlStringLiteral returns the value of a single-quoted SQL string literal.
+func sqlStringLiteral(expr string) (string, bool) {
+ if len(expr) < 2 || expr[0] != '\'' || expr[len(expr)-1] != '\'' {
+ return "", false
+ }
+ inner := expr[1 : len(expr)-1]
+ if strings.Contains(strings.ReplaceAll(inner, "''", ""), "'") {
+ return "", false
+ }
+ return strings.ReplaceAll(inner, "''", "'"), true
+}
+
+// snakeCase converts a Go-style identifier like JobCancel to job_cancel.
+func snakeCase(name string) string {
+ var sb strings.Builder
+ for i, char := range name {
+ if char >= 'A' && char <= 'Z' {
+ if i > 0 {
+ sb.WriteByte('_')
+ }
+ char += 'a' - 'A'
+ }
+ sb.WriteRune(char)
+ }
+ return sb.String()
+}
diff --git a/internal/cmd/riverconformanceadapter/go.mod b/internal/cmd/riverconformanceadapter/go.mod
new file mode 100644
index 000000000..1fe7a8306
--- /dev/null
+++ b/internal/cmd/riverconformanceadapter/go.mod
@@ -0,0 +1,38 @@
+module github.com/riverqueue/river/internal/cmd/riverconformanceadapter
+
+go 1.26.0
+
+toolchain go1.26.6
+
+require (
+ github.com/jackc/pgx/v5 v5.11.0
+ github.com/riverqueue/river v0.47.0
+ github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0
+ github.com/riverqueue/river/riverdriver/riversqlite v0.47.0
+ github.com/riverqueue/river/rivertype v0.47.0
+ github.com/robfig/cron/v3 v3.0.1
+ modernc.org/sqlite v1.59.0
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/riverqueue/river/riverdriver v0.47.0 // indirect
+ github.com/riverqueue/river/rivershared v0.47.0 // indirect
+ github.com/tidwall/gjson v1.19.0 // indirect
+ github.com/tidwall/match v1.2.0 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/tidwall/sjson v1.2.5 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
+ modernc.org/libc v1.75.7 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.12.1 // indirect
+)
diff --git a/internal/cmd/riverconformanceadapter/go.sum b/internal/cmd/riverconformanceadapter/go.sum
new file mode 100644
index 000000000..a4f99f64b
--- /dev/null
+++ b/internal/cmd/riverconformanceadapter/go.sum
@@ -0,0 +1,100 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
+github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
+github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc=
+github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ=
+github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60=
+github.com/riverqueue/river/riverdriver v0.47.0/go.mod h1:NOXl0fUiF1AT/TaQOjdx2A/c0Davn+SKbW8nAXWjfC4=
+github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 h1:5N9nvemhQwbUElMxASw4oEaYJ/v6hiS5Y9VcOQfdC5g=
+github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0/go.mod h1:ZboiXXZKC4+fTkxBxGRVmAsCuUu0NPYliqbWYuQAZyw=
+github.com/riverqueue/river/riverdriver/riversqlite v0.47.0 h1:RcD44ZitX5VyuWEspO7/4w6UOKPtsW8PXAQvaqPXy6Q=
+github.com/riverqueue/river/riverdriver/riversqlite v0.47.0/go.mod h1:QGRduy9CH+qo3ulGPqom0sQDyFCHl1itr8fHW18q1D0=
+github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOtPKQZAmThkjFpLs=
+github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
+github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
+github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
+github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
+github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
+golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
+modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
+modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
+modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
+modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/cmd/riverconformanceadapter/main.go b/internal/cmd/riverconformanceadapter/main.go
new file mode 100644
index 000000000..db295a6d7
--- /dev/null
+++ b/internal/cmd/riverconformanceadapter/main.go
@@ -0,0 +1,4300 @@
+// Command riverconformanceadapter exposes River Go through the shared
+// newline-delimited JSON-RPC conformance protocol.
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/robfig/cron/v3"
+ "modernc.org/sqlite"
+
+ "github.com/riverqueue/river"
+ "github.com/riverqueue/river/internal/dbunique"
+ "github.com/riverqueue/river/internal/retrypolicy"
+ "github.com/riverqueue/river/riverdriver"
+ "github.com/riverqueue/river/riverdriver/riverpgxv5"
+ "github.com/riverqueue/river/riverdriver/riversqlite"
+ "github.com/riverqueue/river/rivermigrate"
+ "github.com/riverqueue/river/rivershared/baseservice"
+ "github.com/riverqueue/river/rivershared/riverpilot"
+ "github.com/riverqueue/river/rivertype"
+)
+
+const (
+ adapterVersion = 22
+ implementationVersion = "0.49.0"
+ protocolRevision = 1
+)
+
+var adapterMethods = []string{ //nolint:gochecknoglobals
+ "barrier_create",
+ "barrier_release",
+ "benchmark_enqueue",
+ "cancel",
+ "clock_set",
+ "connection_count",
+ "cron_next",
+ "delete",
+ "delete_finalized",
+ "delete_many",
+ "fault_disconnect_application",
+ "fault_disconnect_listeners",
+ "fault_expire_leader",
+ "get",
+ "handshake",
+ "insert",
+ "insert_many",
+ "leader",
+ "list",
+ "listener_count",
+ "migrate",
+ "queue_add",
+ "queue_get",
+ "queue_list",
+ "queue_pause",
+ "queue_remove",
+ "queue_resume",
+ "queue_update",
+ "raw_finalize",
+ "raw_insert_exact_json",
+ "raw_insert_full_row",
+ "raw_insert_no_notify",
+ "raw_job_exact_json",
+ "raw_job_row",
+ "raw_job_timestamps",
+ "raw_notifications",
+ "raw_replace_json_text",
+ "raw_set_kind",
+ "request_resign",
+ "reset",
+ "retry",
+ "retry_delay",
+ "rng_seed",
+ "runtime_stats",
+ "start",
+ "stop",
+ "tx_begin",
+ "tx_cancel",
+ "tx_commit",
+ "tx_delete",
+ "tx_delete_many",
+ "tx_fail",
+ "tx_get",
+ "tx_insert",
+ "tx_insert_many",
+ "tx_list",
+ "tx_queue_get",
+ "tx_queue_list",
+ "tx_queue_pause",
+ "tx_queue_resume",
+ "tx_queue_update",
+ "tx_retry",
+ "tx_rollback",
+ "tx_update",
+ "unique_key",
+ "update",
+ "wait",
+ "work",
+}
+
+var capabilities = []string{ //nolint:gochecknoglobals
+ "barriers",
+ "cancel",
+ "custom_schema",
+ "deterministic_controls",
+ "extensions",
+ "fault_injection",
+ "get",
+ "insert",
+ "job_crud",
+ "leadership",
+ "lifecycle",
+ "maintenance",
+ "migrate",
+ "notifications",
+ "periodic_jobs",
+ "poll_only",
+ "queues",
+ "reset",
+ "resumable_jobs",
+ "retry",
+ "scheduler",
+ "subscriptions",
+ "transactions",
+ "unique_jobs",
+ "work",
+}
+
+var sqliteAdapterMethods = []string{ //nolint:gochecknoglobals
+ "cancel",
+ "clock_set",
+ "cron_next",
+ "delete",
+ "delete_many",
+ "get",
+ "handshake",
+ "insert",
+ "insert_many",
+ "list",
+ "migrate",
+ "raw_insert_exact_json",
+ "raw_job_exact_json",
+ "raw_job_row",
+ "raw_job_timestamps",
+ "reset",
+ "retry",
+ "retry_delay",
+ "rng_seed",
+ "tx_begin",
+ "tx_cancel",
+ "tx_commit",
+ "tx_delete",
+ "tx_delete_many",
+ "tx_get",
+ "tx_insert",
+ "tx_insert_many",
+ "tx_list",
+ "tx_retry",
+ "tx_rollback",
+ "tx_update",
+ "unique_key",
+ "update",
+}
+
+var sqliteCapabilities = []string{ //nolint:gochecknoglobals
+ "cancel",
+ "deterministic_controls",
+ "get",
+ "insert",
+ "job_crud",
+ "lifecycle",
+ "migrate",
+ "reset",
+ "retry",
+ "transactions",
+ "unique_jobs",
+}
+
+var sqliteRuntimeCapabilities = []string{ //nolint:gochecknoglobals
+ "barriers", "cancel", "deterministic_controls", "extensions", "get", "insert",
+ "job_crud", "leadership", "lifecycle", "migrate", "notifications",
+ "periodic_jobs", "poll_only", "queues", "reset", "resumable_jobs", "retry", "scheduler",
+ "subscriptions", "transactions", "unique_jobs", "work",
+}
+
+var sqliteRuntimeMethods = []string{ //nolint:gochecknoglobals
+ "barrier_create", "barrier_release", "cancel", "clock_set", "cron_next", "delete", "delete_finalized", "delete_many", "get",
+ "handshake", "insert", "insert_many", "leader", "list", "migrate",
+ "queue_add", "queue_get", "queue_list", "queue_pause", "queue_remove", "queue_resume",
+ "queue_update", "raw_finalize", "raw_insert_exact_json", "raw_insert_no_notify", "raw_job_exact_json", "raw_job_row", "raw_job_timestamps", "raw_notifications", "raw_replace_json_text", "raw_set_kind", "request_resign", "reset", "retry", "retry_delay",
+ "rng_seed", "runtime_stats", "start", "stop", "tx_begin", "tx_cancel", "tx_commit",
+ "tx_delete", "tx_delete_many", "tx_get", "tx_insert", "tx_insert_many",
+ "tx_list", "tx_queue_get", "tx_queue_list", "tx_queue_pause", "tx_queue_resume",
+ "tx_queue_update", "tx_retry", "tx_rollback", "tx_update", "unique_key", "update", "wait", "work",
+}
+
+// sqliteJSONColumnStatements read and replace each SQLite job JSON column for
+// raw_replace_json_text: stored TEXT as is, JSONB rendered with json().
+var sqliteJSONColumnStatements = map[string]struct{ get, set string }{ //nolint:gochecknoglobals
+ "args": {
+ get: "SELECT CASE WHEN typeof(args) = 'text' THEN args ELSE json(args) END, typeof(args) FROM river_job WHERE id = ?",
+ set: "UPDATE river_job SET args = ? WHERE id = ?",
+ },
+ "attempted_by": {
+ get: "SELECT CASE WHEN typeof(attempted_by) = 'text' THEN attempted_by ELSE json(attempted_by) END, typeof(attempted_by) FROM river_job WHERE id = ?",
+ set: "UPDATE river_job SET attempted_by = ? WHERE id = ?",
+ },
+ "errors": {
+ get: "SELECT CASE WHEN typeof(errors) = 'text' THEN errors ELSE json(errors) END, typeof(errors) FROM river_job WHERE id = ?",
+ set: "UPDATE river_job SET errors = ? WHERE id = ?",
+ },
+ "metadata": {
+ get: "SELECT CASE WHEN typeof(metadata) = 'text' THEN metadata ELSE json(metadata) END, typeof(metadata) FROM river_job WHERE id = ?",
+ set: "UPDATE river_job SET metadata = ? WHERE id = ?",
+ },
+ "tags": {
+ get: "SELECT CASE WHEN typeof(tags) = 'text' THEN tags ELSE json(tags) END, typeof(tags) FROM river_job WHERE id = ?",
+ set: "UPDATE river_job SET tags = ? WHERE id = ?",
+ },
+}
+
+// parameterlessMethods take no params; any param is rejected.
+var parameterlessMethods = []string{ //nolint:gochecknoglobals
+ "connection_count", "fault_disconnect_listeners", "fault_expire_leader", "handshake", "leader",
+ "listener_count", "raw_insert_full_row", "runtime_stats",
+}
+
+// insertOnlyCapabilities and insertOnlyMethods are the insert-only-v1
+// profile, for clients that only insert jobs.
+var insertOnlyCapabilities = []string{"insert", "lifecycle", "transactions", "unique_jobs"} //nolint:gochecknoglobals
+
+var insertOnlyMethods = []string{ //nolint:gochecknoglobals
+ "handshake", "insert", "insert_many", "tx_begin", "tx_commit", "tx_insert", "tx_insert_many",
+ "tx_rollback", "unique_key",
+}
+
+// checkRequest rejects methods outside the advertised profile and params on
+// methods that take none.
+func checkRequest(req *request, methods []string) error {
+ if !slices.Contains(methods, req.Method) {
+ return methodNotFound(req.Method)
+ }
+ if slices.Contains(parameterlessMethods, req.Method) {
+ return decodeParams(req.Params, &struct{}{})
+ }
+ return nil
+}
+
+// rawJobRow is a job's JSON and timestamp columns as the database renders
+// them, for comparison across implementations.
+type rawJobRow struct {
+ Args string `json:"args"`
+ AttemptedAt *string `json:"attempted_at"`
+ AttemptedBy *string `json:"attempted_by"`
+ CreatedAt string `json:"created_at"`
+ Errors *string `json:"errors"`
+ FinalizedAt *string `json:"finalized_at"`
+ // JSONB is SQLite's stored JSONB bytes, and nil on PostgreSQL.
+ JSONB *rawJSONBColumns `json:"jsonb"`
+ Metadata string `json:"metadata"`
+ ScheduledAt string `json:"scheduled_at"`
+ Tags string `json:"tags"`
+ // UniqueKey is the stored unique key as uppercase hex.
+ UniqueKey *string `json:"unique_key"`
+ // UniqueKeyType is SQLite's typeof(unique_key), and nil on PostgreSQL.
+ UniqueKeyType *string `json:"unique_key_type"`
+ // UniqueStates is the stored state mask as the database renders it as
+ // text.
+ UniqueStates *string `json:"unique_states"`
+ // UniqueStatesType is SQLite's typeof(unique_states), and nil on
+ // PostgreSQL.
+ UniqueStatesType *string `json:"unique_states_type"`
+}
+
+// rawJSONBColumns is a SQLite job's JSONB columns as uppercase hex, so the
+// harness can check that each column is stored as JSONB and decodes to the
+// JSON text's value.
+type rawJSONBColumns struct {
+ Args string `json:"args"`
+ AttemptedBy *string `json:"attempted_by"`
+ Errors *string `json:"errors"`
+ Metadata string `json:"metadata"`
+ Tags string `json:"tags"`
+}
+
+// scanTargets returns the row's fields in the column order the raw_job_row
+// queries select.
+func (row *rawJobRow) scanTargets() []any {
+ return []any{
+ &row.Args, &row.AttemptedAt, &row.AttemptedBy, &row.CreatedAt, &row.Errors,
+ &row.FinalizedAt, &row.Metadata, &row.ScheduledAt, &row.Tags, &row.UniqueKey, &row.UniqueStates,
+ }
+}
+
+type request struct {
+ ID any `json:"id"`
+ JSONRPC string `json:"jsonrpc"`
+ Method string `json:"method"`
+ Params json.RawMessage `json:"params"`
+}
+
+type response struct {
+ Error *responseError `json:"error,omitempty"`
+ ID any `json:"id"`
+ JSONRPC string `json:"jsonrpc"`
+ Result any `json:"result,omitempty"`
+}
+
+type responseError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+// Stable JSON-RPC error codes from conformance/adapter/contract.json.
+const (
+ errorCodeDatabase = -32003
+ errorCodeInvalidParams = -32602
+ errorCodeInvalidRequest = -32600
+ errorCodeMethodNotFound = -32601
+ errorCodeNotFound = -32001
+ errorCodeParse = -32700
+ errorCodeRejected = -32002
+ errorCodeUnsupported = -32004
+)
+
+// adapterError carries the contract error code for a failure the adapter
+// classifies itself.
+type adapterError struct {
+ code int
+ err error
+}
+
+func (e *adapterError) Error() string { return e.err.Error() }
+
+func (e *adapterError) Unwrap() error { return e.err }
+
+func invalidParams(err error) error { return &adapterError{code: errorCodeInvalidParams, err: err} }
+
+func methodNotFound(method string) error {
+ return &adapterError{code: errorCodeMethodNotFound, err: fmt.Errorf("method not found: %s", method)}
+}
+
+func notFound(err error) error { return &adapterError{code: errorCodeNotFound, err: err} }
+
+func rejected(err error) error { return &adapterError{code: errorCodeRejected, err: err} }
+
+func transactionNotFound(handle string) error {
+ return notFound(fmt.Errorf("transaction %q not found", handle))
+}
+
+func unsupported(err error) error { return &adapterError{code: errorCodeUnsupported, err: err} }
+
+// errorCode maps a failure to its contract error code. River reports missing
+// rows with rivertype.ErrNotFound and database failures with driver errors;
+// every other failure River returns is a rejection of the request.
+func errorCode(err error) int {
+ var classified *adapterError
+ var postgresErr *pgconn.PgError
+ var sqliteErr *sqlite.Error
+ switch {
+ case errors.As(err, &classified):
+ return classified.code
+ case errors.Is(err, rivertype.ErrNotFound):
+ return errorCodeNotFound
+ case errors.As(err, &postgresErr), errors.As(err, &sqliteErr):
+ return errorCodeDatabase
+ default:
+ return errorCodeRejected
+ }
+}
+
+// isAdapterApplicationName reports whether name may identify a conformance
+// adapter's PostgreSQL connections. Every adapter's name carries the
+// river-conformance- prefix, which fault injection relies on to never
+// terminate other connections, and the harness's own observer name is
+// excluded.
+func isAdapterApplicationName(name string) bool {
+ return strings.HasPrefix(name, "river-conformance-") && name != "river-conformance-harness"
+}
+
+// decodeParams decodes request params strictly: absent params decode as an
+// empty object and unknown fields are rejected, so a harness or protocol
+// mismatch fails loudly instead of being ignored.
+func decodeParams(raw json.RawMessage, target any) error {
+ trimmed := bytes.TrimSpace(raw)
+ if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
+ trimmed = []byte("{}")
+ }
+ decoder := json.NewDecoder(bytes.NewReader(trimmed))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return invalidParams(err)
+ }
+ return nil
+}
+
+// checkHandle rejects a transaction handle on a method that does not take
+// one and requires one on a method that does.
+func checkHandle(handle string, transactional bool) error {
+ switch {
+ case transactional && handle == "":
+ return invalidParams(errors.New("handle is required"))
+ case !transactional && handle != "":
+ return invalidParams(errors.New("unknown field \"handle\""))
+ }
+ return nil
+}
+
+type conformanceArgs struct {
+ Behavior string `json:"behavior"`
+ DurationMS uint64 `json:"duration_ms"`
+ Message string `json:"message"`
+}
+
+func (conformanceArgs) Kind() string { return "conformance_echo" }
+
+func (args conformanceArgs) echoArgs() conformanceArgs { return args }
+
+// conformancePeerArgs registers the built-in worker under a second kind, so
+// clients of a heterogeneous fleet can each know only their own kind.
+type conformancePeerArgs struct {
+ conformanceArgs
+}
+
+func (conformancePeerArgs) Kind() string { return "conformance_echo_peer" }
+
+// conformanceRenamedArgs is the built-in worker after a safe rename from
+// `conformance_echo`, which it keeps as a kind alias.
+type conformanceRenamedArgs struct {
+ conformanceArgs
+}
+
+func (conformanceRenamedArgs) Kind() string { return "conformance_echo_renamed" }
+
+func (conformanceRenamedArgs) KindAliases() []string { return []string{"conformance_echo"} }
+
+// echoJobArgs is implemented by every args type the built-in worker is
+// registered under.
+type echoJobArgs interface {
+ river.JobArgs
+
+ echoArgs() conformanceArgs
+}
+
+// kindWorker works jobs of another registered kind with the built-in worker.
+type kindWorker[T echoJobArgs] struct {
+ river.WorkerDefaults[T]
+
+ inner *conformanceWorker
+}
+
+func (w *kindWorker[T]) Work(ctx context.Context, job *river.Job[T]) error {
+ return w.inner.Work(ctx, &river.Job[conformanceArgs]{JobRow: job.JobRow, Args: job.Args.echoArgs()})
+}
+
+// addConformanceWorkers registers the built-in worker under each of kinds,
+// defaulting to `conformance_echo` alone.
+func addConformanceWorkers(workers *river.Workers, worker *conformanceWorker, kinds []string) error {
+ if len(kinds) == 0 {
+ kinds = []string{conformanceArgs{}.Kind()}
+ }
+ for _, kind := range kinds {
+ var err error
+ switch kind {
+ case conformanceArgs{}.Kind():
+ err = river.AddWorkerSafely(workers, worker)
+ case conformancePeerArgs{}.Kind():
+ err = river.AddWorkerSafely(workers, &kindWorker[conformancePeerArgs]{inner: worker})
+ case conformanceRenamedArgs{}.Kind():
+ err = river.AddWorkerSafely(workers, &kindWorker[conformanceRenamedArgs]{inner: worker})
+ default:
+ return invalidParams(fmt.Errorf("unknown worker kind %q", kind))
+ }
+ if err != nil {
+ return invalidParams(err)
+ }
+ }
+ return nil
+}
+
+// uniqueAllArgs accepts any encoded arguments, including non-object ones, so
+// River itself decides whether all-args uniqueness can use them. Keys are
+// computed from the request's raw argument bytes.
+type uniqueAllArgs struct{}
+
+func (uniqueAllArgs) Kind() string { return "conformance_all_args" }
+
+func (*uniqueAllArgs) UnmarshalJSON([]byte) error { return nil }
+
+type uniqueNumericArgs struct {
+ Exponent float64 `json:"exponent"`
+ Fraction float64 `json:"fraction"`
+ Maximum int64 `json:"maximum"`
+ Minimum int64 `json:"minimum"`
+ UnsignedMaximum uint64 `json:"unsigned_maximum"`
+}
+
+func (uniqueNumericArgs) Kind() string { return "conformance_numeric_boundaries" }
+
+type uniqueSelectedAccount struct {
+ ID string `json:"id" river:"unique"`
+ Ignored string `json:"ignored"`
+ Region string `json:"region,omitempty" river:"unique"`
+}
+
+type uniqueSelectedArgs struct {
+ Account uniqueSelectedAccount `json:"account"`
+ Ignored bool `json:"ignored"`
+ Label string `json:"label" river:"unique"`
+ PathKey string `json:"path/key,omitempty" river:"unique"`
+}
+
+func (uniqueSelectedArgs) Kind() string { return "conformance_selected_args" }
+
+type uniqueDottedSelectedUser struct {
+ ID string `json:"id,omitempty" river:"unique"`
+}
+
+type uniqueDottedSelectedArgs struct {
+ At string `json:"@user,omitempty" river:"unique"`
+ Bang string `json:"!x,omitempty" river:"unique"`
+ Brace string `json:"{x},omitempty" river:"unique"`
+ Bracket string `json:"[x],omitempty" river:"unique"`
+ Colon string `json:":id,omitempty" river:"unique"`
+ //nolint:tagliatelle // literal dotted names distinguish them from nested paths
+ Literal string `json:"user.id,omitempty" river:"unique"`
+ Symbols string `json:"a*b?c#d|e,omitempty" river:"unique"`
+ User uniqueDottedSelectedUser `json:"user"`
+ Unicode string `json:"é,omitempty" river:"unique"`
+}
+
+func (uniqueDottedSelectedArgs) Kind() string { return "conformance_dotted_selected_args" }
+
+type uniqueSimpleArgs struct {
+ ID int64 `json:"id"`
+}
+
+func (uniqueSimpleArgs) Kind() string { return "conformance_simple" }
+
+type fixedClock struct{ now time.Time }
+
+func (c fixedClock) Now() time.Time { return c.now }
+
+func (fixedClock) NowOrNil() *time.Time { return nil }
+
+type conformanceWorker struct {
+ river.WorkerDefaults[conformanceArgs]
+
+ barriers *barrierRegistry
+ pool *pgxpool.Pool
+ probe *runtimeProbe
+}
+
+func (w *conformanceWorker) Work(ctx context.Context, job *river.Job[conformanceArgs]) error {
+ switch job.Args.Behavior {
+ case "barrier_output", "barrier_wait":
+ if err := w.barriers.wait(ctx, job.Args.Message); err != nil {
+ return err
+ }
+ if job.Args.Behavior == "barrier_output" {
+ return river.RecordOutput(ctx, map[string]any{"race": "worker"})
+ }
+ return nil
+ case "cancel":
+ return river.JobCancel(errors.New("cancelled by conformance worker"))
+ case "cancel_error":
+ <-ctx.Done()
+ return errors.New("conformance failure after cancellation")
+ case "cancel_panic":
+ <-ctx.Done()
+ panic("conformance panic after cancellation")
+ case "cooperative_cancel":
+ if ctx.Err() != nil && w.probe != nil {
+ w.probe.incrementCancelledAtStart()
+ }
+ <-ctx.Done()
+ return ctx.Err()
+ case "discard":
+ return errors.New("conformance discard")
+ case "error":
+ return errors.New("conformance retryable error")
+ case "ignored_cancel":
+ select {}
+ case "output":
+ return river.RecordOutput(ctx, map[string]any{"message": job.Args.Message})
+ case "panic":
+ panic("conformance worker panic")
+ case "sleep":
+ duration, err := durationFromMilliseconds(job.Args.DurationMS)
+ if err != nil {
+ return err
+ }
+ time.Sleep(duration)
+ case "snooze_once", "snooze_then_cancel":
+ var metadata map[string]any
+ if err := json.Unmarshal(job.Metadata, &metadata); err != nil {
+ return err
+ }
+ if _, alreadySnoozed := metadata["snoozes"]; !alreadySnoozed {
+ duration, err := durationFromMilliseconds(max(job.Args.DurationMS, 1))
+ if err != nil {
+ return err
+ }
+ return river.JobSnooze(duration)
+ }
+ if job.Args.Behavior == "snooze_then_cancel" {
+ <-ctx.Done()
+ return ctx.Err()
+ }
+ case "resumable_cursor":
+ river.ResumableStep(ctx, "first", nil, func(ctx context.Context) error {
+ return river.MetadataSet(ctx, "first_attempt", job.Attempt)
+ })
+ river.ResumableStepCursor(ctx, "second", nil, func(ctx context.Context, cursor int) error {
+ if job.Attempt == 1 {
+ if err := river.ResumableSetCursor(ctx, 7); err != nil {
+ return err
+ }
+ return errors.New("retry with cursor")
+ }
+ if cursor != 7 {
+ return fmt.Errorf("expected cursor 7, got %d", cursor)
+ }
+ return river.MetadataSet(ctx, "cursor_observed", cursor)
+ })
+ river.ResumableStep(ctx, "third", nil, func(ctx context.Context) error {
+ if job.Attempt == 2 {
+ return errors.New("retry after consuming cursor")
+ }
+ return nil
+ })
+ case "resumable", "resumable_duplicate":
+ river.ResumableStep(ctx, "first", nil, func(ctx context.Context) error {
+ w.probe.incrementResumableFirst()
+ return nil
+ })
+ secondName := "second"
+ if job.Args.Behavior == "resumable_duplicate" {
+ secondName = "first"
+ }
+ river.ResumableStep(ctx, secondName, nil, func(ctx context.Context) error {
+ w.probe.incrementResumableSecond()
+ if job.Attempt == 1 {
+ return errors.New("fail second resumable step once")
+ }
+ return nil
+ })
+ case "transactional_complete":
+ if w.pool == nil {
+ return errors.New("transactional completion is unavailable for this backend")
+ }
+ if err := river.MetadataSet(ctx, "transactional_completion", true); err != nil {
+ return err
+ }
+ tx, err := w.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ if _, err := river.JobCompleteTx[*riverpgxv5.Driver](ctx, tx, job); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+ }
+ return nil
+}
+
+// startTuningParams are the contract's optional start tuning parameters.
+// River Go keeps these intervals internal, so the reference adapter reports
+// them as unsupported rather than silently ignoring them.
+type startTuningParams struct {
+ ElectIntervalMS *uint64 `json:"elect_interval_ms"`
+ RescuerIntervalMS *uint64 `json:"rescuer_interval_ms"`
+ SchedulerIntervalMS *uint64 `json:"scheduler_interval_ms"`
+}
+
+func (p startTuningParams) reject() error {
+ for name, value := range map[string]*uint64{
+ "elect_interval_ms": p.ElectIntervalMS,
+ "rescuer_interval_ms": p.RescuerIntervalMS,
+ "scheduler_interval_ms": p.SchedulerIntervalMS,
+ } {
+ if value != nil {
+ return unsupported(fmt.Errorf("the Go reference does not expose %s as configuration", name))
+ }
+ }
+ return nil
+}
+
+type runtimeProbe struct {
+ cancelledAtStart int
+ errorHandlerCalls int
+ events []string
+ mu sync.Mutex
+ periodicStarts int
+ resumableFirstRuns int
+ resumableSecondRuns int
+ stuckJobs int
+ trace []string
+}
+
+func (p *runtimeProbe) incrementErrorHandlerCalls() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.errorHandlerCalls++
+}
+
+func (p *runtimeProbe) addEvent(kind river.EventKind) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.events = append(p.events, string(kind))
+}
+
+func (p *runtimeProbe) addTrace(entry string) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.trace = append(p.trace, entry)
+}
+
+func (p *runtimeProbe) incrementCancelledAtStart() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.cancelledAtStart++
+}
+
+func (p *runtimeProbe) incrementPeriodicStarts() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.periodicStarts++
+}
+
+func (p *runtimeProbe) incrementResumableFirst() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.resumableFirstRuns++
+}
+
+func (p *runtimeProbe) incrementResumableSecond() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.resumableSecondRuns++
+}
+
+func (p *runtimeProbe) incrementStuckJobs() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.stuckJobs++
+}
+
+func (p *runtimeProbe) snapshot() map[string]any {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return map[string]any{
+ "cancelled_at_start": p.cancelledAtStart,
+ "error_handler_calls": p.errorHandlerCalls,
+ "events": valueOrEmpty(slices.Clone(p.events)),
+ "periodic_starts": p.periodicStarts,
+ "resumable_first_runs": p.resumableFirstRuns,
+ "resumable_second_runs": p.resumableSecondRuns,
+ "stuck_jobs": p.stuckJobs,
+ "trace": valueOrEmpty(slices.Clone(p.trace)),
+ }
+}
+
+type conformanceErrorHandler struct {
+ probe *runtimeProbe
+}
+
+func (h *conformanceErrorHandler) HandleError(ctx context.Context, job *rivertype.JobRow, err error) *river.ErrorHandlerResult {
+ h.probe.incrementErrorHandlerCalls()
+ return &river.ErrorHandlerResult{SetCancelled: true}
+}
+
+func (h *conformanceErrorHandler) HandlePanic(ctx context.Context, job *rivertype.JobRow, panicVal any, trace string) *river.ErrorHandlerResult {
+ h.probe.incrementErrorHandlerCalls()
+ return &river.ErrorHandlerResult{SetCancelled: true}
+}
+
+type conformancePlugin struct {
+ river.PluginDefaults
+
+ probe *runtimeProbe
+}
+
+func (p *conformancePlugin) InsertBegin(_ context.Context, _ *rivertype.JobInsertParams) error { //nolint:unparam // River hook signature requires an error result.
+ p.probe.addTrace("hook:insert_begin")
+ return nil
+}
+
+func (p *conformancePlugin) InsertMany(ctx context.Context, _ []*rivertype.JobInsertParams, doInner func(context.Context) ([]*rivertype.JobInsertResult, error)) ([]*rivertype.JobInsertResult, error) {
+ p.probe.addTrace("middleware:insert_before")
+ results, err := doInner(ctx)
+ p.probe.addTrace("middleware:insert_after")
+ return results, err
+}
+
+func (p *conformancePlugin) Start(_ context.Context, _ *rivertype.HookPeriodicJobsStartParams) error { //nolint:unparam // River hook signature requires an error result.
+ p.probe.incrementPeriodicStarts()
+ p.probe.addTrace("hook:periodic_start")
+ return nil
+}
+
+func (p *conformancePlugin) WorkBegin(_ context.Context, _ *rivertype.JobRow) error { //nolint:unparam // River hook signature requires an error result.
+ p.probe.addTrace("hook:work_begin")
+ return nil
+}
+
+func (p *conformancePlugin) Work(ctx context.Context, _ *rivertype.JobRow, doInner func(context.Context) error) error {
+ p.probe.addTrace("middleware:work_before")
+ err := doInner(ctx)
+ p.probe.addTrace("middleware:work_after")
+ return err
+}
+
+func (p *conformancePlugin) WorkEnd(_ context.Context, _ *rivertype.JobRow, err error) error {
+ p.probe.addTrace("hook:work_end")
+ return err
+}
+
+type fixedRetryPolicy struct{ delay time.Duration }
+
+func (p fixedRetryPolicy) NextRetry(job *rivertype.JobRow) time.Time {
+ return time.Now().UTC().Add(p.delay)
+}
+
+type barrierRegistry struct {
+ mu sync.Mutex
+ waiters map[string]chan struct{}
+}
+
+func newBarrierRegistry() *barrierRegistry {
+ return &barrierRegistry{waiters: make(map[string]chan struct{})}
+}
+
+func (r *barrierRegistry) clear() {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ for _, waiter := range r.waiters {
+ close(waiter)
+ }
+ r.waiters = make(map[string]chan struct{})
+}
+
+func (r *barrierRegistry) create(name string) error {
+ if name == "" {
+ return errors.New("barrier name is required")
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if _, exists := r.waiters[name]; exists {
+ return fmt.Errorf("barrier %q already exists", name)
+ }
+ r.waiters[name] = make(chan struct{})
+ return nil
+}
+
+func (r *barrierRegistry) release(name string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ waiter, exists := r.waiters[name]
+ if !exists {
+ return notFound(fmt.Errorf("barrier %q not found", name))
+ }
+ close(waiter)
+ delete(r.waiters, name)
+ return nil
+}
+
+func (r *barrierRegistry) exists(name string) bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ _, exists := r.waiters[name]
+ return exists
+}
+
+// releaseIfPresent releases a barrier that hasn't been released yet.
+func (r *barrierRegistry) releaseIfPresent(name string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if waiter, exists := r.waiters[name]; exists {
+ close(waiter)
+ delete(r.waiters, name)
+ }
+}
+
+func (r *barrierRegistry) wait(ctx context.Context, name string) error {
+ r.mu.Lock()
+ waiter, exists := r.waiters[name]
+ r.mu.Unlock()
+ if !exists {
+ return notFound(fmt.Errorf("barrier %q not found", name))
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-waiter:
+ return nil
+ }
+}
+
+type insertParams struct {
+ Behavior string `json:"behavior"`
+ DurationMS uint64 `json:"duration_ms"`
+ Kind string `json:"kind"`
+ Message string `json:"message"`
+ Opts insertOptsParams `json:"opts"`
+ Schema string `json:"schema"`
+}
+
+// conformanceBehaviors are the worker behaviors the contract defines.
+var conformanceBehaviors = []string{ //nolint:gochecknoglobals // contract enum
+ "", "barrier_output", "barrier_wait", "cancel", "cancel_error", "cancel_panic",
+ "cooperative_cancel", "discard", "error", "ignored_cancel", "output", "panic", "resumable",
+ "resumable_cursor", "resumable_duplicate", "sleep", "snooze_once", "snooze_then_cancel",
+ "transactional_complete",
+}
+
+// rejectRawOnlyFields rejects the fields only raw inserts and single inserts
+// accept when they appear in batch or transactional job params, and
+// validates the worker behavior.
+func (p insertParams) rejectRawOnlyFields() error {
+ if p.Kind != "" {
+ return invalidParams(errors.New("unknown field \"kind\""))
+ }
+ if p.Schema != "" {
+ return invalidParams(errors.New("unknown field \"schema\""))
+ }
+ return p.validateBehavior()
+}
+
+func (p insertParams) validateBehavior() error {
+ if !slices.Contains(conformanceBehaviors, p.Behavior) {
+ return invalidParams(fmt.Errorf("unknown behavior %q", p.Behavior))
+ }
+ return nil
+}
+
+func (p insertParams) args() conformanceArgs {
+ return conformanceArgs{Behavior: p.Behavior, DurationMS: p.DurationMS, Message: p.Message}
+}
+
+type insertOptsParams struct {
+ MaxAttempts *int `json:"max_attempts"`
+ Metadata json.RawMessage `json:"metadata"`
+ Pending bool `json:"pending"`
+ Priority *int `json:"priority"`
+ Queue *string `json:"queue"`
+ ScheduledAt *time.Time `json:"scheduled_at"`
+ Tags []string `json:"tags"`
+ Unique uniqueOptsParams `json:"unique"`
+}
+
+// rawSetKindParams names a job whose kind raw_set_kind rewrites out of band.
+type rawSetKindParams struct {
+ ID int64 `json:"id"`
+ Kind string `json:"kind"`
+}
+
+type uniqueOptsParams struct {
+ ByArgs bool `json:"by_args"`
+ ByPeriodMS *uint64 `json:"by_period_ms"`
+ ByQueue bool `json:"by_queue"`
+ ByState []rivertype.JobState `json:"by_state"`
+ ExcludeKind bool `json:"exclude_kind"`
+}
+
+type uniqueKeyParams struct {
+ Args json.RawMessage `json:"args"`
+ Kind string `json:"kind"`
+ Now time.Time `json:"now"`
+ Options struct {
+ ByArgs bool `json:"by_args"`
+ ByPeriodNanos int64 `json:"by_period_nanos"`
+ ByQueue bool `json:"by_queue"`
+ ByState []rivertype.JobState `json:"by_state"`
+ ExcludeKind bool `json:"exclude_kind"`
+ } `json:"options"`
+ Queue string `json:"queue"`
+ ScheduledAt *time.Time `json:"scheduled_at"`
+
+ // Fixture expectations and documentation passed through unchanged by
+ // the harness; the adapter ignores them.
+ ExpectedError string `json:"expected_error"`
+ ExpectedSHA256 string `json:"expected_sha256"`
+ ExpectedStateMask int `json:"expected_state_mask"`
+ Name string `json:"name"`
+ SelectedUniqueComponents [][]string `json:"selected_unique_components"`
+ SelectedUniquePaths []string `json:"selected_unique_paths"`
+}
+
+func (p uniqueKeyParams) jobArgs() (rivertype.JobArgs, error) {
+ var args rivertype.JobArgs
+ switch p.Kind {
+ case "conformance_all_args":
+ args = &uniqueAllArgs{}
+ case "conformance_numeric_boundaries":
+ var encoded struct {
+ Exponent float64 `json:"exponent"`
+ Fraction float64 `json:"fraction"`
+ Maximum json.Number `json:"maximum"`
+ Minimum json.Number `json:"minimum"`
+ UnsignedMaximum json.Number `json:"unsigned_maximum"`
+ }
+ decoder := json.NewDecoder(bytes.NewReader(p.Args))
+ decoder.UseNumber()
+ if err := decoder.Decode(&encoded); err != nil {
+ return nil, err
+ }
+ maximum, err := strconv.ParseInt(encoded.Maximum.String(), 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ minimum, err := strconv.ParseInt(encoded.Minimum.String(), 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ unsignedMaximum, err := strconv.ParseUint(encoded.UnsignedMaximum.String(), 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ return &uniqueNumericArgs{
+ Exponent: encoded.Exponent, Fraction: encoded.Fraction, Maximum: maximum,
+ Minimum: minimum, UnsignedMaximum: unsignedMaximum,
+ }, nil
+ case "conformance_selected_args":
+ args = &uniqueSelectedArgs{}
+ case "conformance_dotted_selected_args":
+ args = &uniqueDottedSelectedArgs{}
+ case "conformance_simple":
+ args = &uniqueSimpleArgs{}
+ default:
+ return nil, fmt.Errorf("unsupported unique fixture kind %q", p.Kind)
+ }
+ if err := json.Unmarshal(p.Args, args); err != nil {
+ return nil, err
+ }
+ return args, nil
+}
+
+func (p insertOptsParams) opts() (*river.InsertOpts, error) {
+ opts := &river.InsertOpts{
+ Metadata: p.Metadata,
+ Pending: p.Pending,
+ ScheduledAt: valueOrZero(p.ScheduledAt),
+ Tags: p.Tags,
+ UniqueOpts: river.UniqueOpts{
+ ByArgs: p.Unique.ByArgs,
+ ByQueue: p.Unique.ByQueue,
+ ByState: p.Unique.ByState,
+ ExcludeKind: p.Unique.ExcludeKind,
+ },
+ }
+ if p.MaxAttempts != nil {
+ opts.MaxAttempts = *p.MaxAttempts
+ }
+ if p.Priority != nil {
+ opts.Priority = *p.Priority
+ }
+ if p.Queue != nil {
+ opts.Queue = *p.Queue
+ }
+ if p.Unique.ByPeriodMS != nil {
+ byPeriod, err := durationFromMilliseconds(*p.Unique.ByPeriodMS)
+ if err != nil {
+ return nil, fmt.Errorf("unique period: %w", err)
+ }
+ opts.UniqueOpts.ByPeriod = byPeriod
+ }
+ return opts, nil
+}
+
+// latestMigrationVersion returns the newest main-line migration River bundles
+// for driver.
+func latestMigrationVersion[TTx any](driver riverdriver.Driver[TTx]) (int, error) {
+ migrator, err := rivermigrate.New(driver, &rivermigrate.Config{Logger: adapterLogger()})
+ if err != nil {
+ return 0, err
+ }
+ versions := migrator.AllVersions()
+ return versions[len(versions)-1].Version, nil
+}
+
+func handleUniqueKey(rawParams json.RawMessage) (any, error) {
+ var params uniqueKeyParams
+ if err := decodeParams(rawParams, ¶ms); err != nil {
+ return nil, err
+ }
+ args, err := params.jobArgs()
+ if err != nil {
+ return nil, err
+ }
+ opts := &dbunique.UniqueOpts{
+ ByArgs: params.Options.ByArgs,
+ ByPeriod: time.Duration(params.Options.ByPeriodNanos),
+ ByQueue: params.Options.ByQueue,
+ ByState: params.Options.ByState,
+ ExcludeKind: params.Options.ExcludeKind,
+ }
+ key, err := dbunique.UniqueKey(fixedClock{now: params.Now}, opts, &rivertype.JobInsertParams{
+ Args: args,
+ EncodedArgs: params.Args,
+ Kind: params.Kind,
+ Queue: params.Queue,
+ ScheduledAt: params.ScheduledAt,
+ UniqueStates: opts.StateBitmask(),
+ })
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{
+ "sha256": hex.EncodeToString(key),
+ "state_mask": opts.StateBitmask(),
+ }, nil
+}
+
+func handleQueueAdd(
+ ctx context.Context,
+ rawParams json.RawMessage,
+ addFunc func(string, river.QueueConfig) error,
+ removeFunc func(context.Context, string) error,
+) (any, error) {
+ var params struct {
+ MaxWorkers int `json:"max_workers"`
+ Name string `json:"name"`
+ }
+ if err := decodeParams(rawParams, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.MaxWorkers == 0 {
+ params.MaxWorkers = 1
+ }
+ err := addFunc(params.Name, river.QueueConfig{MaxWorkers: params.MaxWorkers})
+ if _, alreadyAdded := errors.AsType[*river.QueueAlreadyAddedError](err); alreadyAdded {
+ if err := removeFunc(ctx, params.Name); err != nil {
+ return nil, err
+ }
+ err = addFunc(params.Name, river.QueueConfig{MaxWorkers: params.MaxWorkers})
+ }
+ return map[string]any{}, err
+}
+
+type runningClient struct {
+ claimBarrier string
+ client *river.Client[pgx.Tx]
+ probe *runtimeProbe
+ subscription <-chan *river.Event
+ subscriptionCancel func()
+}
+
+type adapterState struct {
+ // applicationName identifies this process's PostgreSQL connections.
+ applicationName string
+ barriers *barrierRegistry
+ clock *time.Time
+ pool *pgxpool.Pool
+ profile string
+ rngSeed uint64
+ running *runningClient
+ transactions map[string]pgx.Tx
+}
+
+type requestHandler interface {
+ handle(ctx context.Context, request *request) (any, error)
+}
+
+type sqliteAdapterState struct {
+ barriers *barrierRegistry
+ clock *time.Time
+ pool *sql.DB
+ profile string
+ rngSeed uint64
+ running *sqliteRunningClient
+ transactions map[string]*sql.Tx
+}
+
+type sqliteRunningClient struct {
+ claimBarrier string
+ client *river.Client[*sql.Tx]
+ probe *runtimeProbe
+ subscription <-chan *river.Event
+ subscriptionCancel func()
+}
+
+func main() {
+ if err := run(context.Background()); err != nil {
+ fmt.Fprintln(os.Stderr, "River Go conformance adapter:", err)
+ os.Exit(1)
+ }
+}
+
+func run(ctx context.Context) error {
+ databaseURL := os.Getenv("RIVER_CONFORMANCE_DATABASE_URL")
+ if databaseURL == "" {
+ return errors.New("RIVER_CONFORMANCE_DATABASE_URL is required")
+ }
+ databaseKind := os.Getenv("RIVER_CONFORMANCE_DATABASE_KIND")
+ if databaseKind == "sqlite" {
+ profile := os.Getenv("RIVER_CONFORMANCE_PROFILE")
+ if profile == "" {
+ profile = "portable-storage-v1"
+ }
+ if profile != "portable-storage-v1" && profile != "sqlite-runtime-v1" {
+ return fmt.Errorf("unsupported SQLite conformance profile %q", profile)
+ }
+ // Apply the pragmas through the DSN so every pooled connection gets
+ // them. database/sql replaces a connection after an interrupted
+ // statement (for example during Client.Stop), and a pragma executed
+ // once would only reach the first one. The busy timeout comes first:
+ // another adapter may be switching the same new database to WAL at the
+ // same moment.
+ separator := "?"
+ if strings.Contains(databaseURL, "?") {
+ separator = "&"
+ }
+ pool, err := sql.Open("sqlite", databaseURL+separator+
+ "_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+ pool.SetMaxOpenConns(1)
+ if err := pool.PingContext(ctx); err != nil {
+ return fmt.Errorf("open SQLite database: %w", err)
+ }
+ state := &sqliteAdapterState{
+ barriers: newBarrierRegistry(),
+ pool: pool,
+ profile: profile,
+ transactions: make(map[string]*sql.Tx),
+ }
+ err = runRequestLoop(ctx, state)
+ if state.running != nil {
+ stopCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ _ = state.running.client.StopAndCancel(stopCtx)
+ state.running.subscriptionCancel()
+ }
+ return err
+ }
+ if databaseKind != "" && databaseKind != "postgres" {
+ return fmt.Errorf("unsupported RIVER_CONFORMANCE_DATABASE_KIND %q", databaseKind)
+ }
+ poolConfig, err := pgxpool.ParseConfig(databaseURL)
+ if err != nil {
+ return err
+ }
+ // The harness passes a name unique to this process so its observations
+ // and fault injection can't reach another process of the same
+ // implementation attached to the database.
+ applicationName := os.Getenv("RIVER_CONFORMANCE_APPLICATION_NAME")
+ if applicationName == "" {
+ applicationName = "river-conformance-go"
+ }
+ if !isAdapterApplicationName(applicationName) {
+ return fmt.Errorf("RIVER_CONFORMANCE_APPLICATION_NAME %q must name a conformance adapter", applicationName)
+ }
+ poolConfig.ConnConfig.RuntimeParams["application_name"] = applicationName
+ // Fault scenarios terminate this adapter's backends while they sit idle
+ // in the pool. Checking liveness on every acquire keeps a terminated
+ // connection from failing the next request; SQLx pools, used by other
+ // adapters, test connections before acquire by default as well.
+ poolConfig.ShouldPing = func(context.Context, pgxpool.ShouldPingParams) bool { return true }
+ pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+ profile := os.Getenv("RIVER_CONFORMANCE_PROFILE")
+ if profile == "" {
+ profile = "postgres-full-v1"
+ }
+ if profile != "postgres-full-v1" && profile != "insert-only-v1" {
+ return fmt.Errorf("unsupported PostgreSQL conformance profile %q", profile)
+ }
+ state := &adapterState{
+ applicationName: applicationName,
+ barriers: newBarrierRegistry(),
+ pool: pool,
+ profile: profile,
+ transactions: make(map[string]pgx.Tx),
+ }
+ err = runRequestLoop(ctx, state)
+ if state.running != nil {
+ stopCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ _ = state.running.client.StopAndCancel(stopCtx)
+ state.running.subscriptionCancel()
+ }
+ return err
+}
+
+func runRequestLoop(ctx context.Context, state requestHandler) error {
+ scanner := bufio.NewScanner(os.Stdin)
+ scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
+ encoder := json.NewEncoder(os.Stdout)
+ encoder.SetEscapeHTML(false)
+ for scanner.Scan() {
+ var req request
+ if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
+ if err := encoder.Encode(errorResponse(nil, errorCodeParse, err)); err != nil {
+ return err
+ }
+ continue
+ }
+ if req.JSONRPC != "2.0" {
+ if err := encoder.Encode(errorResponse(req.ID, errorCodeInvalidRequest, errors.New("jsonrpc must be 2.0"))); err != nil {
+ return err
+ }
+ continue
+ }
+ result, err := state.handle(ctx, &req)
+ res := response{ID: req.ID, JSONRPC: "2.0", Result: result}
+ if err != nil {
+ res = errorResponse(req.ID, errorCode(err), err)
+ }
+ if err := encoder.Encode(&res); err != nil {
+ return err
+ }
+ }
+ return scanner.Err()
+}
+
+//nolint:cyclop,funlen,gocognit,maintidx
+func (s *adapterState) handle(ctx context.Context, req *request) (any, error) {
+ methods, profileCapabilities := adapterMethods, capabilities
+ if s.profile == "insert-only-v1" {
+ methods, profileCapabilities = insertOnlyMethods, insertOnlyCapabilities
+ }
+ if err := checkRequest(req, methods); err != nil {
+ return nil, err
+ }
+ switch req.Method {
+ case "handshake":
+ latest, err := latestMigrationVersion(riverpgxv5.New(s.pool))
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{
+ "adapter_version": adapterVersion,
+ "application_name": s.applicationName,
+ "backend": "postgres",
+ "capabilities": profileCapabilities,
+ "implementation": "go",
+ "implementation_version": implementationVersion,
+ "methods": methods,
+ "migration_lines": map[string]int{"main": latest},
+ "profile": s.profile,
+ "protocol_revision": protocolRevision,
+ }, nil
+
+ case "migrate":
+ var params struct {
+ Direction string `json:"direction"`
+ DryRun bool `json:"dry_run"`
+ MaxSteps *int `json:"max_steps"`
+ Schema string `json:"schema"`
+ TargetVersion *int `json:"target_version"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Schema != "" {
+ if _, err := s.pool.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+pgx.Identifier{params.Schema}.Sanitize()); err != nil {
+ return nil, err
+ }
+ }
+ migrator, err := rivermigrate.New(riverpgxv5.New(s.pool), &rivermigrate.Config{
+ Logger: adapterLogger(),
+ Schema: params.Schema,
+ })
+ if err != nil {
+ return nil, err
+ }
+ direction := rivermigrate.DirectionUp
+ if params.Direction != "" {
+ direction = rivermigrate.Direction(params.Direction)
+ }
+ var opts *rivermigrate.MigrateOpts
+ if params.DryRun || params.MaxSteps != nil || params.TargetVersion != nil {
+ opts = &rivermigrate.MigrateOpts{DryRun: params.DryRun}
+ if params.MaxSteps != nil {
+ opts.MaxSteps = *params.MaxSteps
+ }
+ if params.TargetVersion != nil {
+ opts.TargetVersion = *params.TargetVersion
+ }
+ }
+ result, err := migrator.Migrate(ctx, direction, opts)
+ if err != nil {
+ return nil, err
+ }
+ versions := make([]int, len(result.Versions))
+ for i, version := range result.Versions {
+ versions[i] = version.Version
+ }
+ existingMigrations, err := migrator.ExistingVersions(ctx)
+ if err != nil {
+ return nil, err
+ }
+ existing := make([]int, len(existingMigrations))
+ for i, migration := range existingMigrations {
+ existing[i] = migration.Version
+ }
+ validation, err := migrator.Validate(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"applied": versions, "existing": existing, "valid": validation.OK}, nil
+
+ case "reset":
+ if s.running != nil || len(s.transactions) > 0 {
+ return nil, errors.New("reset requires no running client or open transaction")
+ }
+ s.barriers.clear()
+ var params struct {
+ Schema string `json:"schema"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ table := func(name string) string {
+ if params.Schema == "" {
+ return pgx.Identifier{name}.Sanitize()
+ }
+ return pgx.Identifier{params.Schema, name}.Sanitize()
+ }
+ _, err := s.pool.Exec(ctx, "TRUNCATE "+table("river_job")+", "+table("river_notification")+", "+table("river_queue")+", "+table("river_leader")+" RESTART IDENTITY CASCADE")
+ return map[string]any{}, err
+
+ case "clock_set":
+ var params struct {
+ Now time.Time `json:"now"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ s.clock = ¶ms.Now
+ return map[string]any{}, nil
+
+ case "rng_seed":
+ var params struct {
+ Seed uint64 `json:"seed"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ s.rngSeed = params.Seed
+ return map[string]any{}, nil
+
+ case "cron_next":
+ return handleCronNext(req.Params)
+
+ case "retry_delay":
+ if s.clock == nil {
+ return nil, errors.New("clock_set is required before retry_delay")
+ }
+ var params struct {
+ ErrorCount uint32 `json:"error_count"`
+ JobID int64 `json:"job_id"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.ErrorCount < 1 {
+ return nil, errors.New("error_count must be positive")
+ }
+ return map[string]any{"delay_ns": defaultRetryDelay(*s.clock, params.JobID, params.ErrorCount).Nanoseconds()}, nil
+
+ case "unique_key":
+ return handleUniqueKey(req.Params)
+
+ case "barrier_create", "barrier_release":
+ var params struct {
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if req.Method == "barrier_create" {
+ return map[string]any{}, s.barriers.create(params.Name)
+ }
+ return map[string]any{}, s.barriers.release(params.Name)
+
+ case "insert":
+ var params insertParams
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if err := (insertParams{Behavior: params.Behavior, Kind: params.Kind}).rejectRawOnlyFields(); err != nil {
+ return nil, err
+ }
+ client, err := s.clientForSchema(params.Schema)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := params.Opts.opts()
+ if err != nil {
+ return nil, err
+ }
+ result, err := client.Insert(ctx, params.args(), opts)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(result.Job), nil
+
+ case "insert_many":
+ jobs, err := decodeInsertManyParams(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ results, err := client.InsertMany(ctx, jobs)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeInsertManyResults(results), nil
+
+ case "benchmark_enqueue":
+ var params struct {
+ Jobs int `json:"jobs"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Jobs < 1 {
+ return nil, errors.New("jobs must be positive")
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ latencies := make([]time.Duration, 0, params.Jobs)
+ startedAt := time.Now()
+ for index := range params.Jobs {
+ insertedAt := time.Now()
+ if _, err := client.Insert(ctx, conformanceArgs{Message: fmt.Sprintf("benchmark-enqueue-%d", index)}, nil); err != nil {
+ return nil, err
+ }
+ latencies = append(latencies, time.Since(insertedAt))
+ }
+ duration := time.Since(startedAt)
+ slices.Sort(latencies)
+ p95 := latencies[max(0, (len(latencies)*95+99)/100-1)]
+ return map[string]any{"duration_ns": duration.Nanoseconds(), "p95_ns": p95.Nanoseconds()}, nil
+
+ case "get": //nolint:usestdlibvars // JSON-RPC method names are lowercase protocol values.
+ var params struct {
+ ID int64 `json:"id"`
+ Schema string `json:"schema"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.ID < 1 {
+ return nil, errors.New("id must be positive")
+ }
+ client, err := s.clientForSchema(params.Schema)
+ if err != nil {
+ return nil, err
+ }
+ job, err := client.JobGet(ctx, params.ID)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "list":
+ params, _, err := makeJobListParams(req.Params, false)
+ if err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ result, err := client.JobList(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJobListResult(result)
+
+ case "cancel", "delete", "retry": //nolint:usestdlibvars // JSON-RPC method names are lowercase protocol values.
+ id, err := requestID(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ var job *rivertype.JobRow
+ switch req.Method {
+ case "cancel":
+ job, err = client.JobCancel(ctx, id)
+ case "delete": //nolint:usestdlibvars // JSON-RPC method names are lowercase protocol values.
+ job, err = client.JobDelete(ctx, id)
+ case "retry":
+ job, err = client.JobRetry(ctx, id)
+ }
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "delete_finalized":
+ params, err := makeJobDeleteBeforeParams(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ deleted, err := riverpgxv5.New(s.pool).GetExecutor().JobDeleteBefore(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"deleted": deleted}, nil
+
+ case "delete_many":
+ params, _, err := makeJobDeleteManyParams(req.Params, false)
+ if err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ result, err := client.JobDeleteMany(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"jobs": normalizeJobs(result.Jobs)}, nil
+
+ case "update":
+ var params struct {
+ ID int64 `json:"id"`
+ Output any `json:"output"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ job, err := client.JobUpdate(ctx, params.ID, &river.JobUpdateParams{Output: params.Output})
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "raw_finalize":
+ var params struct {
+ ID int64 `json:"id"`
+ Metadata map[string]any `json:"metadata"`
+ State string `json:"state"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.State != "completed" && params.State != "discarded" {
+ return nil, invalidParams(errors.New("state must be completed or discarded"))
+ }
+ metadata, err := json.Marshal(params.Metadata)
+ if err != nil {
+ return nil, err
+ }
+ attemptError, err := json.Marshal(map[string]any{
+ "at": "2026-02-03T04:05:06.789Z",
+ "attempt": 1,
+ "error": "external discard",
+ "trace": "external trace",
+ })
+ if err != nil {
+ return nil, err
+ }
+ // finalized_at is the current time so leader cleaners never delete
+ // the row while the scenario still observes it.
+ commandTag, err := s.pool.Exec(ctx, `
+ UPDATE river_job
+ SET errors = CASE WHEN $2 = 'discarded' THEN array_append(errors, $4::jsonb) ELSE errors END,
+ finalized_at = now(),
+ metadata = metadata || $3::jsonb,
+ state = $2::river_job_state
+ WHERE id = $1 AND state = 'running'`,
+ params.ID, params.State, metadata, attemptError,
+ )
+ if err != nil {
+ return nil, err
+ }
+ if commandTag.RowsAffected() != 1 {
+ return nil, notFound(errors.New("running job not found"))
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ job, err := client.JobGet(ctx, params.ID)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "queue_add":
+ if s.running == nil {
+ return nil, errors.New("queue_add requires a running client")
+ }
+ return handleQueueAdd(ctx, req.Params, s.running.client.Queues().Add, s.running.client.Queues().Remove)
+
+ case "queue_get":
+ var params struct {
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ queue, err := client.QueueGet(ctx, params.Name)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeQueue(queue), nil
+
+ case "queue_list":
+ var params struct {
+ Limit int `json:"limit"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Limit == 0 {
+ params.Limit = 100
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ result, err := client.QueueList(ctx, river.NewQueueListParams().First(params.Limit))
+ if err != nil {
+ return nil, err
+ }
+ queues := make([]any, len(result.Queues))
+ for i, queue := range result.Queues {
+ queues[i] = normalizeQueue(queue)
+ }
+ return map[string]any{"queues": queues}, nil
+
+ case "queue_pause", "queue_resume":
+ var params struct {
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ if req.Method == "queue_pause" {
+ err = client.QueuePause(ctx, params.Name, nil)
+ } else {
+ err = client.QueueResume(ctx, params.Name, nil)
+ }
+ return map[string]any{}, err
+
+ case "queue_remove":
+ if s.running == nil {
+ return nil, errors.New("queue_remove requires a running client")
+ }
+ var params struct {
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ return map[string]any{}, s.running.client.Queues().Remove(ctx, params.Name)
+
+ case "queue_update":
+ var params struct {
+ Metadata json.RawMessage `json:"metadata"`
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ queue, err := client.QueueUpdate(ctx, params.Name, &river.QueueUpdateParams{Metadata: params.Metadata})
+ if err != nil {
+ return nil, err
+ }
+ return normalizeQueue(queue), nil
+
+ case "request_resign":
+ var params struct {
+ Handle string `json:"handle"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ if params.Handle != "" {
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ return map[string]any{}, client.Notify().RequestResignTx(ctx, tx)
+ }
+ return map[string]any{}, client.Notify().RequestResign(ctx)
+
+ case "leader":
+ var leaderID string
+ var electedAt time.Time
+ err := s.pool.QueryRow(ctx, "SELECT leader_id, elected_at FROM river_leader WHERE name = 'default' AND expires_at >= now()").Scan(&leaderID, &electedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return map[string]any{"elected_at": nil, "leader_id": nil}, nil
+ }
+ return map[string]any{"elected_at": formatTime(electedAt), "leader_id": leaderID}, err
+
+ case "listener_count":
+ var count int
+ err := s.pool.QueryRow(ctx, "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND application_name = $1 AND query LIKE 'LISTEN %'", s.applicationName).Scan(&count)
+ return map[string]any{"count": count}, err
+
+ case "connection_count":
+ var count int
+ err := s.pool.QueryRow(ctx, "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND application_name = $1", s.applicationName).Scan(&count)
+ return map[string]any{"count": count}, err
+
+ case "fault_disconnect_listeners":
+ var count int
+ err := s.pool.QueryRow(ctx, "SELECT count(*) FROM (SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = current_database() AND application_name = $1 AND query LIKE 'LISTEN %' AND pid != pg_backend_pid()) AS terminated", s.applicationName).Scan(&count)
+ return map[string]any{"count": count}, err
+
+ case "fault_disconnect_application":
+ var params struct {
+ ApplicationName string `json:"application_name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ // Only conformance adapters may be disconnected.
+ if !isAdapterApplicationName(params.ApplicationName) {
+ return nil, errors.New("application_name must name a conformance adapter")
+ }
+ var count int
+ err := s.pool.QueryRow(ctx, "SELECT count(*) FROM (SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = current_database() AND application_name = $1 AND pid != pg_backend_pid()) AS terminated", params.ApplicationName).Scan(&count)
+ return map[string]any{"count": count}, err
+
+ case "fault_expire_leader":
+ _, err := s.pool.Exec(ctx, "UPDATE river_leader SET expires_at = now() - interval '1 second'")
+ return map[string]any{}, err
+
+ case "raw_insert_no_notify":
+ var params insertParams
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if err := (insertParams{Behavior: params.Behavior, Schema: params.Schema}).rejectRawOnlyFields(); err != nil {
+ return nil, err
+ }
+ encodedArgs, err := json.Marshal(params.args())
+ if err != nil {
+ return nil, err
+ }
+ if params.Kind == "" {
+ params.Kind = "conformance_echo"
+ }
+ maxAttempts := river.MaxAttemptsDefault
+ if params.Opts.MaxAttempts != nil {
+ maxAttempts = *params.Opts.MaxAttempts
+ }
+ var id int64
+ err = s.pool.QueryRow(ctx, "INSERT INTO river_job (args, kind, max_attempts) VALUES ($1, $2, $3) RETURNING id", encodedArgs, params.Kind, maxAttempts).Scan(&id)
+ if err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ job, err := client.JobGet(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "raw_insert_exact_json":
+ var params struct {
+ ID *int64 `json:"id"`
+ MetadataJSON *string `json:"metadata_json"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ var id int64
+ err := s.pool.QueryRow(ctx, `
+ INSERT INTO river_job (id, args, kind, max_attempts, metadata)
+ VALUES (
+ COALESCE($1, nextval(pg_get_serial_sequence('river_job', 'id'))),
+ '{"decimal":0.12345678901234567890123456789,"integer":9223372036854775807}'::jsonb,
+ 'conformance_exact_json', 25,
+ COALESCE($2::jsonb, '{"negative":-9223372036854775808}'::jsonb)
+ ) RETURNING id`, params.ID, params.MetadataJSON).Scan(&id)
+ return map[string]any{"id": id}, err
+
+ case "raw_insert_full_row":
+ var id int64
+ err := s.pool.QueryRow(ctx, `
+ INSERT INTO river_job (
+ args, attempt, attempted_at, attempted_by, created_at, errors,
+ finalized_at, kind, max_attempts, metadata, priority, queue,
+ scheduled_at, state, tags, unique_key, unique_states
+ ) VALUES (
+ '{"nested":{"enabled":true},"values":[1,"two",null]}'::jsonb,
+ 3, '2026-01-02T03:04:06.123456Z', ARRAY['go-client','candidate-client'],
+ '2026-01-02T03:04:05.6789Z',
+ ARRAY['{"at":"2026-01-02T03:04:06.123456Z","attempt":3,"error":"worker failed: escaped \"detail\"","trace":"frame one\nframe two"}'::jsonb],
+ '2026-01-02T03:04:07.000001Z', 'conformance_full_row', 4,
+ '{"output":{"ok":true},"river:rescue_count":2,"user":"metadata"}'::jsonb,
+ 2, 'priority_jobs', '2026-01-02T03:04:05.999999Z', 'discarded',
+ ARRAY['alpha_tag','beta_tag'], decode(repeat('ab', 32), 'hex'), B'11110101'
+ ) RETURNING id`).Scan(&id)
+ if err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ job, err := client.JobGet(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "raw_job_exact_json":
+ id, err := requestID(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ job, err := client.JobGet(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ return exactJSONTokens(job)
+
+ case "raw_job_row":
+ id, err := requestID(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ var row rawJobRow
+ err = s.pool.QueryRow(ctx, `
+ SELECT args::text, attempted_at::text, attempted_by::text, created_at::text, errors::text,
+ finalized_at::text, metadata::text, scheduled_at::text, tags::text,
+ upper(encode(unique_key, 'hex')), unique_states::text
+ FROM river_job
+ WHERE id = $1`, id).Scan(row.scanTargets()...)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, notFound(err)
+ }
+ return row, err
+
+ case "raw_notifications":
+ var params struct {
+ AfterID *int64 `json:"after_id"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.AfterID == nil || *params.AfterID < 0 {
+ return nil, invalidParams(errors.New("after_id must be a non-negative integer"))
+ }
+ return nil, unsupported(errors.New("PostgreSQL has no notification outbox"))
+
+ case "raw_replace_json_text":
+ var params struct {
+ Column string `json:"column"`
+ ID int64 `json:"id"`
+ Text *string `json:"text"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ return nil, unsupported(errors.New("PostgreSQL JSON columns can't hold text that isn't JSON"))
+
+ case "raw_set_kind":
+ var params rawSetKindParams
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Kind == "" {
+ return nil, invalidParams(errors.New("kind is required"))
+ }
+ commandTag, err := s.pool.Exec(ctx, "UPDATE river_job SET kind = $2 WHERE id = $1", params.ID, params.Kind)
+ if err != nil {
+ return nil, err
+ }
+ if commandTag.RowsAffected() != 1 {
+ return nil, notFound(errors.New("job not found"))
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ job, err := client.JobGet(ctx, params.ID)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "raw_job_timestamps":
+ id, err := requestID(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ var createdAt, scheduledAt string
+ err = s.pool.QueryRow(ctx, `
+ SELECT created_at::text, scheduled_at::text
+ FROM river_job
+ WHERE id = $1`, id).Scan(&createdAt, &scheduledAt)
+ return map[string]any{"created_at": createdAt, "scheduled_at": scheduledAt}, err
+
+ case "start":
+ if s.running != nil {
+ return nil, errors.New("client already running")
+ }
+ var params struct {
+ maintenanceParams
+ startTuningParams
+
+ ClaimBarrier string `json:"claim_barrier"`
+ ClientID string `json:"client_id"`
+ ErrorHandlerCancel bool `json:"error_handler_cancel"`
+ FetchOnlyKnownKinds bool `json:"fetch_only_known_kinds"`
+ FetchPollIntervalMS *uint64 `json:"fetch_poll_interval_ms"`
+ Instrumented bool `json:"instrumented"`
+ JobStuckThresholdMS *uint64 `json:"job_stuck_threshold_ms"`
+ JobTimeoutMS *uint64 `json:"job_timeout_ms"`
+ LeaderElectionDisabled bool `json:"leader_election_disabled"`
+ MaxWorkers int `json:"max_workers"`
+ PeriodicRunOnStart bool `json:"periodic_run_on_start"`
+ PeriodicUnique bool `json:"periodic_unique"`
+ PollOnly bool `json:"poll_only"`
+ Queue string `json:"queue"`
+ RescueAfterMS *uint64 `json:"rescue_after_ms"`
+ RetryDelayMS *uint64 `json:"retry_delay_ms"`
+ Schema string `json:"schema"`
+ WorkerKinds []string `json:"worker_kinds"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if err := params.reject(); err != nil {
+ return nil, err
+ }
+ if params.ClaimBarrier != "" && !s.barriers.exists(params.ClaimBarrier) {
+ return nil, invalidParams(fmt.Errorf("claim_barrier %q does not exist", params.ClaimBarrier))
+ }
+ if params.MaxWorkers == 0 {
+ params.MaxWorkers = 4
+ }
+ if params.Queue == "" {
+ params.Queue = river.QueueDefault
+ }
+ probe := &runtimeProbe{}
+ client, err := newWorkerClient(s.pool, s.barriers, workerClientConfig{
+ claimBarrier: params.ClaimBarrier,
+ errorHandlerCancel: params.ErrorHandlerCancel,
+ fetchOnlyKnownKinds: params.FetchOnlyKnownKinds,
+ fetchPollIntervalMS: params.FetchPollIntervalMS,
+ id: params.ClientID,
+ instrumented: params.Instrumented,
+ jobStuckThresholdMS: params.JobStuckThresholdMS,
+ jobTimeoutMS: params.JobTimeoutMS,
+ leaderElectionDisabled: params.LeaderElectionDisabled,
+ maintenance: params.maintenanceParams,
+ maxWorkers: params.MaxWorkers,
+ periodicRunOnStart: params.PeriodicRunOnStart,
+ periodicUnique: params.PeriodicUnique,
+ pollOnly: params.PollOnly,
+ probe: probe,
+ queue: params.Queue,
+ rescueAfterMS: params.RescueAfterMS,
+ retryDelayMS: params.RetryDelayMS,
+ schema: params.Schema,
+ workerKinds: params.WorkerKinds,
+ })
+ if err != nil {
+ return nil, err
+ }
+ subscription, subscriptionCancel := client.Subscribe(
+ river.EventKindJobCancelled,
+ river.EventKindJobCompleted,
+ river.EventKindJobFailed,
+ river.EventKindJobInterrupted,
+ river.EventKindJobSnoozed,
+ river.EventKindQueuePaused,
+ river.EventKindQueueResumed,
+ )
+ if err := client.Start(ctx); err != nil {
+ subscriptionCancel()
+ return nil, err
+ }
+ s.running = &runningClient{
+ claimBarrier: params.ClaimBarrier,
+ client: client,
+ probe: probe,
+ subscription: subscription,
+ subscriptionCancel: subscriptionCancel,
+ }
+ return map[string]any{}, nil
+
+ case "stop":
+ if s.running == nil {
+ return nil, errors.New("client is not running")
+ }
+ var params struct {
+ Cancel bool `json:"cancel"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ // A claim held on the barrier would keep the client from stopping.
+ if s.running.claimBarrier != "" {
+ s.barriers.releaseIfPresent(s.running.claimBarrier)
+ }
+ stopCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ if params.Cancel {
+ err := s.running.client.StopAndCancel(stopCtx)
+ s.running.subscriptionCancel()
+ s.running = nil
+ return map[string]any{}, err
+ }
+ err := s.running.client.Stop(stopCtx)
+ s.running.subscriptionCancel()
+ s.running = nil
+ return map[string]any{}, err
+
+ case "runtime_stats":
+ if s.running == nil {
+ return nil, errors.New("runtime_stats requires a running client")
+ }
+ for {
+ select {
+ case event := <-s.running.subscription:
+ if event != nil {
+ s.running.probe.addEvent(event.Kind)
+ }
+ default:
+ return s.running.probe.snapshot(), nil
+ }
+ }
+
+ case "wait":
+ var params struct {
+ ID int64 `json:"id"`
+ States []rivertype.JobState `json:"states"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ job, err := waitForStates(ctx, client, params.ID, params.States)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "work":
+ var params struct {
+ ClientID string `json:"client_id"`
+ ID int64 `json:"id"`
+ Schema string `json:"schema"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.ID < 1 {
+ return nil, errors.New("id must be positive")
+ }
+ if params.ClientID == "" {
+ params.ClientID = "go-conformance-adapter"
+ }
+ probe := &runtimeProbe{}
+ client, err := newWorkerClient(s.pool, s.barriers, workerClientConfig{
+ id: params.ClientID,
+ maxWorkers: 1,
+ probe: probe,
+ queue: river.QueueDefault,
+ schema: params.Schema,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := client.Start(ctx); err != nil {
+ return nil, err
+ }
+ job, waitErr := waitForStates(ctx, client, params.ID, nil)
+ stopCtx, stopCancel := context.WithTimeout(ctx, 10*time.Second)
+ defer stopCancel()
+ stopErr := client.Stop(stopCtx)
+ if waitErr != nil {
+ return nil, waitErr
+ }
+ if stopErr != nil {
+ return nil, stopErr
+ }
+ return normalizeJob(job), nil
+
+ case "tx_begin":
+ handle, err := requestHandle(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ if _, ok := s.transactions[handle]; ok {
+ return nil, fmt.Errorf("transaction %q already exists", handle)
+ }
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ s.transactions[handle] = tx
+ return map[string]any{}, nil
+
+ case "tx_insert":
+ var params struct {
+ Handle string `json:"handle"`
+ Job insertParams `json:"job"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if err := params.Job.rejectRawOnlyFields(); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ opts, err := params.Job.Opts.opts()
+ if err != nil {
+ return nil, err
+ }
+ result, err := client.InsertTx(ctx, tx, params.Job.args(), opts)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(result.Job), nil
+
+ case "tx_insert_many":
+ var params struct {
+ Handle string `json:"handle"`
+ Jobs json.RawMessage `json:"jobs"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ jobs, err := decodeInsertManyParams(params.Jobs)
+ if err != nil {
+ return nil, err
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ results, err := client.InsertManyTx(ctx, tx, jobs)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeInsertManyResults(results), nil
+
+ case "tx_get", "tx_cancel", "tx_delete", "tx_retry":
+ var params struct {
+ Handle string `json:"handle"`
+ ID int64 `json:"id"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ var job *rivertype.JobRow
+ switch req.Method {
+ case "tx_cancel":
+ job, err = client.JobCancelTx(ctx, tx, params.ID)
+ case "tx_delete":
+ job, err = client.JobDeleteTx(ctx, tx, params.ID)
+ case "tx_get":
+ job, err = client.JobGetTx(ctx, tx, params.ID)
+ case "tx_retry":
+ job, err = client.JobRetryTx(ctx, tx, params.ID)
+ }
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "tx_update":
+ var params struct {
+ Handle string `json:"handle"`
+ ID int64 `json:"id"`
+ Output any `json:"output"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ job, err := client.JobUpdateTx(ctx, tx, params.ID, &river.JobUpdateParams{Output: params.Output})
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "tx_list":
+ params, handle, err := makeJobListParams(req.Params, true)
+ if err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[handle]
+ if !ok {
+ return nil, transactionNotFound(handle)
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ result, err := client.JobListTx(ctx, tx, params)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJobListResult(result)
+
+ case "tx_delete_many":
+ params, handle, err := makeJobDeleteManyParams(req.Params, true)
+ if err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[handle]
+ if !ok {
+ return nil, transactionNotFound(handle)
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ result, err := client.JobDeleteManyTx(ctx, tx, params)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"jobs": normalizeJobs(result.Jobs)}, nil
+
+ case "tx_queue_get":
+ var params struct {
+ Handle string `json:"handle"`
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ queue, err := client.QueueGetTx(ctx, tx, params.Name)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeQueue(queue), nil
+
+ case "tx_queue_list":
+ var params struct {
+ Handle string `json:"handle"`
+ Limit int `json:"limit"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Limit == 0 {
+ params.Limit = 100
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ result, err := client.QueueListTx(ctx, tx, river.NewQueueListParams().First(params.Limit))
+ if err != nil {
+ return nil, err
+ }
+ queues := make([]any, len(result.Queues))
+ for i, queue := range result.Queues {
+ queues[i] = normalizeQueue(queue)
+ }
+ return map[string]any{"queues": queues}, nil
+
+ case "tx_queue_pause", "tx_queue_resume":
+ var params struct {
+ Handle string `json:"handle"`
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ if req.Method == "tx_queue_pause" {
+ err = client.QueuePauseTx(ctx, tx, params.Name, nil)
+ } else {
+ err = client.QueueResumeTx(ctx, tx, params.Name, nil)
+ }
+ return map[string]any{}, err
+
+ case "tx_queue_update":
+ var params struct {
+ Handle string `json:"handle"`
+ Metadata json.RawMessage `json:"metadata"`
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ client, err := s.client()
+ if err != nil {
+ return nil, err
+ }
+ queue, err := client.QueueUpdateTx(ctx, tx, params.Name, &river.QueueUpdateParams{Metadata: params.Metadata})
+ if err != nil {
+ return nil, err
+ }
+ return normalizeQueue(queue), nil
+
+ case "tx_fail":
+ handle, err := requestHandle(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[handle]
+ if !ok {
+ return nil, transactionNotFound(handle)
+ }
+ _, err = tx.Exec(ctx, "SELECT 1 / 0")
+ return nil, err
+
+ case "tx_commit", "tx_rollback":
+ handle, err := requestHandle(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[handle]
+ if !ok {
+ return nil, transactionNotFound(handle)
+ }
+ delete(s.transactions, handle)
+ if req.Method == "tx_commit" {
+ return map[string]any{}, tx.Commit(ctx)
+ }
+ return map[string]any{}, tx.Rollback(ctx)
+ }
+
+ return nil, methodNotFound(req.Method)
+}
+
+//nolint:cyclop,funlen,gocognit,maintidx
+func (s *sqliteAdapterState) handle(ctx context.Context, req *request) (any, error) {
+ methods, profileCapabilities := sqliteAdapterMethods, sqliteCapabilities
+ if s.profile == "sqlite-runtime-v1" {
+ methods, profileCapabilities = sqliteRuntimeMethods, sqliteRuntimeCapabilities
+ }
+ if err := checkRequest(req, methods); err != nil {
+ return nil, err
+ }
+ switch req.Method {
+ case "handshake":
+ latest, err := latestMigrationVersion(riversqlite.New(s.pool))
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{
+ "adapter_version": adapterVersion,
+ "backend": "sqlite",
+ "capabilities": profileCapabilities,
+ "implementation": "go",
+ "implementation_version": implementationVersion,
+ "methods": methods,
+ "migration_lines": map[string]int{"main": latest},
+ "profile": s.profile,
+ "protocol_revision": protocolRevision,
+ }, nil
+
+ case "migrate":
+ var params struct {
+ Direction string `json:"direction"`
+ DryRun bool `json:"dry_run"`
+ MaxSteps *int `json:"max_steps"`
+ Schema string `json:"schema"`
+ TargetVersion *int `json:"target_version"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Schema != "" {
+ return nil, unsupported(errors.New("SQLite conformance does not support custom schemas"))
+ }
+ migrator, err := rivermigrate.New(riversqlite.New(s.pool), &rivermigrate.Config{Logger: adapterLogger()})
+ if err != nil {
+ return nil, err
+ }
+ direction := rivermigrate.DirectionUp
+ if params.Direction != "" {
+ direction = rivermigrate.Direction(params.Direction)
+ }
+ var opts *rivermigrate.MigrateOpts
+ if params.DryRun || params.MaxSteps != nil || params.TargetVersion != nil {
+ opts = &rivermigrate.MigrateOpts{DryRun: params.DryRun}
+ if params.MaxSteps != nil {
+ opts.MaxSteps = *params.MaxSteps
+ }
+ if params.TargetVersion != nil {
+ opts.TargetVersion = *params.TargetVersion
+ }
+ }
+ result, err := migrator.Migrate(ctx, direction, opts)
+ if err != nil {
+ return nil, err
+ }
+ versions := make([]int, len(result.Versions))
+ for i, version := range result.Versions {
+ versions[i] = version.Version
+ }
+ existingMigrations, err := migrator.ExistingVersions(ctx)
+ if err != nil {
+ return nil, err
+ }
+ existing := make([]int, len(existingMigrations))
+ for i, migration := range existingMigrations {
+ existing[i] = migration.Version
+ }
+ validation, err := migrator.Validate(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"applied": versions, "existing": existing, "valid": validation.OK}, nil
+
+ case "reset":
+ if len(s.transactions) > 0 {
+ return nil, errors.New("reset requires no open transaction")
+ }
+ for _, table := range []string{
+ "river_notification", "river_job", "river_queue", "river_leader",
+ } {
+ // #nosec G202 -- Table names are fixed constants above, never request input.
+ if _, err := s.pool.ExecContext(ctx, "DELETE FROM "+table); err != nil {
+ return nil, err
+ }
+ }
+ return map[string]any{}, nil
+
+ case "clock_set":
+ var params struct {
+ Now time.Time `json:"now"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ s.clock = ¶ms.Now
+ return map[string]any{}, nil
+
+ case "rng_seed":
+ var params struct {
+ Seed uint64 `json:"seed"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ s.rngSeed = params.Seed
+ return map[string]any{}, nil
+
+ case "cron_next":
+ return handleCronNext(req.Params)
+
+ case "retry_delay":
+ if s.clock == nil {
+ return nil, errors.New("clock_set is required before retry_delay")
+ }
+ var params struct {
+ ErrorCount uint32 `json:"error_count"`
+ JobID int64 `json:"job_id"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.ErrorCount < 1 {
+ return nil, errors.New("error_count must be positive")
+ }
+ return map[string]any{"delay_ns": defaultRetryDelay(*s.clock, params.JobID, params.ErrorCount).Nanoseconds()}, nil
+
+ case "unique_key":
+ return handleUniqueKey(req.Params)
+
+ case "insert":
+ var params insertParams
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if err := (insertParams{Behavior: params.Behavior, Kind: params.Kind}).rejectRawOnlyFields(); err != nil {
+ return nil, err
+ }
+ if params.Schema != "" {
+ return nil, unsupported(errors.New("SQLite conformance does not support custom schemas"))
+ }
+ opts, err := params.Opts.opts()
+ if err != nil {
+ return nil, err
+ }
+ result, err := s.client().Insert(ctx, params.args(), opts)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(result.Job), nil
+
+ case "insert_many":
+ jobs, err := decodeInsertManyParams(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ results, err := s.client().InsertMany(ctx, jobs)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeInsertManyResults(results), nil
+
+ case "raw_insert_no_notify":
+ var params insertParams
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if err := (insertParams{Behavior: params.Behavior, Schema: params.Schema}).rejectRawOnlyFields(); err != nil {
+ return nil, err
+ }
+ encodedArgs, err := json.Marshal(params.args())
+ if err != nil {
+ return nil, err
+ }
+ kind := params.Kind
+ if kind == "" {
+ kind = "conformance_echo"
+ }
+ maxAttempts := 25
+ if params.Opts.MaxAttempts != nil {
+ maxAttempts = *params.Opts.MaxAttempts
+ }
+ var id int64
+ err = s.pool.QueryRowContext(ctx,
+ "INSERT INTO river_job (args, kind, max_attempts) VALUES (jsonb(?), ?, ?) RETURNING id",
+ string(encodedArgs), kind, maxAttempts,
+ ).Scan(&id)
+ if err != nil {
+ return nil, err
+ }
+ job, err := s.client().JobGet(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "raw_insert_exact_json":
+ var params struct {
+ ID *int64 `json:"id"`
+ MetadataJSON *string `json:"metadata_json"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ var id int64
+ err := s.pool.QueryRowContext(ctx, `
+ INSERT INTO river_job (id, args, kind, max_attempts, metadata)
+ VALUES (
+ ?,
+ jsonb('{"decimal":0.12345678901234567890123456789,"integer":9223372036854775807}'),
+ 'conformance_exact_json', 25,
+ jsonb(COALESCE(?, '{"negative":-9223372036854775808}'))
+ ) RETURNING id`, params.ID, params.MetadataJSON).Scan(&id)
+ return map[string]any{"id": id}, err
+
+ case "get": //nolint:usestdlibvars // JSON-RPC method names are lowercase protocol values.
+ var params struct {
+ ID int64 `json:"id"`
+ Schema string `json:"schema"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Schema != "" {
+ return nil, unsupported(errors.New("SQLite conformance does not support custom schemas"))
+ }
+ job, err := s.client().JobGet(ctx, params.ID)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "list":
+ params, _, err := makeJobListParams(req.Params, false)
+ if err != nil {
+ return nil, err
+ }
+ result, err := s.client().JobList(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJobListResult(result)
+
+ case "cancel", "delete", "retry": //nolint:usestdlibvars // JSON-RPC method names are lowercase protocol values.
+ id, err := requestID(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ client := s.client()
+ var job *rivertype.JobRow
+ switch req.Method {
+ case "cancel":
+ job, err = client.JobCancel(ctx, id)
+ case "delete": //nolint:usestdlibvars // JSON-RPC method names are lowercase protocol values.
+ job, err = client.JobDelete(ctx, id)
+ case "retry":
+ job, err = client.JobRetry(ctx, id)
+ }
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "delete_finalized":
+ params, err := makeJobDeleteBeforeParams(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ deleted, err := riversqlite.New(s.pool).GetExecutor().JobDeleteBefore(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"deleted": deleted}, nil
+
+ case "delete_many":
+ params, _, err := makeJobDeleteManyParams(req.Params, false)
+ if err != nil {
+ return nil, err
+ }
+ result, err := s.client().JobDeleteMany(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"jobs": normalizeJobs(result.Jobs)}, nil
+
+ case "update":
+ var params struct {
+ ID int64 `json:"id"`
+ Output any `json:"output"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ job, err := s.client().JobUpdate(ctx, params.ID, &river.JobUpdateParams{Output: params.Output})
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "raw_finalize":
+ var params struct {
+ ID int64 `json:"id"`
+ Metadata map[string]any `json:"metadata"`
+ State string `json:"state"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.State != "completed" && params.State != "discarded" {
+ return nil, invalidParams(errors.New("state must be completed or discarded"))
+ }
+ metadata, err := json.Marshal(params.Metadata)
+ if err != nil {
+ return nil, err
+ }
+ attemptError, err := json.Marshal(map[string]any{
+ "at": "2026-02-03T04:05:06.789Z",
+ "attempt": 1,
+ "error": "external discard",
+ "trace": "external trace",
+ })
+ if err != nil {
+ return nil, err
+ }
+ result, err := s.pool.ExecContext(ctx, `
+ UPDATE river_job
+ SET errors = CASE WHEN ? = 'discarded'
+ THEN jsonb(json_insert(json(coalesce(errors, jsonb('[]'))), '$[#]', json(?)))
+ ELSE errors END,
+ finalized_at = strftime('%Y-%m-%d %H:%M:%f', 'now'),
+ metadata = jsonb_patch(json(metadata), json(?)),
+ state = ?
+ WHERE id = ? AND state = 'running'`,
+ params.State, string(attemptError),
+ string(metadata), params.State, params.ID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return nil, err
+ }
+ if rowsAffected != 1 {
+ return nil, notFound(errors.New("running job not found"))
+ }
+ job, err := s.client().JobGet(ctx, params.ID)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "raw_job_row":
+ id, err := requestID(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ var row rawJobRow
+ err = s.pool.QueryRowContext(ctx, `
+ SELECT json(args), CAST(attempted_at AS TEXT), json(attempted_by), CAST(created_at AS TEXT),
+ json(errors), CAST(finalized_at AS TEXT), json(metadata), CAST(scheduled_at AS TEXT), json(tags),
+ CASE WHEN unique_key IS NULL THEN NULL ELSE hex(unique_key) END, CAST(unique_states AS TEXT)
+ FROM river_job
+ WHERE id = ?`, id).Scan(row.scanTargets()...)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, notFound(err)
+ }
+ if err != nil {
+ return nil, err
+ }
+ row.JSONB = &rawJSONBColumns{}
+ err = s.pool.QueryRowContext(ctx, `
+ SELECT hex(args),
+ CASE WHEN attempted_by IS NULL THEN NULL ELSE hex(attempted_by) END,
+ CASE WHEN errors IS NULL THEN NULL ELSE hex(errors) END,
+ hex(metadata), hex(tags)
+ FROM river_job
+ WHERE id = ?`, id).Scan(&row.JSONB.Args, &row.JSONB.AttemptedBy, &row.JSONB.Errors, &row.JSONB.Metadata, &row.JSONB.Tags)
+ if err != nil {
+ return nil, err
+ }
+ err = s.pool.QueryRowContext(ctx, `
+ SELECT
+ CASE WHEN unique_key IS NULL THEN NULL ELSE typeof(unique_key) END,
+ CASE WHEN unique_states IS NULL THEN NULL ELSE typeof(unique_states) END
+ FROM river_job
+ WHERE id = ?`, id).Scan(&row.UniqueKeyType, &row.UniqueStatesType)
+ return row, err
+
+ case "raw_notifications":
+ var params struct {
+ AfterID *int64 `json:"after_id"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.AfterID == nil || *params.AfterID < 0 {
+ return nil, invalidParams(errors.New("after_id must be a non-negative integer"))
+ }
+ rows, err := s.pool.QueryContext(ctx, `
+ SELECT id, payload, typeof(payload), topic
+ FROM river_notification
+ WHERE id > ?
+ ORDER BY id`, *params.AfterID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ notifications := []map[string]any{}
+ for rows.Next() {
+ var (
+ id int64
+ payload, payloadType, topic string
+ )
+ if err := rows.Scan(&id, &payload, &payloadType, &topic); err != nil {
+ return nil, err
+ }
+ notifications = append(notifications, map[string]any{
+ "id": id, "payload": payload, "payload_type": payloadType, "topic": topic,
+ })
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return map[string]any{"notifications": notifications}, nil
+
+ case "raw_replace_json_text":
+ var params struct {
+ Column string `json:"column"`
+ ID int64 `json:"id"`
+ Text *string `json:"text"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ statements, ok := sqliteJSONColumnStatements[params.Column]
+ if !ok {
+ return nil, invalidParams(fmt.Errorf("unknown JSON column %q", params.Column))
+ }
+ tx, err := s.pool.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = tx.Rollback() }()
+ var (
+ previous *string
+ previousType string
+ )
+ if err := tx.QueryRowContext(ctx, statements.get, params.ID).Scan(&previous, &previousType); err != nil {
+ return nil, err
+ }
+ if _, err := tx.ExecContext(ctx, statements.set, params.Text, params.ID); err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
+ return map[string]any{"previous": previous, "previous_type": previousType}, nil
+
+ case "raw_set_kind":
+ var params rawSetKindParams
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Kind == "" {
+ return nil, invalidParams(errors.New("kind is required"))
+ }
+ result, err := s.pool.ExecContext(ctx, "UPDATE river_job SET kind = ? WHERE id = ?", params.Kind, params.ID)
+ if err != nil {
+ return nil, err
+ }
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return nil, err
+ }
+ if rowsAffected != 1 {
+ return nil, notFound(errors.New("job not found"))
+ }
+ job, err := s.client().JobGet(ctx, params.ID)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "raw_job_timestamps":
+ id, err := requestID(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ var createdAt, scheduledAt string
+ err = s.pool.QueryRowContext(ctx, `
+ SELECT CAST(created_at AS TEXT), CAST(scheduled_at AS TEXT)
+ FROM river_job
+ WHERE id = ?`, id).Scan(&createdAt, &scheduledAt)
+ return map[string]any{"created_at": createdAt, "scheduled_at": scheduledAt}, err
+
+ case "raw_job_exact_json":
+ id, err := requestID(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ job, err := s.client().JobGet(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ return exactJSONTokens(job)
+
+ case "barrier_create", "barrier_release":
+ var params struct {
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if req.Method == "barrier_create" {
+ return map[string]any{}, s.barriers.create(params.Name)
+ }
+ return map[string]any{}, s.barriers.release(params.Name)
+
+ case "queue_add":
+ if s.running == nil {
+ return nil, errors.New("queue_add requires a running client")
+ }
+ return handleQueueAdd(ctx, req.Params, s.running.client.Queues().Add, s.running.client.Queues().Remove)
+
+ case "queue_get":
+ var params struct {
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ queue, err := s.client().QueueGet(ctx, params.Name)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeQueue(queue), nil
+
+ case "queue_list":
+ var params struct {
+ Limit int `json:"limit"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Limit == 0 {
+ params.Limit = 100
+ }
+ result, err := s.client().QueueList(ctx, river.NewQueueListParams().First(params.Limit))
+ if err != nil {
+ return nil, err
+ }
+ queues := make([]any, len(result.Queues))
+ for i, queue := range result.Queues {
+ queues[i] = normalizeQueue(queue)
+ }
+ return map[string]any{"queues": queues}, nil
+
+ case "queue_pause", "queue_resume":
+ var params struct {
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if req.Method == "queue_pause" {
+ return map[string]any{}, s.client().QueuePause(ctx, params.Name, nil)
+ }
+ return map[string]any{}, s.client().QueueResume(ctx, params.Name, nil)
+
+ case "queue_remove":
+ if s.running == nil {
+ return nil, errors.New("queue_remove requires a running client")
+ }
+ var params struct {
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ return map[string]any{}, s.running.client.Queues().Remove(ctx, params.Name)
+
+ case "queue_update":
+ var params struct {
+ Metadata json.RawMessage `json:"metadata"`
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ queue, err := s.client().QueueUpdate(ctx, params.Name, &river.QueueUpdateParams{Metadata: params.Metadata})
+ if err != nil {
+ return nil, err
+ }
+ return normalizeQueue(queue), nil
+
+ case "leader":
+ var leaderID string
+ var electedAt time.Time
+ err := s.pool.QueryRowContext(ctx,
+ "SELECT leader_id, elected_at FROM river_leader WHERE name = 'default' AND expires_at >= CURRENT_TIMESTAMP",
+ ).Scan(&leaderID, &electedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return map[string]any{"elected_at": nil, "leader_id": nil}, nil
+ }
+ return map[string]any{"elected_at": formatTime(electedAt), "leader_id": leaderID}, err
+
+ case "request_resign":
+ var params struct {
+ Handle string `json:"handle"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Handle != "" {
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ return map[string]any{}, s.client().Notify().RequestResignTx(ctx, tx)
+ }
+ return map[string]any{}, s.client().Notify().RequestResign(ctx)
+
+ case "start":
+ if s.running != nil {
+ return nil, errors.New("client already running")
+ }
+ var params struct {
+ maintenanceParams
+ startTuningParams
+
+ ClaimBarrier string `json:"claim_barrier"`
+ ClientID string `json:"client_id"`
+ ErrorHandlerCancel bool `json:"error_handler_cancel"`
+ FetchOnlyKnownKinds bool `json:"fetch_only_known_kinds"`
+ FetchPollIntervalMS *uint64 `json:"fetch_poll_interval_ms"`
+ Instrumented bool `json:"instrumented"`
+ JobStuckThresholdMS *uint64 `json:"job_stuck_threshold_ms"`
+ JobTimeoutMS *uint64 `json:"job_timeout_ms"`
+ LeaderElectionDisabled bool `json:"leader_election_disabled"`
+ MaxWorkers int `json:"max_workers"`
+ PeriodicRunOnStart bool `json:"periodic_run_on_start"`
+ PeriodicUnique bool `json:"periodic_unique"`
+ PollOnly bool `json:"poll_only"`
+ Queue string `json:"queue"`
+ RescueAfterMS *uint64 `json:"rescue_after_ms"`
+ RetryDelayMS *uint64 `json:"retry_delay_ms"`
+ Schema string `json:"schema"`
+ WorkerKinds []string `json:"worker_kinds"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if err := params.reject(); err != nil {
+ return nil, err
+ }
+ if params.ClaimBarrier != "" && !s.barriers.exists(params.ClaimBarrier) {
+ return nil, invalidParams(fmt.Errorf("claim_barrier %q does not exist", params.ClaimBarrier))
+ }
+ if params.Schema != "" {
+ return nil, unsupported(errors.New("SQLite conformance does not support custom schemas"))
+ }
+ if params.MaxWorkers == 0 {
+ params.MaxWorkers = 4
+ }
+ if params.Queue == "" {
+ params.Queue = river.QueueDefault
+ }
+ probe := &runtimeProbe{}
+ client, err := newSQLiteWorkerClient(s.pool, s.barriers, workerClientConfig{
+ claimBarrier: params.ClaimBarrier,
+ errorHandlerCancel: params.ErrorHandlerCancel, fetchOnlyKnownKinds: params.FetchOnlyKnownKinds,
+ fetchPollIntervalMS: params.FetchPollIntervalMS, id: params.ClientID, instrumented: params.Instrumented,
+ jobStuckThresholdMS: params.JobStuckThresholdMS, jobTimeoutMS: params.JobTimeoutMS,
+ leaderElectionDisabled: params.LeaderElectionDisabled, maintenance: params.maintenanceParams,
+ maxWorkers: params.MaxWorkers, periodicRunOnStart: params.PeriodicRunOnStart,
+ periodicUnique: params.PeriodicUnique, pollOnly: params.PollOnly, probe: probe, queue: params.Queue, rescueAfterMS: params.RescueAfterMS,
+ retryDelayMS: params.RetryDelayMS, workerKinds: params.WorkerKinds,
+ })
+ if err != nil {
+ return nil, err
+ }
+ subscription, subscriptionCancel := client.Subscribe(
+ river.EventKindJobCancelled, river.EventKindJobCompleted, river.EventKindJobFailed,
+ river.EventKindJobInterrupted, river.EventKindJobSnoozed,
+ river.EventKindQueuePaused, river.EventKindQueueResumed,
+ )
+ if err := client.Start(ctx); err != nil {
+ subscriptionCancel()
+ return nil, err
+ }
+ s.running = &sqliteRunningClient{
+ claimBarrier: params.ClaimBarrier, client: client, probe: probe,
+ subscription: subscription, subscriptionCancel: subscriptionCancel,
+ }
+ return map[string]any{}, nil
+
+ case "stop":
+ if s.running == nil {
+ return nil, errors.New("client is not running")
+ }
+ var params struct {
+ Cancel bool `json:"cancel"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ // A claim held on the barrier would keep the client from stopping.
+ if s.running.claimBarrier != "" {
+ s.barriers.releaseIfPresent(s.running.claimBarrier)
+ }
+ stopCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ var err error
+ if params.Cancel {
+ err = s.running.client.StopAndCancel(stopCtx)
+ } else {
+ err = s.running.client.Stop(stopCtx)
+ }
+ s.running.subscriptionCancel()
+ s.running = nil
+ return map[string]any{}, err
+
+ case "runtime_stats":
+ if s.running == nil {
+ return nil, errors.New("runtime_stats requires a running client")
+ }
+ for {
+ select {
+ case event := <-s.running.subscription:
+ if event != nil {
+ s.running.probe.addEvent(event.Kind)
+ }
+ default:
+ return s.running.probe.snapshot(), nil
+ }
+ }
+
+ case "wait":
+ var params struct {
+ ID int64 `json:"id"`
+ States []rivertype.JobState `json:"states"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ job, err := waitForStates(ctx, s.client(), params.ID, params.States)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "work":
+ var params struct {
+ ClientID string `json:"client_id"`
+ ID int64 `json:"id"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.ClientID == "" {
+ params.ClientID = "go-conformance-adapter"
+ }
+ probe := &runtimeProbe{}
+ client, err := newSQLiteWorkerClient(s.pool, s.barriers, workerClientConfig{
+ id: params.ClientID, maxWorkers: 1, probe: probe, queue: river.QueueDefault,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := client.Start(ctx); err != nil {
+ return nil, err
+ }
+ job, waitErr := waitForStates(ctx, client, params.ID, nil)
+ stopCtx, stopCancel := context.WithTimeout(ctx, 10*time.Second)
+ defer stopCancel()
+ stopErr := client.Stop(stopCtx)
+ if waitErr != nil {
+ return nil, waitErr
+ }
+ if stopErr != nil {
+ return nil, stopErr
+ }
+ return normalizeJob(job), nil
+
+ case "tx_begin":
+ handle, err := requestHandle(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ if _, ok := s.transactions[handle]; ok {
+ return nil, fmt.Errorf("transaction %q already exists", handle)
+ }
+ tx, err := s.pool.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ s.transactions[handle] = tx
+ return map[string]any{}, nil
+
+ case "tx_insert":
+ var params struct {
+ Handle string `json:"handle"`
+ Job insertParams `json:"job"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if err := params.Job.rejectRawOnlyFields(); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ opts, err := params.Job.Opts.opts()
+ if err != nil {
+ return nil, err
+ }
+ result, err := s.client().InsertTx(ctx, tx, params.Job.args(), opts)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(result.Job), nil
+
+ case "tx_insert_many":
+ var params struct {
+ Handle string `json:"handle"`
+ Jobs json.RawMessage `json:"jobs"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ jobs, err := decodeInsertManyParams(params.Jobs)
+ if err != nil {
+ return nil, err
+ }
+ results, err := s.client().InsertManyTx(ctx, tx, jobs)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeInsertManyResults(results), nil
+
+ case "tx_get", "tx_cancel", "tx_delete", "tx_retry":
+ var params struct {
+ Handle string `json:"handle"`
+ ID int64 `json:"id"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ client := s.client()
+ var job *rivertype.JobRow
+ var err error
+ switch req.Method {
+ case "tx_cancel":
+ job, err = client.JobCancelTx(ctx, tx, params.ID)
+ case "tx_delete":
+ job, err = client.JobDeleteTx(ctx, tx, params.ID)
+ case "tx_get":
+ job, err = client.JobGetTx(ctx, tx, params.ID)
+ case "tx_retry":
+ job, err = client.JobRetryTx(ctx, tx, params.ID)
+ }
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "tx_update":
+ var params struct {
+ Handle string `json:"handle"`
+ ID int64 `json:"id"`
+ Output any `json:"output"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ job, err := s.client().JobUpdateTx(ctx, tx, params.ID, &river.JobUpdateParams{Output: params.Output})
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJob(job), nil
+
+ case "tx_list":
+ params, handle, err := makeJobListParams(req.Params, true)
+ if err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[handle]
+ if !ok {
+ return nil, transactionNotFound(handle)
+ }
+ result, err := s.client().JobListTx(ctx, tx, params)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeJobListResult(result)
+
+ case "tx_delete_many":
+ params, handle, err := makeJobDeleteManyParams(req.Params, true)
+ if err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[handle]
+ if !ok {
+ return nil, transactionNotFound(handle)
+ }
+ result, err := s.client().JobDeleteManyTx(ctx, tx, params)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"jobs": normalizeJobs(result.Jobs)}, nil
+
+ case "tx_queue_get":
+ var params struct {
+ Handle string `json:"handle"`
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ queue, err := s.client().QueueGetTx(ctx, tx, params.Name)
+ if err != nil {
+ return nil, err
+ }
+ return normalizeQueue(queue), nil
+
+ case "tx_queue_list":
+ var params struct {
+ Handle string `json:"handle"`
+ Limit int `json:"limit"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Limit == 0 {
+ params.Limit = 100
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ result, err := s.client().QueueListTx(ctx, tx, river.NewQueueListParams().First(params.Limit))
+ if err != nil {
+ return nil, err
+ }
+ queues := make([]any, len(result.Queues))
+ for i, queue := range result.Queues {
+ queues[i] = normalizeQueue(queue)
+ }
+ return map[string]any{"queues": queues}, nil
+
+ case "tx_queue_pause", "tx_queue_resume":
+ var params struct {
+ Handle string `json:"handle"`
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ var err error
+ if req.Method == "tx_queue_pause" {
+ err = s.client().QueuePauseTx(ctx, tx, params.Name, nil)
+ } else {
+ err = s.client().QueueResumeTx(ctx, tx, params.Name, nil)
+ }
+ return map[string]any{}, err
+
+ case "tx_queue_update":
+ var params struct {
+ Handle string `json:"handle"`
+ Metadata json.RawMessage `json:"metadata"`
+ Name string `json:"name"`
+ }
+ if err := decodeParams(req.Params, ¶ms); err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[params.Handle]
+ if !ok {
+ return nil, transactionNotFound(params.Handle)
+ }
+ queue, err := s.client().QueueUpdateTx(ctx, tx, params.Name, &river.QueueUpdateParams{Metadata: params.Metadata})
+ if err != nil {
+ return nil, err
+ }
+ return normalizeQueue(queue), nil
+
+ case "tx_commit", "tx_rollback":
+ handle, err := requestHandle(req.Params)
+ if err != nil {
+ return nil, err
+ }
+ tx, ok := s.transactions[handle]
+ if !ok {
+ return nil, transactionNotFound(handle)
+ }
+ delete(s.transactions, handle)
+ if req.Method == "tx_commit" {
+ return map[string]any{}, tx.Commit()
+ }
+ return map[string]any{}, tx.Rollback()
+ }
+
+ return nil, methodNotFound(req.Method)
+}
+
+func (s *sqliteAdapterState) client() *river.Client[*sql.Tx] {
+ if s.running != nil {
+ return s.running.client
+ }
+ client, err := river.NewClient(riversqlite.New(s.pool), &river.Config{Logger: adapterLogger()})
+ if err != nil {
+ panic(fmt.Sprintf("build SQLite conformance client: %v", err))
+ }
+ return client
+}
+
+// defaultRetryDelay evaluates River's production default retry policy for a
+// job with errorCount-1 recorded errors. Its jitter is process-random, so
+// the rng_seed control does not apply to the Go reference.
+func defaultRetryDelay(now time.Time, jobID int64, errorCount uint32) time.Duration {
+ job := &rivertype.JobRow{ID: jobID, Errors: make([]rivertype.AttemptError, errorCount-1)}
+ return retrypolicy.NextRetryAt(now, job).Sub(now)
+}
+
+func durationFromMilliseconds(milliseconds uint64) (time.Duration, error) {
+ duration, err := time.ParseDuration(strconv.FormatUint(milliseconds, 10) + "ms")
+ if err != nil {
+ return 0, fmt.Errorf("milliseconds out of range: %w", err)
+ }
+ return duration, nil
+}
+
+func (s *adapterState) client() (*river.Client[pgx.Tx], error) {
+ return s.clientForSchema("")
+}
+
+func (s *adapterState) clientForSchema(schema string) (*river.Client[pgx.Tx], error) {
+ if s.running != nil {
+ if s.running.client.Schema() != schema {
+ return nil, fmt.Errorf("running client schema %q does not match requested schema %q", s.running.client.Schema(), schema)
+ }
+ return s.running.client, nil
+ }
+ return river.NewClient(riverpgxv5.New(s.pool), &river.Config{Logger: adapterLogger(), Schema: schema})
+}
+
+type workerClientConfig struct {
+ claimBarrier string
+ errorHandlerCancel bool
+ fetchOnlyKnownKinds bool
+ fetchPollIntervalMS *uint64
+ id string
+ instrumented bool
+ jobStuckThresholdMS *uint64
+ jobTimeoutMS *uint64
+ leaderElectionDisabled bool
+ maintenance maintenanceParams
+ maxWorkers int
+ periodicRunOnStart bool
+ periodicUnique bool
+ pollOnly bool
+ probe *runtimeProbe
+ queue string
+ rescueAfterMS *uint64
+ retryDelayMS *uint64
+ schema string
+ workerKinds []string
+}
+
+// maintenanceParams are optional `start` parameters that tune leader-owned
+// maintenance. Parameters River Go does not expose, such as elect or cleaner
+// intervals, are accepted by other adapters and ignored here because Go runs
+// each service once as soon as it gains leadership.
+type maintenanceParams struct {
+ CancelledJobRetentionMS *int64 `json:"cancelled_job_retention_ms"`
+ CompletedJobRetentionMS *int64 `json:"completed_job_retention_ms"`
+ DiscardedJobRetentionMS *int64 `json:"discarded_job_retention_ms"`
+
+ // River Go doesn't expose the cleaners' intervals. The contract lets an
+ // adapter ignore these, so the Go reference accepts and ignores them.
+ JobCleanerIntervalMS *uint64 `json:"job_cleaner_interval_ms"`
+ QueueCleanerIntervalMS *uint64 `json:"queue_cleaner_interval_ms"`
+
+ JobTimeoutDisabled bool `json:"job_timeout_disabled"`
+ ReindexerIndexNames []string `json:"reindexer_index_names"`
+ ReindexerIntervalMS *uint64 `json:"reindexer_interval_ms"`
+}
+
+func (p maintenanceParams) apply(config *river.Config) error {
+ retention := func(milliseconds *int64, target *time.Duration) error {
+ switch {
+ case milliseconds == nil:
+ return nil
+ case *milliseconds == -1:
+ *target = -1
+ return nil
+ case *milliseconds < 0:
+ return errors.New("job retention must be -1 or non-negative")
+ default:
+ duration, err := durationFromMilliseconds(uint64(*milliseconds))
+ *target = duration
+ return err
+ }
+ }
+ if err := retention(p.CancelledJobRetentionMS, &config.CancelledJobRetentionPeriod); err != nil {
+ return err
+ }
+ if err := retention(p.CompletedJobRetentionMS, &config.CompletedJobRetentionPeriod); err != nil {
+ return err
+ }
+ if err := retention(p.DiscardedJobRetentionMS, &config.DiscardedJobRetentionPeriod); err != nil {
+ return err
+ }
+ if p.JobTimeoutDisabled {
+ config.JobTimeout = -1
+ }
+ if p.ReindexerIndexNames != nil {
+ config.ReindexerIndexNames = p.ReindexerIndexNames
+ }
+ if p.ReindexerIntervalMS != nil {
+ interval, err := durationFromMilliseconds(*p.ReindexerIntervalMS)
+ if err != nil {
+ return err
+ }
+ config.ReindexerSchedule = river.PeriodicInterval(interval)
+ }
+ return nil
+}
+
+// handleCronNext evaluates River Go's documented cron syntax, robfig/cron's
+// `ParseStandard`, from a reference time in that time's own offset.
+func handleCronNext(rawParams json.RawMessage) (any, error) {
+ var params struct {
+ Count int `json:"count"`
+ Expression string `json:"expression"`
+ From time.Time `json:"from"`
+ }
+ if err := json.Unmarshal(rawParams, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Count < 1 {
+ return nil, errors.New("count must be positive")
+ }
+ schedule, err := cron.ParseStandard(params.Expression)
+ if err != nil {
+ return nil, err
+ }
+ // The schedule is evaluated in `from`'s fixed offset. Decoding a time
+ // whose offset matches the host's zone yields `time.Local` instead, which
+ // would move occurrences across that zone's DST changes and make the
+ // result depend on the host.
+ _, offset := params.From.Zone()
+ next := make([]string, 0, params.Count)
+ current := params.From.In(time.FixedZone("", offset))
+ for range params.Count {
+ current = schedule.Next(current)
+ if current.IsZero() {
+ break
+ }
+ next = append(next, current.Format(time.RFC3339Nano))
+ }
+ return map[string]any{"next": next}, nil
+}
+
+func newWorkerClient(pool *pgxpool.Pool, barriers *barrierRegistry, config workerClientConfig) (*river.Client[pgx.Tx], error) {
+ riverConfig, err := newWorkerConfig(pool, barriers, config)
+ if err != nil {
+ return nil, err
+ }
+ return river.NewClient(withClaimBarrier[pgx.Tx](riverpgxv5.New(pool), barriers, config.claimBarrier), riverConfig)
+}
+
+func newSQLiteWorkerClient(pool *sql.DB, barriers *barrierRegistry, config workerClientConfig) (*river.Client[*sql.Tx], error) {
+ riverConfig, err := newWorkerConfig(nil, barriers, config)
+ if err != nil {
+ return nil, err
+ }
+ return river.NewClient(withClaimBarrier[*sql.Tx](riversqlite.New(pool), barriers, config.claimBarrier), riverConfig)
+}
+
+// claimBarrierDriver installs a claimBarrierPilot through the driver plugin
+// hook River's client checks for when it's built.
+type claimBarrierDriver[TTx any] struct {
+ riverdriver.Driver[TTx]
+
+ pilot *claimBarrierPilot
+}
+
+func (d *claimBarrierDriver[TTx]) PluginInit(*baseservice.Archetype) {}
+
+func (d *claimBarrierDriver[TTx]) PluginPilot() riverpilot.Pilot { return d.pilot }
+
+// claimBarrierPilot is River's standard pilot, except that its first claim
+// returning jobs holds them until the named barrier is released. The claim
+// has already committed, so the jobs are running without an executor while
+// the producer keeps handling notifications, such as a cancellation.
+type claimBarrierPilot struct {
+ riverpilot.StandardPilot
+
+ barriers *barrierRegistry
+ name string
+ waited atomic.Bool
+}
+
+func (p *claimBarrierPilot) JobGetAvailable(ctx context.Context, exec riverdriver.Executor, state riverpilot.ProducerState, params *riverdriver.JobGetAvailableParams) (*riverdriver.JobGetAvailableResult, error) {
+ res, err := p.StandardPilot.JobGetAvailable(ctx, exec, state, params)
+ if err != nil || len(res.Jobs) == 0 || p.waited.Swap(true) {
+ return res, err
+ }
+ // The jobs are claimed either way, so they're returned however the wait
+ // ends. Stopping the client releases the barrier.
+ _ = p.barriers.wait(ctx, p.name)
+ return res, nil
+}
+
+// withClaimBarrier returns driver unchanged without a barrier name, and
+// otherwise wraps it to install a claimBarrierPilot.
+func withClaimBarrier[TTx any](driver riverdriver.Driver[TTx], barriers *barrierRegistry, name string) riverdriver.Driver[TTx] {
+ if name == "" {
+ return driver
+ }
+ return &claimBarrierDriver[TTx]{Driver: driver, pilot: &claimBarrierPilot{barriers: barriers, name: name}}
+}
+
+func newWorkerConfig(pool *pgxpool.Pool, barriers *barrierRegistry, config workerClientConfig) (*river.Config, error) {
+ workers := river.NewWorkers()
+ if err := addConformanceWorkers(workers, &conformanceWorker{barriers: barriers, pool: pool, probe: config.probe}, config.workerKinds); err != nil {
+ return nil, err
+ }
+ riverConfig := &river.Config{
+ ErrorHandler: nil,
+ FetchCooldown: time.Millisecond,
+ FetchOnlyKnownKinds: config.fetchOnlyKnownKinds,
+ FetchPollInterval: 10 * time.Millisecond,
+ ID: config.id,
+ LeaderElectionDisabled: config.leaderElectionDisabled,
+ Logger: adapterLogger(),
+ PollOnly: config.pollOnly,
+ Queues: map[string]river.QueueConfig{
+ config.queue: {MaxWorkers: config.maxWorkers},
+ },
+ Schema: config.schema,
+ TestOnly: true,
+ Workers: workers,
+ }
+ riverConfig.JobStuckHandler = func(context.Context, river.JobStuckHandlerParams) river.JobStuckHandlerResult {
+ config.probe.incrementStuckJobs()
+ return river.JobStuckHandlerResult{}
+ }
+ if config.rescueAfterMS != nil {
+ duration, err := durationFromMilliseconds(*config.rescueAfterMS)
+ if err != nil {
+ return nil, err
+ }
+ riverConfig.RescueStuckJobsAfter = duration
+ }
+ if config.fetchPollIntervalMS != nil {
+ duration, err := durationFromMilliseconds(*config.fetchPollIntervalMS)
+ if err != nil {
+ return nil, err
+ }
+ riverConfig.FetchPollInterval = duration
+ }
+ if config.errorHandlerCancel {
+ riverConfig.ErrorHandler = &conformanceErrorHandler{probe: config.probe}
+ }
+ if config.instrumented {
+ riverConfig.Plugins = []rivertype.Plugin{&conformancePlugin{probe: config.probe}}
+ }
+ if config.jobStuckThresholdMS != nil {
+ duration, err := durationFromMilliseconds(*config.jobStuckThresholdMS)
+ if err != nil {
+ return nil, err
+ }
+ riverConfig.JobStuckThreshold = duration
+ }
+ if config.jobTimeoutMS != nil {
+ duration, err := durationFromMilliseconds(*config.jobTimeoutMS)
+ if err != nil {
+ return nil, err
+ }
+ riverConfig.JobTimeout = duration
+ }
+ if err := config.maintenance.apply(riverConfig); err != nil {
+ return nil, err
+ }
+ if config.periodicUnique && !config.periodicRunOnStart {
+ return nil, invalidParams(errors.New("periodic_unique requires periodic_run_on_start"))
+ }
+ if config.periodicRunOnStart {
+ var uniqueOpts river.UniqueOpts
+ if config.periodicUnique {
+ uniqueOpts = river.UniqueOpts{ByArgs: true, ByQueue: true}
+ }
+ riverConfig.PeriodicJobs = []*river.PeriodicJob{river.NewPeriodicJob(
+ river.PeriodicInterval(time.Hour),
+ func() (river.JobArgs, *river.InsertOpts) {
+ return conformanceArgs{Message: "periodic run on start"}, &river.InsertOpts{
+ Metadata: []byte(`{"periodic":true}`),
+ UniqueOpts: uniqueOpts,
+ }
+ },
+ &river.PeriodicJobOpts{ID: "conformance-periodic", RunOnStart: true},
+ )}
+ if config.periodicUnique {
+ // Added after the unique job, so its insertion shows the unique
+ // job's insertion was attempted.
+ riverConfig.PeriodicJobs = append(riverConfig.PeriodicJobs, river.NewPeriodicJob(
+ river.PeriodicInterval(time.Hour),
+ func() (river.JobArgs, *river.InsertOpts) {
+ return conformanceArgs{Message: "periodic marker"}, &river.InsertOpts{
+ Metadata: []byte(`{"periodic":true}`),
+ }
+ },
+ &river.PeriodicJobOpts{ID: "conformance-periodic-marker", RunOnStart: true},
+ ))
+ }
+ }
+ if config.retryDelayMS != nil {
+ duration, err := durationFromMilliseconds(*config.retryDelayMS)
+ if err != nil {
+ return nil, err
+ }
+ riverConfig.RetryPolicy = fixedRetryPolicy{delay: duration}
+ }
+ return riverConfig, nil
+}
+
+func adapterLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
+}
+
+func errorResponse(id any, code int, err error) response {
+ return response{
+ Error: &responseError{Code: code, Message: err.Error()},
+ ID: id,
+ JSONRPC: "2.0",
+ }
+}
+
+func exactJSONTokens(job *rivertype.JobRow) (map[string]any, error) {
+ var args map[string]json.RawMessage
+ if err := json.Unmarshal(job.EncodedArgs, &args); err != nil {
+ return nil, fmt.Errorf("decode exact args: %w", err)
+ }
+ var metadata map[string]json.RawMessage
+ if err := json.Unmarshal(job.Metadata, &metadata); err != nil {
+ return nil, fmt.Errorf("decode exact metadata: %w", err)
+ }
+ requiredToken := func(source map[string]json.RawMessage, key string) (string, error) {
+ raw, ok := source[key]
+ if !ok {
+ return "", fmt.Errorf("exact JSON key %q not found", key)
+ }
+ return string(raw), nil
+ }
+ decimal, err := requiredToken(args, "decimal")
+ if err != nil {
+ return nil, err
+ }
+ integer, err := requiredToken(args, "integer")
+ if err != nil {
+ return nil, err
+ }
+ negative, err := requiredToken(metadata, "negative")
+ if err != nil {
+ return nil, err
+ }
+ result := map[string]any{
+ "decimal": decimal,
+ "integer": integer,
+ "negative": negative,
+ }
+ for _, key := range []string{"big_integer", "beyond_float", "long_decimal"} {
+ if value, ok := metadata[key]; ok {
+ result[key] = string(value)
+ }
+ }
+ return result, nil
+}
+
+func normalizeJob(job *rivertype.JobRow) map[string]any {
+ var args any
+ if err := json.Unmarshal(job.EncodedArgs, &args); err != nil {
+ args = nil
+ }
+ var metadata any
+ if err := json.Unmarshal(job.Metadata, &metadata); err != nil {
+ metadata = nil
+ }
+ if metadataObject, ok := metadata.(map[string]any); ok {
+ delete(metadataObject, "river:unique_nonce")
+ }
+ attemptedBy := job.AttemptedBy
+ if attemptedBy == nil {
+ attemptedBy = []string{}
+ }
+ errorsNormalized := make([]any, len(job.Errors))
+ for i, attemptErr := range job.Errors {
+ errorsNormalized[i] = map[string]any{
+ "at": formatTime(attemptErr.At),
+ "attempt": attemptErr.Attempt,
+ "error": attemptErr.Error,
+ "trace": attemptErr.Trace,
+ }
+ }
+ var uniqueKey any
+ if job.UniqueKey != nil {
+ uniqueKey = hex.EncodeToString(job.UniqueKey)
+ }
+ return map[string]any{
+ "args": args,
+ "attempt": job.Attempt,
+ "attempted_at": formatOptionalTime(job.AttemptedAt),
+ "attempted_by": attemptedBy,
+ "created_at": formatTime(job.CreatedAt),
+ "errors": errorsNormalized,
+ "finalized_at": formatOptionalTime(job.FinalizedAt),
+ "id": job.ID,
+ "kind": job.Kind,
+ "max_attempts": job.MaxAttempts,
+ "metadata": metadata,
+ "priority": job.Priority,
+ "queue": job.Queue,
+ "scheduled_at": formatTime(job.ScheduledAt),
+ "state": job.State,
+ "tags": valueOrEmpty(job.Tags),
+ "unique_key": uniqueKey,
+ "unique_states": job.UniqueStates,
+ }
+}
+
+func normalizeJobs(jobs []*rivertype.JobRow) []any {
+ normalized := make([]any, len(jobs))
+ for i, job := range jobs {
+ normalized[i] = normalizeJob(job)
+ }
+ return normalized
+}
+
+func decodeInsertManyParams(encoded json.RawMessage) ([]river.InsertManyParams, error) {
+ var envelope struct {
+ Jobs []insertParams `json:"jobs"`
+ }
+ if len(encoded) > 0 && encoded[0] == '[' {
+ if err := decodeParams(encoded, &envelope.Jobs); err != nil {
+ return nil, err
+ }
+ } else if err := decodeParams(encoded, &envelope); err != nil {
+ return nil, err
+ }
+ jobs := make([]river.InsertManyParams, len(envelope.Jobs))
+ for i, job := range envelope.Jobs {
+ if err := job.rejectRawOnlyFields(); err != nil {
+ return nil, err
+ }
+ opts, err := job.Opts.opts()
+ if err != nil {
+ return nil, err
+ }
+ jobs[i] = river.InsertManyParams{Args: job.args(), InsertOpts: opts}
+ }
+ return jobs, nil
+}
+
+func normalizeInsertManyResults(results []*rivertype.JobInsertResult) map[string]any {
+ normalized := make([]any, len(results))
+ for i, result := range results {
+ normalized[i] = map[string]any{
+ "job": normalizeJob(result.Job),
+ "unique_skipped_as_duplicate": result.UniqueSkippedAsDuplicate,
+ }
+ }
+ return map[string]any{"results": normalized}
+}
+
+func normalizeJobListResult(result *river.JobListResult) (map[string]any, error) {
+ var cursor any
+ if result.LastCursor != nil {
+ encoded, err := result.LastCursor.MarshalText()
+ if err != nil {
+ return nil, err
+ }
+ cursor = string(encoded)
+ }
+ return map[string]any{"cursor": cursor, "jobs": normalizeJobs(result.Jobs)}, nil
+}
+
+func normalizeQueue(queue *rivertype.Queue) map[string]any {
+ var metadata any
+ if err := json.Unmarshal(queue.Metadata, &metadata); err != nil {
+ metadata = nil
+ }
+ return map[string]any{
+ "created_at": formatTime(queue.CreatedAt),
+ "metadata": metadata,
+ "name": queue.Name,
+ "paused_at": formatOptionalTime(queue.PausedAt),
+ "updated_at": formatTime(queue.UpdatedAt),
+ }
+}
+
+func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) }
+
+func formatOptionalTime(value *time.Time) any {
+ if value == nil {
+ return nil
+ }
+ return formatTime(*value)
+}
+
+func requestID(paramsJSON json.RawMessage) (int64, error) {
+ var params struct {
+ ID int64 `json:"id"`
+ }
+ if err := decodeParams(paramsJSON, ¶ms); err != nil {
+ return 0, err
+ }
+ if params.ID < 1 {
+ return 0, invalidParams(errors.New("id must be positive"))
+ }
+ return params.ID, nil
+}
+
+func requestHandle(paramsJSON json.RawMessage) (string, error) {
+ var params struct {
+ Handle string `json:"handle"`
+ }
+ if err := decodeParams(paramsJSON, ¶ms); err != nil {
+ return "", err
+ }
+ if params.Handle == "" {
+ return "", invalidParams(errors.New("handle is required"))
+ }
+ return params.Handle, nil
+}
+
+func waitForStates[TTx any](ctx context.Context, client *river.Client[TTx], id int64, states []rivertype.JobState) (*rivertype.JobRow, error) {
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ if len(states) == 0 {
+ states = []rivertype.JobState{
+ rivertype.JobStateCancelled,
+ rivertype.JobStateCompleted,
+ rivertype.JobStateDiscarded,
+ }
+ }
+ for {
+ job, err := client.JobGet(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if slices.Contains(states, job.State) {
+ return job, nil
+ }
+ select {
+ case <-ctx.Done():
+ return nil, fmt.Errorf("job %d did not reach %v from state %s: %w", id, states, job.State, ctx.Err())
+ case <-time.After(10 * time.Millisecond):
+ }
+ }
+}
+
+// makeJobListParams decodes list filters and the transaction handle, which
+// only tx_list accepts.
+func makeJobListParams(raw json.RawMessage, transactional bool) (*river.JobListParams, string, error) {
+ var params struct {
+ After string `json:"after"`
+ Direction string `json:"direction"`
+ IDs []int64 `json:"ids"`
+ Kinds []string `json:"kinds"`
+ Limit int `json:"limit"`
+ Metadata json.RawMessage `json:"metadata"`
+ OrderBy string `json:"order_by"`
+ Priorities []int16 `json:"priorities"`
+ Queues []string `json:"queues"`
+ States []rivertype.JobState `json:"states"`
+ Handle string `json:"handle"`
+ TagsAll []string `json:"tags_all"`
+ TagsAny []string `json:"tags_any"`
+ }
+ if err := decodeParams(raw, ¶ms); err != nil {
+ return nil, "", err
+ }
+ if err := checkHandle(params.Handle, transactional); err != nil {
+ return nil, "", err
+ }
+ if params.Limit == 0 {
+ params.Limit = 100
+ }
+ result := river.NewJobListParams().First(params.Limit)
+ if params.IDs != nil {
+ result = result.IDs(params.IDs...)
+ }
+ if params.Kinds != nil {
+ result = result.Kinds(params.Kinds...)
+ }
+ if params.Metadata != nil {
+ result = result.Metadata(string(params.Metadata))
+ }
+ if params.OrderBy != "" || params.Direction != "" {
+ field := river.JobListOrderByID
+ switch params.OrderBy {
+ case "", string(river.JobListOrderByID):
+ case string(river.JobListOrderByFinalizedAt):
+ field = river.JobListOrderByFinalizedAt
+ case string(river.JobListOrderByScheduledAt):
+ field = river.JobListOrderByScheduledAt
+ case string(river.JobListOrderByTime):
+ field = river.JobListOrderByTime
+ default:
+ return nil, "", invalidParams(fmt.Errorf("unsupported order_by %q", params.OrderBy))
+ }
+ direction := river.SortOrderAsc
+ switch params.Direction {
+ case "", "asc":
+ case "desc":
+ direction = river.SortOrderDesc
+ default:
+ return nil, "", invalidParams(fmt.Errorf("unsupported direction %q", params.Direction))
+ }
+ result = result.OrderBy(field, direction)
+ }
+ if params.Priorities != nil {
+ result = result.Priorities(params.Priorities...)
+ }
+ if params.Queues != nil {
+ result = result.Queues(params.Queues...)
+ }
+ if params.States != nil {
+ result = result.States(params.States...)
+ }
+ if params.TagsAll != nil {
+ result = result.TagsAll(params.TagsAll...)
+ }
+ if params.TagsAny != nil {
+ result = result.TagsAny(params.TagsAny...)
+ }
+ if params.After != "" {
+ var cursor river.JobListCursor
+ if err := cursor.UnmarshalText([]byte(params.After)); err != nil {
+ return nil, "", rejected(err)
+ }
+ result = result.After(&cursor)
+ }
+ return result, params.Handle, nil
+}
+
+// makeJobDeleteBeforeParams decodes delete_finalized params into one batch
+// of the job cleaner's deletion, covering every finalized state. A null or
+// absent `queues_included` decodes as nil, which matches every queue, while
+// an empty list stays non-nil and matches none.
+func makeJobDeleteBeforeParams(raw json.RawMessage) (*riverdriver.JobDeleteBeforeParams, error) {
+ var params struct {
+ Before time.Time `json:"before"`
+ Limit int `json:"limit"`
+ QueuesExcluded []string `json:"queues_excluded"`
+ QueuesIncluded []string `json:"queues_included"`
+ }
+ if err := decodeParams(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ if params.Before.IsZero() {
+ return nil, invalidParams(errors.New("before is required"))
+ }
+ if params.Limit < 1 {
+ return nil, invalidParams(errors.New("limit must be positive"))
+ }
+ return &riverdriver.JobDeleteBeforeParams{
+ CancelledDoDelete: true,
+ CancelledFinalizedAtHorizon: params.Before,
+ CompletedDoDelete: true,
+ CompletedFinalizedAtHorizon: params.Before,
+ DiscardedDoDelete: true,
+ DiscardedFinalizedAtHorizon: params.Before,
+ Max: params.Limit,
+ QueuesExcluded: params.QueuesExcluded,
+ QueuesIncluded: params.QueuesIncluded,
+ }, nil
+}
+
+// makeJobDeleteManyParams decodes bulk delete filters and the transaction
+// handle, which only tx_delete_many accepts.
+func makeJobDeleteManyParams(raw json.RawMessage, transactional bool) (*river.JobDeleteManyParams, string, error) {
+ var params struct {
+ All bool `json:"all"`
+ Handle string `json:"handle"`
+ IDs []int64 `json:"ids"`
+ Kinds []string `json:"kinds"`
+ Limit int `json:"limit"`
+ Queues []string `json:"queues"`
+ States []rivertype.JobState `json:"states"`
+ }
+ if err := decodeParams(raw, ¶ms); err != nil {
+ return nil, "", err
+ }
+ if err := checkHandle(params.Handle, transactional); err != nil {
+ return nil, "", err
+ }
+ if params.Limit == 0 {
+ params.Limit = 100
+ }
+ result := river.NewJobDeleteManyParams().First(params.Limit)
+ if params.All {
+ return result.UnsafeAll(), params.Handle, nil
+ }
+ if params.IDs != nil {
+ result = result.IDs(params.IDs...)
+ }
+ if params.Kinds != nil {
+ result = result.Kinds(params.Kinds...)
+ }
+ if params.Queues != nil {
+ result = result.Queues(params.Queues...)
+ }
+ if params.States != nil {
+ result = result.States(params.States...)
+ }
+ return result, params.Handle, nil
+}
+
+func valueOrZero[T any](value *T) T {
+ if value == nil {
+ var zero T
+ return zero
+ }
+ return *value
+}
+
+func valueOrEmpty[T any](values []T) []T {
+ if values == nil {
+ return []T{}
+ }
+ return values
+}
diff --git a/internal/cmd/syncrustmigrations/main.go b/internal/cmd/syncrustmigrations/main.go
new file mode 100644
index 000000000..8846a78dc
--- /dev/null
+++ b/internal/cmd/syncrustmigrations/main.go
@@ -0,0 +1,150 @@
+// Command syncrustmigrations mirrors River's canonical database migrations
+// into the publishable Rust migration crate and records their hashes for
+// cross-language conformance.
+package main
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+)
+
+type database struct {
+ canonicalDir string
+ manifestPath string
+ mirrorDir string
+ name string
+}
+
+type manifest struct {
+ Database string `json:"database"`
+ Files []manifestFile `json:"files"`
+ Line string `json:"line"`
+}
+
+type manifestFile struct {
+ Path string `json:"path"`
+ SHA256 string `json:"sha256"`
+}
+
+func main() {
+ check := flag.Bool("check", false, "check generated files without writing")
+ flag.Parse()
+
+ databases := []database{
+ {
+ canonicalDir: "riverdriver/riverpgxv5/migration/main",
+ manifestPath: "conformance/migrations.json",
+ mirrorDir: "rust/riverqueue-migrate/migrations/main",
+ name: "postgres",
+ },
+ {
+ canonicalDir: "riverdriver/riversqlite/migration/main",
+ manifestPath: "conformance/migrations-sqlite.json",
+ mirrorDir: "rust/riverqueue-migrate/migrations/sqlite/main",
+ name: "sqlite",
+ },
+ }
+ for _, database := range databases {
+ syncDatabase(database, *check)
+ }
+}
+
+func syncDatabase(database database, check bool) {
+ entries, err := os.ReadDir(database.canonicalDir)
+ if err != nil {
+ fatal(err)
+ }
+
+ var names []string
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
+ names = append(names, entry.Name())
+ }
+ }
+ slices.Sort(names)
+
+ generatedManifest := manifest{Database: database.name, Line: "main"}
+ for _, name := range names {
+ sourcePath := filepath.Join(database.canonicalDir, name)
+ contents, err := os.ReadFile(sourcePath)
+ if err != nil {
+ fatal(err)
+ }
+ hash := sha256.Sum256(contents)
+ generatedManifest.Files = append(generatedManifest.Files, manifestFile{
+ Path: filepath.ToSlash(sourcePath),
+ SHA256: hex.EncodeToString(hash[:]),
+ })
+
+ mirrorPath := filepath.Join(database.mirrorDir, name)
+ if check {
+ checkFile(mirrorPath, contents)
+ } else {
+ writeFile(mirrorPath, contents)
+ }
+ }
+ removeStaleMirrors(database.mirrorDir, names, check)
+
+ manifestContents, err := json.MarshalIndent(&generatedManifest, "", " ")
+ if err != nil {
+ fatal(err)
+ }
+ manifestContents = append(manifestContents, '\n')
+ if check {
+ checkFile(database.manifestPath, manifestContents)
+ } else {
+ writeFile(database.manifestPath, manifestContents)
+ }
+}
+
+func removeStaleMirrors(directory string, expected []string, check bool) {
+ entries, err := os.ReadDir(directory)
+ if err != nil {
+ fatal(err)
+ }
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") || slices.Contains(expected, entry.Name()) {
+ continue
+ }
+ path := filepath.Join(directory, entry.Name())
+ if check {
+ fatal(fmt.Errorf("generated file is stale: %s (run make generate/rust-migrations)", path))
+ }
+ if err := os.Remove(path); err != nil {
+ fatal(err)
+ }
+ }
+}
+
+func checkFile(path string, expected []byte) {
+ actual, err := os.ReadFile(path)
+ if err != nil {
+ fatal(fmt.Errorf("read generated file %s: %w", path, err))
+ }
+ if !bytes.Equal(actual, expected) {
+ fatal(fmt.Errorf("generated file is stale: %s (run make generate/rust-migrations)", path))
+ }
+}
+
+func fatal(err error) {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+}
+
+func writeFile(path string, contents []byte) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ fatal(err)
+ }
+ //nolint:gosec // Generated repository artifacts are intentionally world-readable.
+ if err := os.WriteFile(path, contents, 0o644); err != nil {
+ fatal(err)
+ }
+}
diff --git a/internal/retrypolicy/default.go b/internal/retrypolicy/default.go
index cbf0d1bae..3d1d1b5fb 100644
--- a/internal/retrypolicy/default.go
+++ b/internal/retrypolicy/default.go
@@ -39,6 +39,19 @@ func NextRetryAt(now time.Time, job *rivertype.JobRow) time.Time {
return now.Add(secondsAsCappedDuration(retrySeconds(errorCount)))
}
+// DelayBounds returns the smallest and largest delay NextRetryAt schedules
+// for a job with errorCount-1 recorded errors. Delays are errorCount^4
+// seconds with up to 10% jitter either way, capped at the maximum
+// time.Duration.
+func DelayBounds(errorCount int) (time.Duration, time.Duration) {
+ base := retrySecondsWithoutJitter(errorCount)
+ if base == maxDurationSeconds {
+ return maxDuration, maxDuration
+ }
+ return secondsAsCappedDuration(base - base*jitterFraction),
+ secondsAsCappedDuration(min(base+base*jitterFraction, maxDurationSeconds))
+}
+
// secondsAsCappedDuration converts seconds to a duration, returning the
// maximum duration for values at or above it. Converting an out-of-range
// float to an integer is implementation-specific in Go and yields the minimum
@@ -54,6 +67,10 @@ func secondsAsCappedDuration(seconds float64) time.Duration {
// The maximum value of a duration before it overflows. About 292 years.
const maxDuration time.Duration = 1<<63 - 1
+// jitterFraction is the largest fraction of a retry delay that jitter adds
+// or removes.
+const jitterFraction = 0.1
+
// Same as the above, but changed to a float represented in seconds.
var maxDurationSeconds = maxDuration.Seconds() //nolint:gochecknoglobals
@@ -71,7 +88,7 @@ func retrySeconds(attempt int) float64 {
}
// Jitter number of seconds +/- 10%.
- retrySeconds += retrySeconds * (rand.Float64()*0.2 - 0.1)
+ retrySeconds += retrySeconds * (rand.Float64()*2*jitterFraction - jitterFraction)
// Cap retrySeconds once more in case adding random jitter pushed it over
// maxDurationSeconds. (This should never realistically happen, but protect
diff --git a/internal/retrypolicy/default_test.go b/internal/retrypolicy/default_test.go
index 0ad54e38e..f9ec5c791 100644
--- a/internal/retrypolicy/default_test.go
+++ b/internal/retrypolicy/default_test.go
@@ -85,6 +85,27 @@ func TestDefault_NextRetry(t *testing.T) {
})
}
+func TestDelayBounds(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now().UTC()
+ for _, errorCount := range []int{1, 2, 11, 309, 310, 1_000} {
+ minDelay, maxDelay := DelayBounds(errorCount)
+ require.LessOrEqual(t, minDelay, maxDelay)
+ for range 20 {
+ delay := NextRetryAt(now, &rivertype.JobRow{Errors: make([]rivertype.AttemptError, errorCount-1)}).Sub(now)
+ require.GreaterOrEqual(t, delay, minDelay, "error count %d", errorCount)
+ require.LessOrEqual(t, delay, maxDelay, "error count %d", errorCount)
+ }
+ }
+ minDelay, maxDelay := DelayBounds(1)
+ require.Equal(t, 900*time.Millisecond, minDelay)
+ require.Equal(t, 1100*time.Millisecond, maxDelay)
+ minDelay, maxDelay = DelayBounds(310)
+ require.Equal(t, time.Duration(math.MaxInt64), minDelay)
+ require.Equal(t, time.Duration(math.MaxInt64), maxDelay)
+}
+
func TestRetrySeconds(t *testing.T) {
t.Parallel()
diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md
new file mode 100644
index 000000000..83a6a32e5
--- /dev/null
+++ b/rust/CHANGELOG.md
@@ -0,0 +1,32 @@
+# Changelog
+
+All notable changes to River's Rust crates are documented in this file. The
+workspace crates (`riverqueue`, `riverqueue-macros`, `riverqueue-migrate`,
+`riverqueue-cli`, and `riverqueue-test`) are versioned and released together.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+Changes to River for Go are recorded in the [repository changelog](../CHANGELOG.md).
+
+## [Unreleased]
+
+### Added
+
+- First preview release of River for Rust. `riverqueue` provides a typed,
+ Tokio-based client for PostgreSQL (through SQLx) and SQLite that shares
+ River's database schema and job protocol with River for Go, so Rust and Go
+ clients can insert and work jobs in the same database. It includes typed
+ workers, transactional inserts and completion, unique, scheduled, periodic,
+ and resumable jobs, queue management, job cancellation, events, hooks,
+ middleware, leader election, and maintenance services.
+- Requests run with `.tx(...)` use the caller's transaction directly, without
+ a savepoint or nested transaction, like River for Go's `*Tx` methods. A
+ request that returns an error may leave partial writes in that transaction,
+ so roll it back, or open your own savepoint around the request to continue.
+- `riverqueue-macros` provides `#[derive(JobArgs)]`, including unique options.
+- `riverqueue-migrate` applies and validates River's migration lines on
+ PostgreSQL and SQLite, sharing migration history with River for Go.
+- `riverqueue-cli` installs the `riverqueue` command for migrations and
+ benchmarks.
+- `riverqueue-test` provides fixtures, insertion assertions, and helpers for
+ running workers in tests.
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
new file mode 100644
index 000000000..407dca815
--- /dev/null
+++ b/rust/Cargo.lock
@@ -0,0 +1,2045 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "chrono-tz"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
+dependencies = [
+ "chrono",
+ "phf",
+]
+
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.6",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "const-oid",
+ "crypto-common 0.2.2",
+ "ctutils",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "either"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "etcetera"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
+
+[[package]]
+name = "flume"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "rand_core",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "md-5"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
+dependencies = [
+ "cfg-if",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
+dependencies = [
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "riverqueue"
+version = "0.49.0-alpha.1"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "base64",
+ "chrono",
+ "chrono-tz",
+ "futures-util",
+ "rand",
+ "riverqueue-macros",
+ "riverqueue-migrate",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "sqlx",
+ "thiserror",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "riverqueue-cli"
+version = "0.49.0-alpha.1"
+dependencies = [
+ "riverqueue",
+ "riverqueue-migrate",
+ "serde",
+ "sqlx",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "riverqueue-conformance"
+version = "0.49.0-alpha.1"
+dependencies = [
+ "async-trait",
+ "chrono",
+ "riverqueue",
+ "riverqueue-migrate",
+ "serde",
+ "serde_json",
+ "sqlx",
+ "tokio",
+]
+
+[[package]]
+name = "riverqueue-macros"
+version = "0.49.0-alpha.1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "riverqueue",
+ "serde",
+ "syn 2.0.119",
+ "trybuild",
+]
+
+[[package]]
+name = "riverqueue-migrate"
+version = "0.49.0-alpha.1"
+dependencies = [
+ "serde_json",
+ "sqlx",
+ "thiserror",
+ "tokio",
+]
+
+[[package]]
+name = "riverqueue-test"
+version = "0.49.0-alpha.1"
+dependencies = [
+ "chrono",
+ "riverqueue",
+ "riverqueue-migrate",
+ "serde",
+ "serde_json",
+ "sqlx",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "sha1"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "siphasher"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
+dependencies = [
+ "base64",
+ "bytes",
+ "cfg-if",
+ "chrono",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.16.1",
+ "hashlink",
+ "indexmap",
+ "log",
+ "memchr",
+ "percent-encoding",
+ "rustls",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "smallvec",
+ "thiserror",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+ "webpki-roots",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
+dependencies = [
+ "cfg-if",
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "sqlx-core",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+ "syn 2.0.119",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-mysql"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
+dependencies = [
+ "bitflags",
+ "byteorder",
+ "bytes",
+ "chrono",
+ "crc",
+ "digest 0.11.3",
+ "dotenvy",
+ "either",
+ "futures-core",
+ "futures-util",
+ "generic-array",
+ "log",
+ "percent-encoding",
+ "serde",
+ "sha1",
+ "sha2 0.11.0",
+ "sqlx-core",
+ "thiserror",
+ "tracing",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "chrono",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "rand",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
+dependencies = [
+ "atoi",
+ "chrono",
+ "flume",
+ "form_urlencoded",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "sqlx-core",
+ "thiserror",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "target-tuple"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "876fef147edbcbddc8ac5cbbba92c7b86519e314e86638596c09673b2ed01e7f"
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.6+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "sharded-slab",
+ "thread_local",
+ "tracing-core",
+]
+
+[[package]]
+name = "trybuild"
+version = "1.0.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0cabaa10be1917331a313866bd94526343e03c77bcf69144b62b072ad35d47c"
+dependencies = [
+ "glob",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "target-tuple",
+ "termcolor",
+ "toml",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "whoami"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
new file mode 100644
index 000000000..3c084a513
--- /dev/null
+++ b/rust/Cargo.toml
@@ -0,0 +1,57 @@
+[workspace]
+members = [
+ "riverqueue",
+ "riverqueue-cli",
+ "riverqueue-conformance",
+ "riverqueue-macros",
+ "riverqueue-migrate",
+ "riverqueue-test",
+]
+resolver = "3"
+
+[workspace.package]
+authors = ["Riverqueue contributors"]
+edition = "2024"
+homepage = "https://riverqueue.com"
+license = "MPL-2.0"
+repository = "https://github.com/riverqueue/river"
+rust-version = "1.95"
+version = "0.49.0-alpha.1"
+
+[workspace.dependencies]
+async-trait = "0.1.92"
+base64 = "0.22.1"
+chrono = { version = "0.4.45", features = ["serde"] }
+futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
+proc-macro2 = "1.0.107"
+quote = "1.0.47"
+rand = "0.10.2"
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = { version = "1.0.151", features = ["raw_value"] }
+sha2 = "0.11.0"
+sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio"] }
+syn = { version = "2.0", features = ["full"] }
+thiserror = "2.0.20"
+tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] }
+tokio-util = { version = "0.7.19", features = ["rt"] }
+tracing = "0.1.44"
+
+[workspace.lints.rust]
+missing_debug_implementations = "warn"
+unsafe_code = "forbid"
+# `--cfg river_postgres_tests` builds the PostgreSQL integration tests, which
+# need `RIVER_RUST_DATABASE_URL`; `make test/rust` sets it when the URL is set.
+unexpected_cfgs = { level = "warn", check-cfg = ["cfg(river_postgres_tests)"] }
+
+[workspace.lints.clippy]
+all = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+doc_markdown = "allow"
+missing_errors_doc = "warn"
+module_name_repetitions = "allow"
+must_use_candidate = "allow"
+
+# Line tables keep file and line numbers in backtraces and panics at a
+# fraction of full debug info's size. The `test` profile inherits this.
+[profile.dev]
+debug = "line-tables-only"
diff --git a/rust/README.md b/rust/README.md
new file mode 100644
index 000000000..e276f422c
--- /dev/null
+++ b/rust/README.md
@@ -0,0 +1,78 @@
+# River for Rust (preview)
+
+This workspace contains River's Rust implementation. It shares River's
+database schema and job protocol with River for Go on PostgreSQL and SQLite,
+with an API designed for Rust and Tokio. The crates are a pre-release
+preview. Shared cross-language fixtures live in
+[`../conformance`](../conformance).
+
+## Workspace crates
+
+- `riverqueue`: typed client, worker runtime, CRUD, queues, events, extensions,
+ periodic/resumable jobs, and maintenance.
+- `riverqueue-macros`: `#[derive(JobArgs)]`.
+- `riverqueue-migrate`: canonical River migration lines.
+- `riverqueue-cli`: the `riverqueue` command-line program for migrations and
+ benchmarks.
+- `riverqueue-test`: typed fixtures and worker-test helpers.
+- `riverqueue-conformance`: private verification package.
+
+The API uses a caller-owned SQLx pool, Tokio, typed workers, and
+`CancellationToken`. `Client` isn't generic over the database: it accepts a
+PostgreSQL or SQLite pool, and there's no driver trait to implement.
+
+## Quick start
+
+The [`riverqueue` crate README](riverqueue/README.md) walks through defining
+a job, registering a worker, inserting, and starting a client.
+
+To run Rust clients alongside River Go against one database, including
+version matching, queue and kind layout, unique jobs, and rolling deployment
+and rollback, see the
+[mixed deployment guide](riverqueue/docs/mixed-deployments.md), also published
+as `riverqueue::guide::mixed_deployments`.
+
+Runnable examples in `riverqueue/examples` cover workers and graceful
+shutdown, cancellation, transactional completion, unique and periodic jobs,
+event subscriptions, custom schemas, SQLite, and a mixed Go and Rust
+deployment; `riverqueue-migrate/examples` covers migrations.
+
+Run the Rust suite from the repository root:
+
+```sh
+make lint/rust
+make test/rust
+make doc/rust
+make check/rust/package
+```
+
+For basic end-to-end performance figures, the `riverqueue` binary from
+`riverqueue-cli` has the Rust equivalent of `river bench`. It truncates the selected River job table,
+so use a disposable database:
+
+```sh
+make bench/rust DATABASE_URL=postgres://localhost/river_bench \
+ RUST_BENCH_ARGS='--duration 30s'
+```
+
+The command supports continuous burn, fixed `--num-total-jobs` burn-down,
+custom schemas, tunable worker/pool/batch sizes, periodic jobs/sec output, and a
+final jobs/sec plus p95 end-to-end latency summary. Use `riverqueue bench
+--help` for all options. The conformance performance gate remains the
+reproducible Go/Rust comparison across enqueue-only, worker-only, and mixed
+workloads.
+
+PostgreSQL integration tests require a disposable database. They build only
+with `--cfg river_postgres_tests`, which the Makefile targets pass to rustc
+and rustdoc, building into `target/postgres-tests`:
+
+```sh
+RIVER_RUST_DATABASE_URL=postgres://localhost/river_rust_test \
+ make test/rust/postgres
+```
+
+`make check/rust/package` builds the five publishable crate archives and
+verifies that each one builds from its packaged sources, resolving the
+exact-version workspace dependencies from the other archives. It does not
+publish anything. Release tags use `riverqueue-vX.Y.Z`, independently of Go
+module tags.
diff --git a/rust/deny.toml b/rust/deny.toml
new file mode 100644
index 000000000..4aae2315c
--- /dev/null
+++ b/rust/deny.toml
@@ -0,0 +1,29 @@
+[graph]
+all-features = true
+
+[advisories]
+yanked = "deny"
+
+[licenses]
+allow = [
+ "Apache-2.0",
+ "BSD-3-Clause",
+ "CDLA-Permissive-2.0",
+ "ISC",
+ "MIT",
+ "MPL-2.0",
+ "Unicode-3.0",
+ "Zlib",
+]
+confidence-threshold = 0.8
+
+[bans]
+highlight = "all"
+multiple-versions = "warn"
+wildcards = "allow"
+
+[sources]
+allow-git = []
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
+unknown-git = "deny"
+unknown-registry = "deny"
diff --git a/rust/riverqueue-cli/Cargo.toml b/rust/riverqueue-cli/Cargo.toml
new file mode 100644
index 000000000..b509e8604
--- /dev/null
+++ b/rust/riverqueue-cli/Cargo.toml
@@ -0,0 +1,34 @@
+[package]
+name = "riverqueue-cli"
+description = "Command-line tools for River's Rust client: migrations and benchmarks"
+keywords = ["background", "jobs", "migrations", "postgres", "queue"]
+categories = ["command-line-utilities", "database"]
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+readme = "README.md"
+repository.workspace = true
+homepage.workspace = true
+
+[features]
+default = ["postgres", "sqlite"]
+postgres = ["riverqueue/postgres", "riverqueue-migrate/postgres", "sqlx/postgres"]
+sqlite = ["riverqueue/sqlite", "riverqueue-migrate/sqlite", "sqlx/sqlite"]
+
+[[bin]]
+name = "riverqueue"
+path = "src/main.rs"
+# The binary shares the library crate's name, so its docs would collide.
+doc = false
+
+[dependencies]
+riverqueue = { path = "../riverqueue", version = "=0.49.0-alpha.1", default-features = false }
+riverqueue-migrate = { path = "../riverqueue-migrate", version = "=0.49.0-alpha.1", default-features = false }
+serde.workspace = true
+sqlx = { workspace = true, features = ["tls-rustls"] }
+tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
+tokio-util.workspace = true
+
+[lints]
+workspace = true
diff --git a/rust/riverqueue-cli/LICENSE b/rust/riverqueue-cli/LICENSE
new file mode 120000
index 000000000..30cff7403
--- /dev/null
+++ b/rust/riverqueue-cli/LICENSE
@@ -0,0 +1 @@
+../../LICENSE
\ No newline at end of file
diff --git a/rust/riverqueue-cli/README.md b/rust/riverqueue-cli/README.md
new file mode 100644
index 000000000..1a27fb7ec
--- /dev/null
+++ b/rust/riverqueue-cli/README.md
@@ -0,0 +1,36 @@
+# riverqueue-cli
+
+Command-line tools for [River](https://riverqueue.com)'s Rust client. Install
+the `riverqueue` binary with:
+
+```sh
+cargo install riverqueue-cli
+```
+
+## Migrations
+
+River's schema is managed by versioned migrations shared with every River
+implementation. Apply them before starting clients:
+
+```sh
+riverqueue migrate-up --database-url postgres://localhost/app
+riverqueue migrate-up --database-url postgres://localhost/app --schema river
+riverqueue migrate-up --database-url sqlite://app.sqlite3
+```
+
+`migrate-down`, `migrate-list`, and `validate` take the same connection
+options. `--target-version N`, `--max-steps N`, and `--dry-run` limit or
+preview a migration run. Applications can instead migrate from Rust with the
+[`riverqueue-migrate`](https://docs.rs/riverqueue-migrate) crate.
+
+## Benchmark
+
+`riverqueue bench` measures worker throughput and end-to-end latency. It
+**truncates the River job table** in the selected database, so only point it at
+a disposable database:
+
+```sh
+riverqueue bench --database-url postgres://localhost/river_bench --duration 30s
+```
+
+Run `riverqueue bench --help` for its options.
diff --git a/rust/riverqueue-cli/src/bench.rs b/rust/riverqueue-cli/src/bench.rs
new file mode 100644
index 000000000..410d04e8e
--- /dev/null
+++ b/rust/riverqueue-cli/src/bench.rs
@@ -0,0 +1,741 @@
+//! The destructive `bench` command.
+
+use std::{
+ convert::Infallible,
+ error::Error as StdError,
+ io,
+ sync::{
+ Arc, OnceLock,
+ atomic::{AtomicU64, Ordering},
+ },
+ time::{Duration, Instant},
+};
+
+use riverqueue::{
+ Client, EventKind, EventReceiver, EventRecvError, InsertOpts, Job, JobArgs, QueueConfig,
+ SubscribeConfig, WorkContext, WorkOutcome, Worker, WorkerRegistry, database::SchemaName,
+};
+use serde::{Deserialize, Serialize};
+use sqlx::{
+ AssertSqlSafe, PgPool,
+ postgres::{PgConnectOptions, PgPoolOptions},
+};
+use tokio::sync::Notify;
+use tokio_util::sync::CancellationToken;
+
+const DEFAULT_BACKLOG: u64 = 75_000;
+const DEFAULT_BATCH_SIZE: usize = 5_000;
+const DEFAULT_MAX_CONNECTIONS: u32 = 50;
+const DEFAULT_MAX_WORKERS: usize = 2_000;
+const ITERATION_PERIOD: Duration = Duration::from_secs(2);
+
+pub(crate) const HELP: &str = r"Benchmark River's Rust worker runtime
+
+Usage:
+ riverqueue bench [options]
+
+The benchmark truncates the selected River job table, optionally vacuums it,
+then inserts and works no-op jobs while reporting rough throughput and p95
+end-to-end latency. Use only a disposable development or benchmark database.
+
+Options:
+ --database-url URL PostgreSQL URL (or set DATABASE_URL)
+ --schema NAME River schema (default: current schema)
+ --duration DURATION Stop after a Go-style duration such as 30s or 5m
+ -n, --num-total-jobs COUNT Insert COUNT jobs, then work them all
+ --backlog COUNT Target continuous-mode backlog (default: 75000)
+ --batch-size COUNT Jobs per insertion batch (default: 5000)
+ --max-connections COUNT SQLx pool size (default: 50)
+ --max-workers COUNT Concurrent workers (default: 2000)
+ --skip-vacuum Truncate without VACUUM FULL
+ -h, --help Print help
+
+With neither --duration nor --num-total-jobs, the benchmark runs until Ctrl-C.
+The two stopping options are mutually exclusive.
+";
+
+#[derive(Clone, Debug, Deserialize, JobArgs, Serialize)]
+#[river(kind = "benchmark")]
+struct BenchmarkArgs {
+ num: u64,
+}
+
+struct BenchmarkWorker;
+
+impl Worker for BenchmarkWorker {
+ type Error = Infallible;
+
+ fn work(
+ &self,
+ _context: WorkContext,
+ _job: Job,
+ ) -> impl std::future::Future